aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Makefile1
-rw-r--r--application/config/ConfigManager.php3
-rw-r--r--application/container/ContainerBuilder.php5
-rw-r--r--application/container/ShaarliContainer.php2
-rw-r--r--application/front/controller/admin/ManageShaareController.php36
-rw-r--r--application/front/controller/admin/MetadataController.php29
-rw-r--r--application/http/MetadataRetriever.php68
-rw-r--r--assets/common/js/metadata.js39
-rw-r--r--assets/default/js/base.js12
-rw-r--r--assets/default/scss/shaarli.scss51
-rw-r--r--doc/md/Shaarli-configuration.md1
-rw-r--r--index.php2
-rw-r--r--package.json1
-rw-r--r--tests/container/ContainerBuilderTest.php2
-rw-r--r--tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php118
-rw-r--r--tests/http/MetadataRetrieverTest.php123
-rw-r--r--tpl/default/editlink.html22
-rw-r--r--webpack.config.js2
-rw-r--r--yarn.lock5
19 files changed, 447 insertions, 75 deletions
diff --git a/Makefile b/Makefile
index 0ff6bd3f..7415887a 100644
--- a/Makefile
+++ b/Makefile
@@ -175,6 +175,7 @@ translate:
175eslint: 175eslint:
176 @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/ 176 @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/
177 @yarn run eslint -c .dev/.eslintrc.js assets/default/js/ 177 @yarn run eslint -c .dev/.eslintrc.js assets/default/js/
178 @yarn run eslint -c .dev/.eslintrc.js assets/common/js/
178 179
179### Run CSSLint check against Shaarli's SCSS files 180### Run CSSLint check against Shaarli's SCSS files
180sasslint: 181sasslint:
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index 4c98be30..fb085023 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -366,7 +366,8 @@ class ConfigManager
366 $this->setEmpty('general.links_per_page', 20); 366 $this->setEmpty('general.links_per_page', 20);
367 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); 367 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
368 $this->setEmpty('general.default_note_title', 'Note: '); 368 $this->setEmpty('general.default_note_title', 'Note: ');
369 $this->setEmpty('general.retrieve_description', false); 369 $this->setEmpty('general.retrieve_description', true);
370 $this->setEmpty('general.enable_async_metadata', true);
370 371
371 $this->setEmpty('updates.check_updates', false); 372 $this->setEmpty('updates.check_updates', false);
372 $this->setEmpty('updates.check_updates_branch', 'stable'); 373 $this->setEmpty('updates.check_updates_branch', 'stable');
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index c21d58dd..fd94a1c3 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -14,6 +14,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
14use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; 14use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
15use Shaarli\History; 15use Shaarli\History;
16use Shaarli\Http\HttpAccess; 16use Shaarli\Http\HttpAccess;
17use Shaarli\Http\MetadataRetriever;
17use Shaarli\Netscape\NetscapeBookmarkUtils; 18use Shaarli\Netscape\NetscapeBookmarkUtils;
18use Shaarli\Plugin\PluginManager; 19use Shaarli\Plugin\PluginManager;
19use Shaarli\Render\PageBuilder; 20use Shaarli\Render\PageBuilder;
@@ -90,6 +91,10 @@ class ContainerBuilder
90 ); 91 );
91 }; 92 };
92 93
94 $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
95 return new MetadataRetriever($container->conf, $container->httpAccess);
96 };
97
93 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { 98 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
94 return new PageBuilder( 99 return new PageBuilder(
95 $container->conf, 100 $container->conf,
diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php
index 66e669aa..3a7c238f 100644
--- a/application/container/ShaarliContainer.php
+++ b/application/container/ShaarliContainer.php
@@ -10,6 +10,7 @@ use Shaarli\Feed\FeedBuilder;
10use Shaarli\Formatter\FormatterFactory; 10use Shaarli\Formatter\FormatterFactory;
11use Shaarli\History; 11use Shaarli\History;
12use Shaarli\Http\HttpAccess; 12use Shaarli\Http\HttpAccess;
13use Shaarli\Http\MetadataRetriever;
13use Shaarli\Netscape\NetscapeBookmarkUtils; 14use Shaarli\Netscape\NetscapeBookmarkUtils;
14use Shaarli\Plugin\PluginManager; 15use Shaarli\Plugin\PluginManager;
15use Shaarli\Render\PageBuilder; 16use Shaarli\Render\PageBuilder;
@@ -35,6 +36,7 @@ use Slim\Container;
35 * @property History $history 36 * @property History $history
36 * @property HttpAccess $httpAccess 37 * @property HttpAccess $httpAccess
37 * @property LoginManager $loginManager 38 * @property LoginManager $loginManager
39 * @property MetadataRetriever $metadataRetriever
38 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils 40 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
39 * @property callable $notFoundHandler Overrides default Slim exception display 41 * @property callable $notFoundHandler Overrides default Slim exception display
40 * @property PageBuilder $pageBuilder 42 * @property PageBuilder $pageBuilder
diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
index bb083486..df2f1631 100644
--- a/application/front/controller/admin/ManageShaareController.php
+++ b/application/front/controller/admin/ManageShaareController.php
@@ -53,36 +53,22 @@ class ManageShaareController extends ShaarliAdminController
53 53
54 // If this is an HTTP(S) link, we try go get the page to extract 54 // If this is an HTTP(S) link, we try go get the page to extract
55 // the title (otherwise we will to straight to the edit form.) 55 // the title (otherwise we will to straight to the edit form.)
56 if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) { 56 if (true !== $this->container->conf->get('general.enable_async_metadata', true)
57 $retrieveDescription = $this->container->conf->get('general.retrieve_description'); 57 && empty($title)
58 // Short timeout to keep the application responsive 58 && strpos(get_url_scheme($url) ?: '', 'http') !== false
59 // The callback will fill $charset and $title with data from the downloaded page. 59 ) {
60 $this->container->httpAccess->getHttpResponse( 60 $metadata = $this->container->metadataRetriever->retrieve($url);
61 $url,
62 $this->container->conf->get('general.download_timeout', 30),
63 $this->container->conf->get('general.download_max_size', 4194304),
64 $this->container->httpAccess->getCurlDownloadCallback(
65 $charset,
66 $title,
67 $description,
68 $tags,
69 $retrieveDescription
70 )
71 );
72 if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
73 $title = mb_convert_encoding($title, 'utf-8', $charset);
74 }
75 } 61 }
76 62
77 if (empty($url) && empty($title)) { 63 if (empty($url)) {
78 $title = $this->container->conf->get('general.default_note_title', t('Note: ')); 64 $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
79 } 65 }
80 66
81 $link = [ 67 $link = [
82 'title' => $title, 68 'title' => $title ?? $metadata['title'] ?? '',
83 'url' => $url ?? '', 69 'url' => $url ?? '',
84 'description' => $description ?? '', 70 'description' => $description ?? $metadata['description'] ?? '',
85 'tags' => $tags ?? '', 71 'tags' => $tags ?? $metadata['tags'] ?? '',
86 'private' => $private, 72 'private' => $private,
87 ]; 73 ];
88 } else { 74 } else {
@@ -352,6 +338,8 @@ class ManageShaareController extends ShaarliAdminController
352 'source' => $request->getParam('source') ?? '', 338 'source' => $request->getParam('source') ?? '',
353 'tags' => $tags, 339 'tags' => $tags,
354 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), 340 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
341 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
342 'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
355 ]); 343 ]);
356 344
357 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); 345 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644
index 00000000..ff845944
--- /dev/null
+++ b/application/front/controller/admin/MetadataController.php
@@ -0,0 +1,29 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Controller used to retrieve/update bookmark's metadata.
12 */
13class MetadataController extends ShaarliAdminController
14{
15 /**
16 * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
17 */
18 public function ajaxRetrieveTitle(Request $request, Response $response): Response
19 {
20 $url = $request->getParam('url');
21
22 // Only try to extract metadata from URL with HTTP(s) scheme
23 if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
24 return $response->withJson($this->container->metadataRetriever->retrieve($url));
25 }
26
27 return $response->withJson([]);
28 }
29}
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644
index 00000000..2ca982e2
--- /dev/null
+++ b/application/http/MetadataRetriever.php
@@ -0,0 +1,68 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7use Shaarli\Config\ConfigManager;
8
9/**
10 * HTTP Tool used to extract metadata from external URL (title, description, etc.).
11 */
12class MetadataRetriever
13{
14 /** @var ConfigManager */
15 protected $conf;
16
17 /** @var HttpAccess */
18 protected $httpAccess;
19
20 public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
21 {
22 $this->conf = $conf;
23 $this->httpAccess = $httpAccess;
24 }
25
26 /**
27 * Retrieve metadata for given URL.
28 *
29 * @return array [
30 * 'title' => <remote title>,
31 * 'description' => <remote description>,
32 * 'tags' => <remote keywords>,
33 * ]
34 */
35 public function retrieve(string $url): array
36 {
37 $charset = null;
38 $title = null;
39 $description = null;
40 $tags = null;
41 $retrieveDescription = $this->conf->get('general.retrieve_description');
42
43 // Short timeout to keep the application responsive
44 // The callback will fill $charset and $title with data from the downloaded page.
45 $this->httpAccess->getHttpResponse(
46 $url,
47 $this->conf->get('general.download_timeout', 30),
48 $this->conf->get('general.download_max_size', 4194304),
49 $this->httpAccess->getCurlDownloadCallback(
50 $charset,
51 $title,
52 $description,
53 $tags,
54 $retrieveDescription
55 )
56 );
57
58 if (!empty($title) && strtolower($charset) !== 'utf-8') {
59 $title = mb_convert_encoding($title, 'utf-8', $charset);
60 }
61
62 return [
63 'title' => $title,
64 'description' => $description,
65 'tags' => $tags,
66 ];
67 }
68}
diff --git a/assets/common/js/metadata.js b/assets/common/js/metadata.js
new file mode 100644
index 00000000..5200b481
--- /dev/null
+++ b/assets/common/js/metadata.js
@@ -0,0 +1,39 @@
1import he from 'he';
2
3function clearLoaders(loaders) {
4 if (loaders != null && loaders.length > 0) {
5 [...loaders].forEach((loader) => {
6 loader.classList.remove('loading-input');
7 });
8 }
9}
10
11(() => {
12 const loaders = document.querySelectorAll('.loading-input');
13 const inputTitle = document.querySelector('input[name="lf_title"]');
14 if (inputTitle != null && inputTitle.value.length > 0) {
15 clearLoaders(loaders);
16 return;
17 }
18
19 const url = document.querySelector('input[name="lf_url"]').value;
20 const basePath = document.querySelector('input[name="js_base_path"]').value;
21
22 const xhr = new XMLHttpRequest();
23 xhr.open('GET', `${basePath}/admin/metadata?url=${encodeURI(url)}`, true);
24 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
25 xhr.onload = () => {
26 const result = JSON.parse(xhr.response);
27 Object.keys(result).forEach((key) => {
28 if (result[key] !== null && result[key].length) {
29 const element = document.querySelector(`input[name="lf_${key}"], textarea[name="lf_${key}"]`);
30 if (element != null && element.value.length === 0) {
31 element.value = he.decode(result[key]);
32 }
33 }
34 });
35 clearLoaders(loaders);
36 };
37
38 xhr.send();
39})();
diff --git a/assets/default/js/base.js b/assets/default/js/base.js
index be986ae0..31688815 100644
--- a/assets/default/js/base.js
+++ b/assets/default/js/base.js
@@ -1,4 +1,5 @@
1import Awesomplete from 'awesomplete'; 1import Awesomplete from 'awesomplete';
2import he from 'he';
2 3
3/** 4/**
4 * Find a parent element according to its tag and its attributes 5 * Find a parent element according to its tag and its attributes
@@ -96,15 +97,6 @@ function updateAwesompleteList(selector, tags, instances) {
96} 97}
97 98
98/** 99/**
99 * html_entities in JS
100 *
101 * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
102 */
103function htmlEntities(str) {
104 return str.replace(/[\u00A0-\u9999<>&]/gim, (i) => `&#${i.charCodeAt(0)};`);
105}
106
107/**
108 * Add the class 'hidden' to city options not attached to the current selected continent. 100 * Add the class 'hidden' to city options not attached to the current selected continent.
109 * 101 *
110 * @param cities List of <option> elements 102 * @param cities List of <option> elements
@@ -569,7 +561,7 @@ function init(description) {
569 input.setAttribute('name', totag); 561 input.setAttribute('name', totag);
570 input.setAttribute('value', totag); 562 input.setAttribute('value', totag);
571 findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none'; 563 findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
572 block.querySelector('a.tag-link').innerHTML = htmlEntities(totag); 564 block.querySelector('a.tag-link').innerHTML = he.encode(totag);
573 block 565 block
574 .querySelector('a.tag-link') 566 .querySelector('a.tag-link')
575 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`); 567 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
diff --git a/assets/default/scss/shaarli.scss b/assets/default/scss/shaarli.scss
index a528adb0..df9c867b 100644
--- a/assets/default/scss/shaarli.scss
+++ b/assets/default/scss/shaarli.scss
@@ -1269,6 +1269,57 @@ form {
1269 } 1269 }
1270} 1270}
1271 1271
1272.loading-input {
1273 position: relative;
1274
1275 @keyframes around {
1276 0% {
1277 transform: rotate(0deg);
1278 }
1279
1280 100% {
1281 transform: rotate(360deg);
1282 }
1283 }
1284
1285 .icon-container {
1286 position: absolute;
1287 right: 60px;
1288 top: calc(50% - 10px);
1289 }
1290
1291 .loader {
1292 position: relative;
1293 height: 20px;
1294 width: 20px;
1295 display: inline-block;
1296 animation: around 5.4s infinite;
1297
1298 &::after,
1299 &::before {
1300 content: "";
1301 background: $form-input-background;
1302 position: absolute;
1303 display: inline-block;
1304 width: 100%;
1305 height: 100%;
1306 border-width: 2px;
1307 border-color: #333 #333 transparent transparent;
1308 border-style: solid;
1309 border-radius: 20px;
1310 box-sizing: border-box;
1311 top: 0;
1312 left: 0;
1313 animation: around 0.7s ease-in-out infinite;
1314 }
1315
1316 &::after {
1317 animation: around 0.7s ease-in-out 0.1s infinite;
1318 background: transparent;
1319 }
1320 }
1321}
1322
1272// LOGIN 1323// LOGIN
1273.login-form-container { 1324.login-form-container {
1274 .remember-me { 1325 .remember-me {
diff --git a/doc/md/Shaarli-configuration.md b/doc/md/Shaarli-configuration.md
index 263fb761..dbfc3da9 100644
--- a/doc/md/Shaarli-configuration.md
+++ b/doc/md/Shaarli-configuration.md
@@ -150,6 +150,7 @@ _These settings should not be edited_
150- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php). 150- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
151- **enabled_plugins**: List of enabled plugins. 151- **enabled_plugins**: List of enabled plugins.
152- **default_note_title**: Default title of a new note. 152- **default_note_title**: Default title of a new note.
153- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
153- **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags. 154- **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags.
154- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`. 155- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
155 156
diff --git a/index.php b/index.php
index b10397dd..220847f5 100644
--- a/index.php
+++ b/index.php
@@ -129,7 +129,7 @@ $app->group('/admin', function () {
129 $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save'); 129 $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
130 $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken'); 130 $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
131 $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index'); 131 $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
132 132 $this->get('/metadata', '\Shaarli\Front\Controller\Admin\MetadataController:ajaxRetrieveTitle');
133 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility'); 133 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
134})->add('\Shaarli\Front\ShaarliAdminMiddleware'); 134})->add('\Shaarli\Front\ShaarliAdminMiddleware');
135 135
diff --git a/package.json b/package.json
index 8a24512a..b879b223 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
7 "awesomplete": "^1.1.2", 7 "awesomplete": "^1.1.2",
8 "blazy": "^1.8.2", 8 "blazy": "^1.8.2",
9 "fork-awesome": "^1.1.7", 9 "fork-awesome": "^1.1.7",
10 "he": "^1.2.0",
10 "pure-extras": "^1.0.0", 11 "pure-extras": "^1.0.0",
11 "purecss": "^1.0.0" 12 "purecss": "^1.0.0"
12 }, 13 },
diff --git a/tests/container/ContainerBuilderTest.php b/tests/container/ContainerBuilderTest.php
index 5d52daef..3dadc0b9 100644
--- a/tests/container/ContainerBuilderTest.php
+++ b/tests/container/ContainerBuilderTest.php
@@ -12,6 +12,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
12use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; 12use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
13use Shaarli\History; 13use Shaarli\History;
14use Shaarli\Http\HttpAccess; 14use Shaarli\Http\HttpAccess;
15use Shaarli\Http\MetadataRetriever;
15use Shaarli\Netscape\NetscapeBookmarkUtils; 16use Shaarli\Netscape\NetscapeBookmarkUtils;
16use Shaarli\Plugin\PluginManager; 17use Shaarli\Plugin\PluginManager;
17use Shaarli\Render\PageBuilder; 18use Shaarli\Render\PageBuilder;
@@ -72,6 +73,7 @@ class ContainerBuilderTest extends TestCase
72 static::assertInstanceOf(History::class, $container->history); 73 static::assertInstanceOf(History::class, $container->history);
73 static::assertInstanceOf(HttpAccess::class, $container->httpAccess); 74 static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
74 static::assertInstanceOf(LoginManager::class, $container->loginManager); 75 static::assertInstanceOf(LoginManager::class, $container->loginManager);
76 static::assertInstanceOf(MetadataRetriever::class, $container->metadataRetriever);
75 static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils); 77 static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
76 static::assertInstanceOf(PageBuilder::class, $container->pageBuilder); 78 static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
77 static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager); 79 static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php b/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
index 2eb95251..4fd88480 100644
--- a/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
+++ b/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
@@ -9,6 +9,7 @@ use Shaarli\Config\ConfigManager;
9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; 9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
10use Shaarli\Front\Controller\Admin\ManageShaareController; 10use Shaarli\Front\Controller\Admin\ManageShaareController;
11use Shaarli\Http\HttpAccess; 11use Shaarli\Http\HttpAccess;
12use Shaarli\Http\MetadataRetriever;
12use Shaarli\TestCase; 13use Shaarli\TestCase;
13use Slim\Http\Request; 14use Slim\Http\Request;
14use Slim\Http\Response; 15use Slim\Http\Response;
@@ -25,6 +26,7 @@ class DisplayCreateFormTest extends TestCase
25 $this->createContainer(); 26 $this->createContainer();
26 27
27 $this->container->httpAccess = $this->createMock(HttpAccess::class); 28 $this->container->httpAccess = $this->createMock(HttpAccess::class);
29 $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
28 $this->controller = new ManageShaareController($this->container); 30 $this->controller = new ManageShaareController($this->container);
29 } 31 }
30 32
@@ -32,7 +34,7 @@ class DisplayCreateFormTest extends TestCase
32 * Test displaying bookmark create form 34 * Test displaying bookmark create form
33 * Ensure that every step of the standard workflow works properly. 35 * Ensure that every step of the standard workflow works properly.
34 */ 36 */
35 public function testDisplayCreateFormWithUrl(): void 37 public function testDisplayCreateFormWithUrlAndWithMetadataRetrieval(): void
36 { 38 {
37 $this->container->environment = [ 39 $this->container->environment = [
38 'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc' 40 'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
@@ -53,40 +55,20 @@ class DisplayCreateFormTest extends TestCase
53 }); 55 });
54 $response = new Response(); 56 $response = new Response();
55 57
56 $this->container->httpAccess 58 $this->container->conf = $this->createMock(ConfigManager::class);
57 ->expects(static::once()) 59 $this->container->conf->method('get')->willReturnCallback(function (string $param, $default) {
58 ->method('getCurlDownloadCallback') 60 if ($param === 'general.enable_async_metadata') {
59 ->willReturnCallback( 61 return false;
60 function (&$charset, &$title, &$description, &$tags) use ( 62 }
61 $remoteTitle, 63
62 $remoteDesc, 64 return $default;
63 $remoteTags 65 });
64 ): callable { 66
65 return function () use ( 67 $this->container->metadataRetriever->expects(static::once())->method('retrieve')->willReturn([
66 &$charset, 68 'title' => $remoteTitle,
67 &$title, 69 'description' => $remoteDesc,
68 &$description, 70 'tags' => $remoteTags,
69 &$tags, 71 ]);
70 $remoteTitle,
71 $remoteDesc,
72 $remoteTags
73 ): void {
74 $charset = 'ISO-8859-1';
75 $title = $remoteTitle;
76 $description = $remoteDesc;
77 $tags = $remoteTags;
78 };
79 }
80 )
81 ;
82 $this->container->httpAccess
83 ->expects(static::once())
84 ->method('getHttpResponse')
85 ->with($expectedUrl, 30, 4194304)
86 ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
87 $callback();
88 })
89 ;
90 72
91 $this->container->bookmarkService 73 $this->container->bookmarkService
92 ->expects(static::once()) 74 ->expects(static::once())
@@ -127,6 +109,72 @@ class DisplayCreateFormTest extends TestCase
127 static::assertSame($tags, $assignedVariables['tags']); 109 static::assertSame($tags, $assignedVariables['tags']);
128 static::assertArrayHasKey('source', $assignedVariables); 110 static::assertArrayHasKey('source', $assignedVariables);
129 static::assertArrayHasKey('default_private_links', $assignedVariables); 111 static::assertArrayHasKey('default_private_links', $assignedVariables);
112 static::assertArrayHasKey('async_metadata', $assignedVariables);
113 static::assertArrayHasKey('retrieve_description', $assignedVariables);
114 }
115
116 /**
117 * Test displaying bookmark create form without any external metadata retrieval attempt
118 */
119 public function testDisplayCreateFormWithUrlAndWithoutMetadata(): void
120 {
121 $this->container->environment = [
122 'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
123 ];
124
125 $assignedVariables = [];
126 $this->assignTemplateVars($assignedVariables);
127
128 $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
129 $expectedUrl = str_replace('&utm_ad=pay', '', $url);
130
131 $request = $this->createMock(Request::class);
132 $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
133 return $key === 'post' ? $url : null;
134 });
135 $response = new Response();
136
137 $this->container->metadataRetriever->expects(static::never())->method('retrieve');
138
139 $this->container->bookmarkService
140 ->expects(static::once())
141 ->method('bookmarksCountPerTag')
142 ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
143 ;
144
145 // Make sure that PluginManager hook is triggered
146 $this->container->pluginManager
147 ->expects(static::at(0))
148 ->method('executeHooks')
149 ->willReturnCallback(function (string $hook, array $data): array {
150 static::assertSame('render_editlink', $hook);
151 static::assertSame('', $data['link']['title']);
152 static::assertSame('', $data['link']['description']);
153
154 return $data;
155 })
156 ;
157
158 $result = $this->controller->displayCreateForm($request, $response);
159
160 static::assertSame(200, $result->getStatusCode());
161 static::assertSame('editlink', (string) $result->getBody());
162
163 static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
164
165 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
166 static::assertSame('', $assignedVariables['link']['title']);
167 static::assertSame('', $assignedVariables['link']['description']);
168 static::assertSame('', $assignedVariables['link']['tags']);
169 static::assertFalse($assignedVariables['link']['private']);
170
171 static::assertTrue($assignedVariables['link_is_new']);
172 static::assertSame($referer, $assignedVariables['http_referer']);
173 static::assertSame($tags, $assignedVariables['tags']);
174 static::assertArrayHasKey('source', $assignedVariables);
175 static::assertArrayHasKey('default_private_links', $assignedVariables);
176 static::assertArrayHasKey('async_metadata', $assignedVariables);
177 static::assertArrayHasKey('retrieve_description', $assignedVariables);
130 } 178 }
131 179
132 /** 180 /**
diff --git a/tests/http/MetadataRetrieverTest.php b/tests/http/MetadataRetrieverTest.php
new file mode 100644
index 00000000..2a1838e8
--- /dev/null
+++ b/tests/http/MetadataRetrieverTest.php
@@ -0,0 +1,123 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7use PHPUnit\Framework\TestCase;
8use Shaarli\Config\ConfigManager;
9
10class MetadataRetrieverTest extends TestCase
11{
12 /** @var MetadataRetriever */
13 protected $retriever;
14
15 /** @var ConfigManager */
16 protected $conf;
17
18 /** @var HttpAccess */
19 protected $httpAccess;
20
21 public function setUp(): void
22 {
23 $this->conf = $this->createMock(ConfigManager::class);
24 $this->httpAccess = $this->createMock(HttpAccess::class);
25 $this->retriever = new MetadataRetriever($this->conf, $this->httpAccess);
26
27 $this->conf->method('get')->willReturnCallback(function (string $param, $default) {
28 return $default === null ? $param : $default;
29 });
30 }
31
32 /**
33 * Test metadata retrieve() with values returned
34 */
35 public function testFullRetrieval(): void
36 {
37 $url = 'https://domain.tld/link';
38 $remoteTitle = 'Remote Title ';
39 $remoteDesc = 'Sometimes the meta description is relevant.';
40 $remoteTags = 'abc def';
41
42 $expectedResult = [
43 'title' => $remoteTitle,
44 'description' => $remoteDesc,
45 'tags' => $remoteTags,
46 ];
47
48 $this->httpAccess
49 ->expects(static::once())
50 ->method('getCurlDownloadCallback')
51 ->willReturnCallback(
52 function (&$charset, &$title, &$description, &$tags) use (
53 $remoteTitle,
54 $remoteDesc,
55 $remoteTags
56 ): callable {
57 return function () use (
58 &$charset,
59 &$title,
60 &$description,
61 &$tags,
62 $remoteTitle,
63 $remoteDesc,
64 $remoteTags
65 ): void {
66 $charset = 'ISO-8859-1';
67 $title = $remoteTitle;
68 $description = $remoteDesc;
69 $tags = $remoteTags;
70 };
71 }
72 )
73 ;
74 $this->httpAccess
75 ->expects(static::once())
76 ->method('getHttpResponse')
77 ->with($url, 30, 4194304)
78 ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
79 $callback();
80 })
81 ;
82
83 $result = $this->retriever->retrieve($url);
84
85 static::assertSame($expectedResult, $result);
86 }
87
88 /**
89 * Test metadata retrieve() without any value
90 */
91 public function testEmptyRetrieval(): void
92 {
93 $url = 'https://domain.tld/link';
94
95 $expectedResult = [
96 'title' => null,
97 'description' => null,
98 'tags' => null,
99 ];
100
101 $this->httpAccess
102 ->expects(static::once())
103 ->method('getCurlDownloadCallback')
104 ->willReturnCallback(
105 function (&$charset, &$title, &$description, &$tags): callable {
106 return function () use (&$charset, &$title, &$description, &$tags): void {};
107 }
108 )
109 ;
110 $this->httpAccess
111 ->expects(static::once())
112 ->method('getHttpResponse')
113 ->with($url, 30, 4194304)
114 ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
115 $callback();
116 })
117 ;
118
119 $result = $this->retriever->retrieve($url);
120
121 static::assertSame($expectedResult, $result);
122 }
123}
diff --git a/tpl/default/editlink.html b/tpl/default/editlink.html
index 568545bd..7ab7e1fe 100644
--- a/tpl/default/editlink.html
+++ b/tpl/default/editlink.html
@@ -12,6 +12,8 @@
12 action="{$base_path}/admin/shaare" 12 action="{$base_path}/admin/shaare"
13 class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light" 13 class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
14 > 14 >
15 {$asyncLoadClass=$link_is_new && $async_metadata && empty($link.title) ? 'loading-input' : ''}
16
15 <h2 class="window-title"> 17 <h2 class="window-title">
16 {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if} 18 {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
17 </h2> 19 </h2>
@@ -28,21 +30,32 @@
28 <div> 30 <div>
29 <label for="lf_title">{'Title'|t}</label> 31 <label for="lf_title">{'Title'|t}</label>
30 </div> 32 </div>
31 <div> 33 <div class="{$asyncLoadClass}">
32 <input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input autofocus"> 34 <input type="text" name="lf_title" id="lf_title" value="{$link.title}"
35 class="lf_input {if="!$async_metadata"}autofocus{/if}"
36 >
37 <div class="icon-container">
38 <i class="loader"></i>
39 </div>
33 </div> 40 </div>
34 <div> 41 <div>
35 <label for="lf_description">{'Description'|t}</label> 42 <label for="lf_description">{'Description'|t}</label>
36 </div> 43 </div>
37 <div> 44 <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
38 <textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea> 45 <textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea>
46 <div class="icon-container">
47 <i class="loader"></i>
48 </div>
39 </div> 49 </div>
40 <div> 50 <div>
41 <label for="lf_tags">{'Tags'|t}</label> 51 <label for="lf_tags">{'Tags'|t}</label>
42 </div> 52 </div>
43 <div> 53 <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
44 <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input autofocus" 54 <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input autofocus"
45 data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" > 55 data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" >
56 <div class="icon-container">
57 <i class="loader"></i>
58 </div>
46 </div> 59 </div>
47 60
48 <div> 61 <div>
@@ -88,5 +101,6 @@
88 </form> 101 </form>
89 </div> 102 </div>
90 {include="page.footer"} 103 {include="page.footer"}
104 {if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
91</body> 105</body>
92</html> 106</html>
diff --git a/webpack.config.js b/webpack.config.js
index a73758cc..8e3d1470 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -20,6 +20,7 @@ module.exports = [
20 entry: { 20 entry: {
21 thumbnails: './assets/common/js/thumbnails.js', 21 thumbnails: './assets/common/js/thumbnails.js',
22 thumbnails_update: './assets/common/js/thumbnails-update.js', 22 thumbnails_update: './assets/common/js/thumbnails-update.js',
23 metadata: './assets/common/js/metadata.js',
23 pluginsadmin: './assets/default/js/plugins-admin.js', 24 pluginsadmin: './assets/default/js/plugins-admin.js',
24 shaarli: [ 25 shaarli: [
25 './assets/default/js/base.js', 26 './assets/default/js/base.js',
@@ -99,6 +100,7 @@ module.exports = [
99 ].concat(glob.sync('./assets/vintage/img/*')), 100 ].concat(glob.sync('./assets/vintage/img/*')),
100 markdown: './assets/common/css/markdown.css', 101 markdown: './assets/common/css/markdown.css',
101 thumbnails: './assets/common/js/thumbnails.js', 102 thumbnails: './assets/common/js/thumbnails.js',
103 metadata: './assets/common/js/metadata.js',
102 thumbnails_update: './assets/common/js/thumbnails-update.js', 104 thumbnails_update: './assets/common/js/thumbnails-update.js',
103 }, 105 },
104 output: { 106 output: {
diff --git a/yarn.lock b/yarn.lock
index 0a12820c..55bd9827 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2912,6 +2912,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
2912 inherits "^2.0.3" 2912 inherits "^2.0.3"
2913 minimalistic-assert "^1.0.1" 2913 minimalistic-assert "^1.0.1"
2914 2914
2915he@^1.2.0:
2916 version "1.2.0"
2917 resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
2918 integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
2919
2915hmac-drbg@^1.0.0: 2920hmac-drbg@^1.0.0:
2916 version "1.0.1" 2921 version "1.0.1"
2917 resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" 2922 resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"