]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Add a setting to retrieve bookmark metadata asynchrounously
authorArthurHoaro <arthur@hoa.ro>
Fri, 25 Sep 2020 11:29:36 +0000 (13:29 +0200)
committerArthurHoaro <arthur@hoa.ro>
Thu, 15 Oct 2020 07:08:46 +0000 (09:08 +0200)
  - 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

19 files changed:
Makefile
application/config/ConfigManager.php
application/container/ContainerBuilder.php
application/container/ShaarliContainer.php
application/front/controller/admin/ManageShaareController.php
application/front/controller/admin/MetadataController.php [new file with mode: 0644]
application/http/MetadataRetriever.php [new file with mode: 0644]
assets/common/js/metadata.js [new file with mode: 0644]
assets/default/js/base.js
assets/default/scss/shaarli.scss
doc/md/Shaarli-configuration.md
index.php
package.json
tests/container/ContainerBuilderTest.php
tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
tests/http/MetadataRetrieverTest.php [new file with mode: 0644]
tpl/default/editlink.html
webpack.config.js
yarn.lock

index 0ff6bd3f7a5ef59ed7900b29a3b093855352b928..7415887a42cb572a7f632374193e91d87e8895f9 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -175,6 +175,7 @@ translate:
 eslint:
        @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/
        @yarn run eslint -c .dev/.eslintrc.js assets/default/js/
+       @yarn run eslint -c .dev/.eslintrc.js assets/common/js/
 
 ### Run CSSLint check against Shaarli's SCSS files
 sasslint:
index 4c98be3051e3fafa7f9aa7b0891392a2c116f7db..fb0850235fb78da19b7ea686ebcad0a07f082d49 100644 (file)
@@ -366,7 +366,8 @@ class ConfigManager
         $this->setEmpty('general.links_per_page', 20);
         $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
         $this->setEmpty('general.default_note_title', 'Note: ');
-        $this->setEmpty('general.retrieve_description', false);
+        $this->setEmpty('general.retrieve_description', true);
+        $this->setEmpty('general.enable_async_metadata', true);
 
         $this->setEmpty('updates.check_updates', false);
         $this->setEmpty('updates.check_updates_branch', 'stable');
index c21d58ddde92f8eb0794d1fc99f24217d11f94b7..fd94a1c33ac1facfaf80e1961399155eefe6fa6a 100644 (file)
@@ -14,6 +14,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
 use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
 use Shaarli\History;
 use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
 use Shaarli\Netscape\NetscapeBookmarkUtils;
 use Shaarli\Plugin\PluginManager;
 use Shaarli\Render\PageBuilder;
@@ -90,6 +91,10 @@ class ContainerBuilder
             );
         };
 
+        $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
+            return new MetadataRetriever($container->conf, $container->httpAccess);
+        };
+
         $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
             return new PageBuilder(
                 $container->conf,
index 66e669aaed3fd6760c966d0fbfd1689696c74f20..3a7c238fca9de79832bbeca676c39433129ad27d 100644 (file)
@@ -10,6 +10,7 @@ use Shaarli\Feed\FeedBuilder;
 use Shaarli\Formatter\FormatterFactory;
 use Shaarli\History;
 use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
 use Shaarli\Netscape\NetscapeBookmarkUtils;
 use Shaarli\Plugin\PluginManager;
 use Shaarli\Render\PageBuilder;
@@ -35,6 +36,7 @@ use Slim\Container;
  * @property History                  $history
  * @property HttpAccess               $httpAccess
  * @property LoginManager             $loginManager
+ * @property MetadataRetriever        $metadataRetriever
  * @property NetscapeBookmarkUtils    $netscapeBookmarkUtils
  * @property callable                 $notFoundHandler       Overrides default Slim exception display
  * @property PageBuilder              $pageBuilder
index bb083486591055b5eddd7c3a75610b69d869eb33..df2f1631d001956e6b26b92a3298cbcd74b082fa 100644 (file)
@@ -53,36 +53,22 @@ class ManageShaareController extends ShaarliAdminController
 
             // If this is an HTTP(S) link, we try go get the page to extract
             // the title (otherwise we will to straight to the edit form.)
-            if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
-                $retrieveDescription = $this->container->conf->get('general.retrieve_description');
-                // Short timeout to keep the application responsive
-                // The callback will fill $charset and $title with data from the downloaded page.
-                $this->container->httpAccess->getHttpResponse(
-                    $url,
-                    $this->container->conf->get('general.download_timeout', 30),
-                    $this->container->conf->get('general.download_max_size', 4194304),
-                    $this->container->httpAccess->getCurlDownloadCallback(
-                        $charset,
-                        $title,
-                        $description,
-                        $tags,
-                        $retrieveDescription
-                    )
-                );
-                if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
-                    $title = mb_convert_encoding($title, 'utf-8', $charset);
-                }
+            if (true !== $this->container->conf->get('general.enable_async_metadata', true)
+                && empty($title)
+                && strpos(get_url_scheme($url) ?: '', 'http') !== false
+            ) {
+                $metadata = $this->container->metadataRetriever->retrieve($url);
             }
 
-            if (empty($url) && empty($title)) {
-                $title = $this->container->conf->get('general.default_note_title', t('Note: '));
+            if (empty($url)) {
+                $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
             }
 
             $link = [
-                'title' => $title,
+                'title' => $title ?? $metadata['title'] ?? '',
                 'url' => $url ?? '',
-                'description' => $description ?? '',
-                'tags' => $tags ?? '',
+                'description' => $description ?? $metadata['description'] ?? '',
+                'tags' => $tags ?? $metadata['tags'] ?? '',
                 'private' => $private,
             ];
         } else {
@@ -352,6 +338,8 @@ class ManageShaareController extends ShaarliAdminController
             'source' => $request->getParam('source') ?? '',
             'tags' => $tags,
             'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
+            'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
+            'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
         ]);
 
         $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 (file)
index 0000000..ff84594
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Controller used to retrieve/update bookmark's metadata.
+ */
+class MetadataController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
+     */
+    public function ajaxRetrieveTitle(Request $request, Response $response): Response
+    {
+        $url = $request->getParam('url');
+
+        // Only try to extract metadata from URL with HTTP(s) scheme
+        if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
+            return $response->withJson($this->container->metadataRetriever->retrieve($url));
+        }
+
+        return $response->withJson([]);
+    }
+}
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644 (file)
index 0000000..2ca982e
--- /dev/null
@@ -0,0 +1,68 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * HTTP Tool used to extract metadata from external URL (title, description, etc.).
+ */
+class MetadataRetriever
+{
+    /** @var ConfigManager */
+    protected $conf;
+
+    /** @var HttpAccess */
+    protected $httpAccess;
+
+    public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
+    {
+        $this->conf = $conf;
+        $this->httpAccess = $httpAccess;
+    }
+
+    /**
+     * Retrieve metadata for given URL.
+     *
+     * @return array [
+     *                  'title' => <remote title>,
+     *                  'description' => <remote description>,
+     *                  'tags' => <remote keywords>,
+     *               ]
+     */
+    public function retrieve(string $url): array
+    {
+        $charset = null;
+        $title = null;
+        $description = null;
+        $tags = null;
+        $retrieveDescription = $this->conf->get('general.retrieve_description');
+
+        // Short timeout to keep the application responsive
+        // The callback will fill $charset and $title with data from the downloaded page.
+        $this->httpAccess->getHttpResponse(
+            $url,
+            $this->conf->get('general.download_timeout', 30),
+            $this->conf->get('general.download_max_size', 4194304),
+            $this->httpAccess->getCurlDownloadCallback(
+                $charset,
+                $title,
+                $description,
+                $tags,
+                $retrieveDescription
+            )
+        );
+
+        if (!empty($title) && strtolower($charset) !== 'utf-8') {
+            $title = mb_convert_encoding($title, 'utf-8', $charset);
+        }
+
+        return [
+            'title' => $title,
+            'description' => $description,
+            'tags' => $tags,
+        ];
+    }
+}
diff --git a/assets/common/js/metadata.js b/assets/common/js/metadata.js
new file mode 100644 (file)
index 0000000..5200b48
--- /dev/null
@@ -0,0 +1,39 @@
+import he from 'he';
+
+function clearLoaders(loaders) {
+  if (loaders != null && loaders.length > 0) {
+    [...loaders].forEach((loader) => {
+      loader.classList.remove('loading-input');
+    });
+  }
+}
+
+(() => {
+  const loaders = document.querySelectorAll('.loading-input');
+  const inputTitle = document.querySelector('input[name="lf_title"]');
+  if (inputTitle != null && inputTitle.value.length > 0) {
+    clearLoaders(loaders);
+    return;
+  }
+
+  const url = document.querySelector('input[name="lf_url"]').value;
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
+
+  const xhr = new XMLHttpRequest();
+  xhr.open('GET', `${basePath}/admin/metadata?url=${encodeURI(url)}`, true);
+  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+  xhr.onload = () => {
+    const result = JSON.parse(xhr.response);
+    Object.keys(result).forEach((key) => {
+      if (result[key] !== null && result[key].length) {
+        const element = document.querySelector(`input[name="lf_${key}"], textarea[name="lf_${key}"]`);
+        if (element != null && element.value.length === 0) {
+          element.value = he.decode(result[key]);
+        }
+      }
+    });
+    clearLoaders(loaders);
+  };
+
+  xhr.send();
+})();
index be986ae015ed7aef888814bd5757f0fe434125cc..3168881514595071111672d52959baa1976713aa 100644 (file)
@@ -1,4 +1,5 @@
 import Awesomplete from 'awesomplete';
+import he from 'he';
 
 /**
  * Find a parent element according to its tag and its attributes
@@ -95,15 +96,6 @@ function updateAwesompleteList(selector, tags, instances) {
   return instances;
 }
 
-/**
- * html_entities in JS
- *
- * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
- */
-function htmlEntities(str) {
-  return str.replace(/[\u00A0-\u9999<>&]/gim, (i) => `&#${i.charCodeAt(0)};`);
-}
-
 /**
  * Add the class 'hidden' to city options not attached to the current selected continent.
  *
@@ -569,7 +561,7 @@ function init(description) {
           input.setAttribute('name', totag);
           input.setAttribute('value', totag);
           findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
-          block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
+          block.querySelector('a.tag-link').innerHTML = he.encode(totag);
           block
             .querySelector('a.tag-link')
             .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
index a528adb0dbe7931ea79c0cf0875b85951a3038c8..df9c867bb6b3bba0065af25c29e03271934ee5a6 100644 (file)
@@ -1269,6 +1269,57 @@ form {
   }
 }
 
+.loading-input {
+  position: relative;
+
+  @keyframes around {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+
+  .icon-container {
+    position: absolute;
+    right: 60px;
+    top: calc(50% - 10px);
+  }
+
+  .loader {
+    position: relative;
+    height: 20px;
+    width: 20px;
+    display: inline-block;
+    animation: around 5.4s infinite;
+
+    &::after,
+    &::before {
+      content: "";
+      background: $form-input-background;
+      position: absolute;
+      display: inline-block;
+      width: 100%;
+      height: 100%;
+      border-width: 2px;
+      border-color: #333 #333 transparent transparent;
+      border-style: solid;
+      border-radius: 20px;
+      box-sizing: border-box;
+      top: 0;
+      left: 0;
+      animation: around 0.7s ease-in-out infinite;
+    }
+
+    &::after {
+      animation: around 0.7s ease-in-out 0.1s infinite;
+      background: transparent;
+    }
+  }
+}
+
 // LOGIN
 .login-form-container {
   .remember-me {
index 263fb7616014ebba6e2a4384d9e1741c21a93106..dbfc3da953fc220bfccaa86bb8fc33454933c123 100644 (file)
@@ -150,6 +150,7 @@ _These settings should not be edited_
 - **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
 - **enabled_plugins**: List of enabled plugins.
 - **default_note_title**: Default title of a new note.
+- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
 - **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.
 - **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
 
index b10397dda2d079cb785caa6fddef262d77331503..220847f5ec4f3469af3e2eb32e08b5f6cc2c4ab9 100644 (file)
--- a/index.php
+++ b/index.php
@@ -129,7 +129,7 @@ $app->group('/admin', function () {
     $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
     $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
     $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
-
+    $this->get('/metadata', '\Shaarli\Front\Controller\Admin\MetadataController:ajaxRetrieveTitle');
     $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
 })->add('\Shaarli\Front\ShaarliAdminMiddleware');
 
index 8a24512a4bba538f0a89687bb0a0147b87d65898..b879b22359b719e723708a7fab40e060a0fdef06 100644 (file)
@@ -7,6 +7,7 @@
     "awesomplete": "^1.1.2",
     "blazy": "^1.8.2",
     "fork-awesome": "^1.1.7",
+    "he": "^1.2.0",
     "pure-extras": "^1.0.0",
     "purecss": "^1.0.0"
   },
index 5d52daefd5f7cf0e8d62559ee19cd8853c9e8e6a..3dadc0b9ed711fee38207fdd659b86bc3cd49563 100644 (file)
@@ -12,6 +12,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
 use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
 use Shaarli\History;
 use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
 use Shaarli\Netscape\NetscapeBookmarkUtils;
 use Shaarli\Plugin\PluginManager;
 use Shaarli\Render\PageBuilder;
@@ -72,6 +73,7 @@ class ContainerBuilderTest extends TestCase
         static::assertInstanceOf(History::class, $container->history);
         static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
         static::assertInstanceOf(LoginManager::class, $container->loginManager);
+        static::assertInstanceOf(MetadataRetriever::class, $container->metadataRetriever);
         static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
         static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
         static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
index 2eb952514b8d1b0cd622bb7c3cd96253081fe812..4fd88480627733d1ec4ae204f0e016154e5210f1 100644 (file)
@@ -9,6 +9,7 @@ use Shaarli\Config\ConfigManager;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
 use Shaarli\Front\Controller\Admin\ManageShaareController;
 use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
 use Shaarli\TestCase;
 use Slim\Http\Request;
 use Slim\Http\Response;
@@ -25,6 +26,7 @@ class DisplayCreateFormTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
         $this->controller = new ManageShaareController($this->container);
     }
 
@@ -32,7 +34,7 @@ class DisplayCreateFormTest extends TestCase
      * Test displaying bookmark create form
      * Ensure that every step of the standard workflow works properly.
      */
-    public function testDisplayCreateFormWithUrl(): void
+    public function testDisplayCreateFormWithUrlAndWithMetadataRetrieval(): void
     {
         $this->container->environment = [
             'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
@@ -53,40 +55,20 @@ class DisplayCreateFormTest extends TestCase
         });
         $response = new Response();
 
-        $this->container->httpAccess
-            ->expects(static::once())
-            ->method('getCurlDownloadCallback')
-            ->willReturnCallback(
-                function (&$charset, &$title, &$description, &$tags) use (
-                    $remoteTitle,
-                    $remoteDesc,
-                    $remoteTags
-                ): callable {
-                    return function () use (
-                        &$charset,
-                        &$title,
-                        &$description,
-                        &$tags,
-                        $remoteTitle,
-                        $remoteDesc,
-                        $remoteTags
-                    ): void {
-                        $charset = 'ISO-8859-1';
-                        $title = $remoteTitle;
-                        $description = $remoteDesc;
-                        $tags = $remoteTags;
-                    };
-                }
-            )
-        ;
-        $this->container->httpAccess
-            ->expects(static::once())
-            ->method('getHttpResponse')
-            ->with($expectedUrl, 30, 4194304)
-            ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
-                $callback();
-            })
-        ;
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $param, $default) {
+            if ($param === 'general.enable_async_metadata') {
+                return false;
+            }
+
+            return $default;
+        });
+
+        $this->container->metadataRetriever->expects(static::once())->method('retrieve')->willReturn([
+            'title' => $remoteTitle,
+            'description' => $remoteDesc,
+            'tags' => $remoteTags,
+        ]);
 
         $this->container->bookmarkService
             ->expects(static::once())
@@ -127,6 +109,72 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($tags, $assignedVariables['tags']);
         static::assertArrayHasKey('source', $assignedVariables);
         static::assertArrayHasKey('default_private_links', $assignedVariables);
+        static::assertArrayHasKey('async_metadata', $assignedVariables);
+        static::assertArrayHasKey('retrieve_description', $assignedVariables);
+    }
+
+    /**
+     * Test displaying bookmark create form without any external metadata retrieval attempt
+     */
+    public function testDisplayCreateFormWithUrlAndWithoutMetadata(): void
+    {
+        $this->container->environment = [
+            'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
+        ];
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
+        $expectedUrl = str_replace('&utm_ad=pay', '', $url);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
+            return $key === 'post' ? $url : null;
+        });
+        $response = new Response();
+
+        $this->container->metadataRetriever->expects(static::never())->method('retrieve');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::at(0))
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data): array {
+                static::assertSame('render_editlink', $hook);
+                static::assertSame('', $data['link']['title']);
+                static::assertSame('', $data['link']['description']);
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame('', $assignedVariables['link']['title']);
+        static::assertSame('', $assignedVariables['link']['description']);
+        static::assertSame('', $assignedVariables['link']['tags']);
+        static::assertFalse($assignedVariables['link']['private']);
+
+        static::assertTrue($assignedVariables['link_is_new']);
+        static::assertSame($referer, $assignedVariables['http_referer']);
+        static::assertSame($tags, $assignedVariables['tags']);
+        static::assertArrayHasKey('source', $assignedVariables);
+        static::assertArrayHasKey('default_private_links', $assignedVariables);
+        static::assertArrayHasKey('async_metadata', $assignedVariables);
+        static::assertArrayHasKey('retrieve_description', $assignedVariables);
     }
 
     /**
diff --git a/tests/http/MetadataRetrieverTest.php b/tests/http/MetadataRetrieverTest.php
new file mode 100644 (file)
index 0000000..2a1838e
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+
+class MetadataRetrieverTest extends TestCase
+{
+    /** @var MetadataRetriever */
+    protected $retriever;
+
+    /** @var ConfigManager */
+    protected $conf;
+
+    /** @var HttpAccess */
+    protected $httpAccess;
+
+    public function setUp(): void
+    {
+        $this->conf = $this->createMock(ConfigManager::class);
+        $this->httpAccess = $this->createMock(HttpAccess::class);
+        $this->retriever = new MetadataRetriever($this->conf, $this->httpAccess);
+
+        $this->conf->method('get')->willReturnCallback(function (string $param, $default) {
+            return $default === null ? $param : $default;
+        });
+    }
+
+    /**
+     * Test metadata retrieve() with values returned
+     */
+    public function testFullRetrieval(): void
+    {
+        $url = 'https://domain.tld/link';
+        $remoteTitle = 'Remote Title ';
+        $remoteDesc = 'Sometimes the meta description is relevant.';
+        $remoteTags = 'abc def';
+
+        $expectedResult = [
+            'title' => $remoteTitle,
+            'description' => $remoteDesc,
+            'tags' => $remoteTags,
+        ];
+
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getCurlDownloadCallback')
+            ->willReturnCallback(
+                function (&$charset, &$title, &$description, &$tags) use (
+                    $remoteTitle,
+                    $remoteDesc,
+                    $remoteTags
+                ): callable {
+                    return function () use (
+                        &$charset,
+                        &$title,
+                        &$description,
+                        &$tags,
+                        $remoteTitle,
+                        $remoteDesc,
+                        $remoteTags
+                    ): void {
+                        $charset = 'ISO-8859-1';
+                        $title = $remoteTitle;
+                        $description = $remoteDesc;
+                        $tags = $remoteTags;
+                    };
+                }
+            )
+        ;
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getHttpResponse')
+            ->with($url, 30, 4194304)
+            ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
+                $callback();
+            })
+        ;
+
+        $result = $this->retriever->retrieve($url);
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * Test metadata retrieve() without any value
+     */
+    public function testEmptyRetrieval(): void
+    {
+        $url = 'https://domain.tld/link';
+
+        $expectedResult = [
+            'title' => null,
+            'description' => null,
+            'tags' => null,
+        ];
+
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getCurlDownloadCallback')
+            ->willReturnCallback(
+                function (&$charset, &$title, &$description, &$tags): callable {
+                    return function () use (&$charset, &$title, &$description, &$tags): void {};
+                }
+            )
+        ;
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getHttpResponse')
+            ->with($url, 30, 4194304)
+            ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
+                $callback();
+            })
+        ;
+
+        $result = $this->retriever->retrieve($url);
+
+        static::assertSame($expectedResult, $result);
+    }
+}
index 568545bd5cee0fb100ef838fb9d08cc813079b3c..7ab7e1fe3b8d7f7c5023587f6d80611a6fae14e8 100644 (file)
@@ -12,6 +12,8 @@
           action="{$base_path}/admin/shaare"
           class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
     >
+      {$asyncLoadClass=$link_is_new && $async_metadata && empty($link.title) ? 'loading-input' : ''}
+
       <h2 class="window-title">
         {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
       </h2>
       <div>
       <label for="lf_title">{'Title'|t}</label>
       </div>
-      <div>
-        <input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input autofocus">
+      <div class="{$asyncLoadClass}">
+        <input type="text" name="lf_title" id="lf_title" value="{$link.title}"
+         class="lf_input {if="!$async_metadata"}autofocus{/if}"
+        >
+        <div class="icon-container">
+          <i class="loader"></i>
+        </div>
       </div>
       <div>
         <label for="lf_description">{'Description'|t}</label>
       </div>
-      <div>
+      <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
         <textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea>
+        <div class="icon-container">
+          <i class="loader"></i>
+        </div>
       </div>
       <div>
         <label for="lf_tags">{'Tags'|t}</label>
       </div>
-      <div>
+      <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
         <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input autofocus"
           data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" >
+        <div class="icon-container">
+          <i class="loader"></i>
+        </div>
       </div>
 
       <div>
     </form>
   </div>
   {include="page.footer"}
+  {if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
 </body>
 </html>
index a73758cce4af906044e9ed9f1ae31fa8941993e1..8e3d1470eaccdab4b62069b8a66e9460b69eca35 100644 (file)
@@ -20,6 +20,7 @@ module.exports = [
     entry: {
       thumbnails: './assets/common/js/thumbnails.js',
       thumbnails_update: './assets/common/js/thumbnails-update.js',
+      metadata: './assets/common/js/metadata.js',
       pluginsadmin: './assets/default/js/plugins-admin.js',
       shaarli: [
         './assets/default/js/base.js',
@@ -99,6 +100,7 @@ module.exports = [
       ].concat(glob.sync('./assets/vintage/img/*')),
       markdown: './assets/common/css/markdown.css',
       thumbnails: './assets/common/js/thumbnails.js',
+      metadata: './assets/common/js/metadata.js',
       thumbnails_update: './assets/common/js/thumbnails-update.js',
     },
     output: {
index 0a12820c83b2a4a357ec95f8560a1ef9f47c713a..55bd9827843a20d21bd96e425c69e53af52fa976 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -2912,6 +2912,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
     inherits "^2.0.3"
     minimalistic-assert "^1.0.1"
 
+he@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
 hmac-drbg@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"