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
This commit is contained in:
parent
f34554c6c2
commit
4cf3564d28
19 changed files with 447 additions and 75 deletions
1
Makefile
1
Makefile
|
@ -175,6 +175,7 @@ translate:
|
||||||
eslint:
|
eslint:
|
||||||
@yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/
|
@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/default/js/
|
||||||
|
@yarn run eslint -c .dev/.eslintrc.js assets/common/js/
|
||||||
|
|
||||||
### Run CSSLint check against Shaarli's SCSS files
|
### Run CSSLint check against Shaarli's SCSS files
|
||||||
sasslint:
|
sasslint:
|
||||||
|
|
|
@ -366,7 +366,8 @@ protected function setDefaultValues()
|
||||||
$this->setEmpty('general.links_per_page', 20);
|
$this->setEmpty('general.links_per_page', 20);
|
||||||
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
|
$this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
|
||||||
$this->setEmpty('general.default_note_title', 'Note: ');
|
$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', false);
|
||||||
$this->setEmpty('updates.check_updates_branch', 'stable');
|
$this->setEmpty('updates.check_updates_branch', 'stable');
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
|
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
|
||||||
use Shaarli\History;
|
use Shaarli\History;
|
||||||
use Shaarli\Http\HttpAccess;
|
use Shaarli\Http\HttpAccess;
|
||||||
|
use Shaarli\Http\MetadataRetriever;
|
||||||
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
||||||
use Shaarli\Plugin\PluginManager;
|
use Shaarli\Plugin\PluginManager;
|
||||||
use Shaarli\Render\PageBuilder;
|
use Shaarli\Render\PageBuilder;
|
||||||
|
@ -90,6 +91,10 @@ public function build(): ShaarliContainer
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
|
||||||
|
return new MetadataRetriever($container->conf, $container->httpAccess);
|
||||||
|
};
|
||||||
|
|
||||||
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
|
$container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
|
||||||
return new PageBuilder(
|
return new PageBuilder(
|
||||||
$container->conf,
|
$container->conf,
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
use Shaarli\Formatter\FormatterFactory;
|
use Shaarli\Formatter\FormatterFactory;
|
||||||
use Shaarli\History;
|
use Shaarli\History;
|
||||||
use Shaarli\Http\HttpAccess;
|
use Shaarli\Http\HttpAccess;
|
||||||
|
use Shaarli\Http\MetadataRetriever;
|
||||||
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
||||||
use Shaarli\Plugin\PluginManager;
|
use Shaarli\Plugin\PluginManager;
|
||||||
use Shaarli\Render\PageBuilder;
|
use Shaarli\Render\PageBuilder;
|
||||||
|
@ -35,6 +36,7 @@
|
||||||
* @property History $history
|
* @property History $history
|
||||||
* @property HttpAccess $httpAccess
|
* @property HttpAccess $httpAccess
|
||||||
* @property LoginManager $loginManager
|
* @property LoginManager $loginManager
|
||||||
|
* @property MetadataRetriever $metadataRetriever
|
||||||
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
|
* @property NetscapeBookmarkUtils $netscapeBookmarkUtils
|
||||||
* @property callable $notFoundHandler Overrides default Slim exception display
|
* @property callable $notFoundHandler Overrides default Slim exception display
|
||||||
* @property PageBuilder $pageBuilder
|
* @property PageBuilder $pageBuilder
|
||||||
|
|
|
@ -53,36 +53,22 @@ public function displayCreateForm(Request $request, Response $response): Respons
|
||||||
|
|
||||||
// If this is an HTTP(S) link, we try go get the page to extract
|
// 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.)
|
// the title (otherwise we will to straight to the edit form.)
|
||||||
if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
|
if (true !== $this->container->conf->get('general.enable_async_metadata', true)
|
||||||
$retrieveDescription = $this->container->conf->get('general.retrieve_description');
|
&& empty($title)
|
||||||
// Short timeout to keep the application responsive
|
&& strpos(get_url_scheme($url) ?: '', 'http') !== false
|
||||||
// The callback will fill $charset and $title with data from the downloaded page.
|
) {
|
||||||
$this->container->httpAccess->getHttpResponse(
|
$metadata = $this->container->metadataRetriever->retrieve($url);
|
||||||
$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 (empty($url) && empty($title)) {
|
if (empty($url)) {
|
||||||
$title = $this->container->conf->get('general.default_note_title', t('Note: '));
|
$metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
|
||||||
}
|
}
|
||||||
|
|
||||||
$link = [
|
$link = [
|
||||||
'title' => $title,
|
'title' => $title ?? $metadata['title'] ?? '',
|
||||||
'url' => $url ?? '',
|
'url' => $url ?? '',
|
||||||
'description' => $description ?? '',
|
'description' => $description ?? $metadata['description'] ?? '',
|
||||||
'tags' => $tags ?? '',
|
'tags' => $tags ?? $metadata['tags'] ?? '',
|
||||||
'private' => $private,
|
'private' => $private,
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
|
@ -352,6 +338,8 @@ protected function displayForm(array $link, bool $isNew, Request $request, Respo
|
||||||
'source' => $request->getParam('source') ?? '',
|
'source' => $request->getParam('source') ?? '',
|
||||||
'tags' => $tags,
|
'tags' => $tags,
|
||||||
'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
|
'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);
|
$this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
|
||||||
|
|
29
application/front/controller/admin/MetadataController.php
Normal file
29
application/front/controller/admin/MetadataController.php
Normal file
|
@ -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([]);
|
||||||
|
}
|
||||||
|
}
|
68
application/http/MetadataRetriever.php
Normal file
68
application/http/MetadataRetriever.php
Normal file
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
39
assets/common/js/metadata.js
Normal file
39
assets/common/js/metadata.js
Normal file
|
@ -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();
|
||||||
|
})();
|
|
@ -1,4 +1,5 @@
|
||||||
import Awesomplete from 'awesomplete';
|
import Awesomplete from 'awesomplete';
|
||||||
|
import he from 'he';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a parent element according to its tag and its attributes
|
* Find a parent element according to its tag and its attributes
|
||||||
|
@ -95,15 +96,6 @@ function updateAwesompleteList(selector, tags, instances) {
|
||||||
return 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.
|
* 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('name', totag);
|
||||||
input.setAttribute('value', totag);
|
input.setAttribute('value', totag);
|
||||||
findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
|
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
|
block
|
||||||
.querySelector('a.tag-link')
|
.querySelector('a.tag-link')
|
||||||
.setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
|
.setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
|
||||||
|
|
|
@ -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
|
||||||
.login-form-container {
|
.login-form-container {
|
||||||
.remember-me {
|
.remember-me {
|
||||||
|
|
|
@ -150,6 +150,7 @@ _These settings should not be edited_
|
||||||
- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
|
- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
|
||||||
- **enabled_plugins**: List of enabled plugins.
|
- **enabled_plugins**: List of enabled plugins.
|
||||||
- **default_note_title**: Default title of a new note.
|
- **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.
|
- **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/`.
|
- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
|
||||||
|
|
||||||
|
|
|
@ -129,7 +129,7 @@
|
||||||
$this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
|
$this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
|
||||||
$this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
|
$this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
|
||||||
$this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
|
$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');
|
$this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
|
||||||
})->add('\Shaarli\Front\ShaarliAdminMiddleware');
|
})->add('\Shaarli\Front\ShaarliAdminMiddleware');
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
"awesomplete": "^1.1.2",
|
"awesomplete": "^1.1.2",
|
||||||
"blazy": "^1.8.2",
|
"blazy": "^1.8.2",
|
||||||
"fork-awesome": "^1.1.7",
|
"fork-awesome": "^1.1.7",
|
||||||
|
"he": "^1.2.0",
|
||||||
"pure-extras": "^1.0.0",
|
"pure-extras": "^1.0.0",
|
||||||
"purecss": "^1.0.0"
|
"purecss": "^1.0.0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
|
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
|
||||||
use Shaarli\History;
|
use Shaarli\History;
|
||||||
use Shaarli\Http\HttpAccess;
|
use Shaarli\Http\HttpAccess;
|
||||||
|
use Shaarli\Http\MetadataRetriever;
|
||||||
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
use Shaarli\Netscape\NetscapeBookmarkUtils;
|
||||||
use Shaarli\Plugin\PluginManager;
|
use Shaarli\Plugin\PluginManager;
|
||||||
use Shaarli\Render\PageBuilder;
|
use Shaarli\Render\PageBuilder;
|
||||||
|
@ -72,6 +73,7 @@ public function testBuildContainer(): void
|
||||||
static::assertInstanceOf(History::class, $container->history);
|
static::assertInstanceOf(History::class, $container->history);
|
||||||
static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
|
static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
|
||||||
static::assertInstanceOf(LoginManager::class, $container->loginManager);
|
static::assertInstanceOf(LoginManager::class, $container->loginManager);
|
||||||
|
static::assertInstanceOf(MetadataRetriever::class, $container->metadataRetriever);
|
||||||
static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
|
static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
|
||||||
static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
|
static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
|
||||||
static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
|
static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
|
use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
|
||||||
use Shaarli\Front\Controller\Admin\ManageShaareController;
|
use Shaarli\Front\Controller\Admin\ManageShaareController;
|
||||||
use Shaarli\Http\HttpAccess;
|
use Shaarli\Http\HttpAccess;
|
||||||
|
use Shaarli\Http\MetadataRetriever;
|
||||||
use Shaarli\TestCase;
|
use Shaarli\TestCase;
|
||||||
use Slim\Http\Request;
|
use Slim\Http\Request;
|
||||||
use Slim\Http\Response;
|
use Slim\Http\Response;
|
||||||
|
@ -25,6 +26,7 @@ public function setUp(): void
|
||||||
$this->createContainer();
|
$this->createContainer();
|
||||||
|
|
||||||
$this->container->httpAccess = $this->createMock(HttpAccess::class);
|
$this->container->httpAccess = $this->createMock(HttpAccess::class);
|
||||||
|
$this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
|
||||||
$this->controller = new ManageShaareController($this->container);
|
$this->controller = new ManageShaareController($this->container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -32,7 +34,7 @@ public function setUp(): void
|
||||||
* Test displaying bookmark create form
|
* Test displaying bookmark create form
|
||||||
* Ensure that every step of the standard workflow works properly.
|
* Ensure that every step of the standard workflow works properly.
|
||||||
*/
|
*/
|
||||||
public function testDisplayCreateFormWithUrl(): void
|
public function testDisplayCreateFormWithUrlAndWithMetadataRetrieval(): void
|
||||||
{
|
{
|
||||||
$this->container->environment = [
|
$this->container->environment = [
|
||||||
'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
|
'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
|
||||||
|
@ -53,40 +55,20 @@ public function testDisplayCreateFormWithUrl(): void
|
||||||
});
|
});
|
||||||
$response = new Response();
|
$response = new Response();
|
||||||
|
|
||||||
$this->container->httpAccess
|
$this->container->conf = $this->createMock(ConfigManager::class);
|
||||||
->expects(static::once())
|
$this->container->conf->method('get')->willReturnCallback(function (string $param, $default) {
|
||||||
->method('getCurlDownloadCallback')
|
if ($param === 'general.enable_async_metadata') {
|
||||||
->willReturnCallback(
|
return false;
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
;
|
return $default;
|
||||||
$this->container->httpAccess
|
});
|
||||||
->expects(static::once())
|
|
||||||
->method('getHttpResponse')
|
$this->container->metadataRetriever->expects(static::once())->method('retrieve')->willReturn([
|
||||||
->with($expectedUrl, 30, 4194304)
|
'title' => $remoteTitle,
|
||||||
->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
|
'description' => $remoteDesc,
|
||||||
$callback();
|
'tags' => $remoteTags,
|
||||||
})
|
]);
|
||||||
;
|
|
||||||
|
|
||||||
$this->container->bookmarkService
|
$this->container->bookmarkService
|
||||||
->expects(static::once())
|
->expects(static::once())
|
||||||
|
@ -127,6 +109,72 @@ function (&$charset, &$title, &$description, &$tags) use (
|
||||||
static::assertSame($tags, $assignedVariables['tags']);
|
static::assertSame($tags, $assignedVariables['tags']);
|
||||||
static::assertArrayHasKey('source', $assignedVariables);
|
static::assertArrayHasKey('source', $assignedVariables);
|
||||||
static::assertArrayHasKey('default_private_links', $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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
123
tests/http/MetadataRetrieverTest.php
Normal file
123
tests/http/MetadataRetrieverTest.php
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,6 +12,8 @@
|
||||||
action="{$base_path}/admin/shaare"
|
action="{$base_path}/admin/shaare"
|
||||||
class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
|
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">
|
<h2 class="window-title">
|
||||||
{if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
|
{if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
|
||||||
</h2>
|
</h2>
|
||||||
|
@ -28,21 +30,32 @@ <h2 class="window-title">
|
||||||
<div>
|
<div>
|
||||||
<label for="lf_title">{'Title'|t}</label>
|
<label for="lf_title">{'Title'|t}</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="{$asyncLoadClass}">
|
||||||
<input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input autofocus">
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label for="lf_description">{'Description'|t}</label>
|
<label for="lf_description">{'Description'|t}</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
|
||||||
<textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea>
|
<textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea>
|
||||||
|
<div class="icon-container">
|
||||||
|
<i class="loader"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="lf_tags">{'Tags'|t}</label>
|
<label for="lf_tags">{'Tags'|t}</label>
|
||||||
</div>
|
</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"
|
<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" >
|
data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" >
|
||||||
|
<div class="icon-container">
|
||||||
|
<i class="loader"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
@ -88,5 +101,6 @@ <h2 class="window-title">
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{include="page.footer"}
|
{include="page.footer"}
|
||||||
|
{if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -20,6 +20,7 @@ module.exports = [
|
||||||
entry: {
|
entry: {
|
||||||
thumbnails: './assets/common/js/thumbnails.js',
|
thumbnails: './assets/common/js/thumbnails.js',
|
||||||
thumbnails_update: './assets/common/js/thumbnails-update.js',
|
thumbnails_update: './assets/common/js/thumbnails-update.js',
|
||||||
|
metadata: './assets/common/js/metadata.js',
|
||||||
pluginsadmin: './assets/default/js/plugins-admin.js',
|
pluginsadmin: './assets/default/js/plugins-admin.js',
|
||||||
shaarli: [
|
shaarli: [
|
||||||
'./assets/default/js/base.js',
|
'./assets/default/js/base.js',
|
||||||
|
@ -99,6 +100,7 @@ module.exports = [
|
||||||
].concat(glob.sync('./assets/vintage/img/*')),
|
].concat(glob.sync('./assets/vintage/img/*')),
|
||||||
markdown: './assets/common/css/markdown.css',
|
markdown: './assets/common/css/markdown.css',
|
||||||
thumbnails: './assets/common/js/thumbnails.js',
|
thumbnails: './assets/common/js/thumbnails.js',
|
||||||
|
metadata: './assets/common/js/metadata.js',
|
||||||
thumbnails_update: './assets/common/js/thumbnails-update.js',
|
thumbnails_update: './assets/common/js/thumbnails-update.js',
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
|
|
|
@ -2912,6 +2912,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
|
||||||
inherits "^2.0.3"
|
inherits "^2.0.3"
|
||||||
minimalistic-assert "^1.0.1"
|
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:
|
hmac-drbg@^1.0.0:
|
||||||
version "1.0.1"
|
version "1.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
|
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
|
||||||
|
|
Loading…
Reference in a new issue