diff options
author | ArthurHoaro <arthur@hoa.ro> | 2020-09-25 13:29:36 +0200 |
---|---|---|
committer | ArthurHoaro <arthur@hoa.ro> | 2020-10-15 09:08:46 +0200 |
commit | 4cf3564d28dc8e4d08a3e64f09ad045ffbde97ae (patch) | |
tree | 8f8ef095cdfea3b35953417fd3d8bb6cdbc7cb46 | |
parent | f34554c6c2cd8fe99fe2e8907bfc196a4884416a (diff) | |
download | Shaarli-4cf3564d28dc8e4d08a3e64f09ad045ffbde97ae.tar.gz Shaarli-4cf3564d28dc8e4d08a3e64f09ad045ffbde97ae.tar.zst Shaarli-4cf3564d28dc8e4d08a3e64f09ad045ffbde97ae.zip |
Add a setting to retrieve bookmark metadata asynchrounously
- There is a new standalone script (metadata.js) which requests
a new controller to get bookmark metadata and fill the form async
- This feature is enabled with the new setting: general.enable_async_metadata
(enabled by default)
- general.retrieve_description is now enabled by default
- A small rotating loader animation has a been added to bookmark inputs
when metadata is being retrieved (default template)
- Custom JS htmlentities has been removed and mathiasbynens/he
library is used instead
Fixes #1563
-rw-r--r-- | Makefile | 1 | ||||
-rw-r--r-- | application/config/ConfigManager.php | 3 | ||||
-rw-r--r-- | application/container/ContainerBuilder.php | 5 | ||||
-rw-r--r-- | application/container/ShaarliContainer.php | 2 | ||||
-rw-r--r-- | application/front/controller/admin/ManageShaareController.php | 36 | ||||
-rw-r--r-- | application/front/controller/admin/MetadataController.php | 29 | ||||
-rw-r--r-- | application/http/MetadataRetriever.php | 68 | ||||
-rw-r--r-- | assets/common/js/metadata.js | 39 | ||||
-rw-r--r-- | assets/default/js/base.js | 12 | ||||
-rw-r--r-- | assets/default/scss/shaarli.scss | 51 | ||||
-rw-r--r-- | doc/md/Shaarli-configuration.md | 1 | ||||
-rw-r--r-- | index.php | 2 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | tests/container/ContainerBuilderTest.php | 2 | ||||
-rw-r--r-- | tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php | 118 | ||||
-rw-r--r-- | tests/http/MetadataRetrieverTest.php | 123 | ||||
-rw-r--r-- | tpl/default/editlink.html | 22 | ||||
-rw-r--r-- | webpack.config.js | 2 | ||||
-rw-r--r-- | yarn.lock | 5 |
19 files changed, 447 insertions, 75 deletions
@@ -175,6 +175,7 @@ translate: | |||
175 | eslint: | 175 | eslint: |
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 |
180 | sasslint: | 181 | sasslint: |
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; | |||
14 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; | 14 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; |
15 | use Shaarli\History; | 15 | use Shaarli\History; |
16 | use Shaarli\Http\HttpAccess; | 16 | use Shaarli\Http\HttpAccess; |
17 | use Shaarli\Http\MetadataRetriever; | ||
17 | use Shaarli\Netscape\NetscapeBookmarkUtils; | 18 | use Shaarli\Netscape\NetscapeBookmarkUtils; |
18 | use Shaarli\Plugin\PluginManager; | 19 | use Shaarli\Plugin\PluginManager; |
19 | use Shaarli\Render\PageBuilder; | 20 | use 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; | |||
10 | use Shaarli\Formatter\FormatterFactory; | 10 | use Shaarli\Formatter\FormatterFactory; |
11 | use Shaarli\History; | 11 | use Shaarli\History; |
12 | use Shaarli\Http\HttpAccess; | 12 | use Shaarli\Http\HttpAccess; |
13 | use Shaarli\Http\MetadataRetriever; | ||
13 | use Shaarli\Netscape\NetscapeBookmarkUtils; | 14 | use Shaarli\Netscape\NetscapeBookmarkUtils; |
14 | use Shaarli\Plugin\PluginManager; | 15 | use Shaarli\Plugin\PluginManager; |
15 | use Shaarli\Render\PageBuilder; | 16 | use 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Front\Controller\Admin; | ||
6 | |||
7 | use Slim\Http\Request; | ||
8 | use Slim\Http\Response; | ||
9 | |||
10 | /** | ||
11 | * Controller used to retrieve/update bookmark's metadata. | ||
12 | */ | ||
13 | class 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Http; | ||
6 | |||
7 | use Shaarli\Config\ConfigManager; | ||
8 | |||
9 | /** | ||
10 | * HTTP Tool used to extract metadata from external URL (title, description, etc.). | ||
11 | */ | ||
12 | class 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 @@ | |||
1 | import he from 'he'; | ||
2 | |||
3 | function 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 @@ | |||
1 | import Awesomplete from 'awesomplete'; | 1 | import Awesomplete from 'awesomplete'; |
2 | import 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 | */ | ||
103 | function 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 | ||
@@ -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; | |||
12 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; | 12 | use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; |
13 | use Shaarli\History; | 13 | use Shaarli\History; |
14 | use Shaarli\Http\HttpAccess; | 14 | use Shaarli\Http\HttpAccess; |
15 | use Shaarli\Http\MetadataRetriever; | ||
15 | use Shaarli\Netscape\NetscapeBookmarkUtils; | 16 | use Shaarli\Netscape\NetscapeBookmarkUtils; |
16 | use Shaarli\Plugin\PluginManager; | 17 | use Shaarli\Plugin\PluginManager; |
17 | use Shaarli\Render\PageBuilder; | 18 | use 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; | |||
9 | use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; | 9 | use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; |
10 | use Shaarli\Front\Controller\Admin\ManageShaareController; | 10 | use Shaarli\Front\Controller\Admin\ManageShaareController; |
11 | use Shaarli\Http\HttpAccess; | 11 | use Shaarli\Http\HttpAccess; |
12 | use Shaarli\Http\MetadataRetriever; | ||
12 | use Shaarli\TestCase; | 13 | use Shaarli\TestCase; |
13 | use Slim\Http\Request; | 14 | use Slim\Http\Request; |
14 | use Slim\Http\Response; | 15 | use 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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Http; | ||
6 | |||
7 | use PHPUnit\Framework\TestCase; | ||
8 | use Shaarli\Config\ConfigManager; | ||
9 | |||
10 | class 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: { |
@@ -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 | ||
2915 | he@^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 | |||
2915 | hmac-drbg@^1.0.0: | 2920 | hmac-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" |