diff --git a/.docker/nginx.conf b/.docker/nginx.conf index 07fba33fe..30810a871 100644 --- a/.docker/nginx.conf +++ b/.docker/nginx.conf @@ -17,27 +17,13 @@ http { index index.html index.php; server { - listen 80; - root /var/www/shaarli; + listen 80; + root /var/www/shaarli; access_log /var/log/nginx/shaarli.access.log; error_log /var/log/nginx/shaarli.error.log; - location ~ /\. { - # deny access to dotfiles - access_log off; - log_not_found off; - deny all; - } - - location ~ ~$ { - # deny access to temp editor files, e.g. "script.php~" - access_log off; - log_not_found off; - deny all; - } - - location ~* \.(?:ico|css|js|gif|jpe?g|png)$ { + location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ { # cache static assets expires max; add_header Pragma public; @@ -49,25 +35,25 @@ http { alias /var/www/shaarli/images/favicon.ico; } + location /doc/html/ { + default_type "text/html"; + try_files $uri $uri/ $uri.html =404; + } + location / { - # Slim - rewrite URLs - try_files $uri /index.php$is_args$args; + # Slim - rewrite URLs & do NOT serve static files through this location + try_files _ /index.php$is_args$args; } - location ~ (index)\.php$ { + location ~ index\.php$ { # Slim - split URL path into (script_filename, path_info) try_files $uri =404; - fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_split_path_info ^(index.php)(/.+)$; # filter and proxy PHP requests to PHP-FPM fastcgi_pass unix:/var/run/php-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } - - location ~ \.php$ { - # deny access to all other PHP scripts - deny all; - } } } diff --git a/.dockerignore b/.dockerignore index 96fd31c5b..19fd87a50 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,8 +2,16 @@ .dev .git .github +.gitattributes +.gitignore +.travis.yml tests +# Docker related resources are not needed inside the container +.dockerignore +Dockerfile +Dockerfile.armhf + # Docker Compose resources docker-compose.yml @@ -13,6 +21,9 @@ data/* pagecache/* tmp/* +# Shaarli's docs are created during the build +doc/html/ + # Eclipse project files .settings .buildpath diff --git a/.htaccess b/.htaccess index 25fcfb034..9d1522dfb 100644 --- a/.htaccess +++ b/.htaccess @@ -13,7 +13,7 @@ RewriteRule .* - [e=HTTP_AUTHORIZATION:%1] # Alternative (if the 2 lines above don't work) # SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0 -# REST API +# Slim URL Redirection # Ionos Hosting needs RewriteBase / # RewriteBase / RewriteCond %{REQUEST_FILENAME} !-f diff --git a/.travis.yml b/.travis.yml index d74609473..422bf8359 100644 --- a/.travis.yml +++ b/.travis.yml @@ -49,6 +49,10 @@ cache: directories: - $HOME/.composer/cache +before_install: + # Disable xdebug: it significantly speed up tests and linter, and we don't use coverage yet + - phpenv config-rm xdebug.ini || echo 'No xdebug config.' + install: # install/update composer and php dependencies - composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION @@ -60,4 +64,5 @@ before_script: script: - make clean - make check_permissions + - make code_sniffer - make all_tests diff --git a/AUTHORS b/AUTHORS index 0ec52accb..be8153643 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,4 @@ - 991 ArthurHoaro + 1097 ArthurHoaro 402 VirtualTam 294 nodiscc 56 Sébastien Sauvage @@ -25,6 +25,7 @@ 2 Alexandre G.-Raymond 2 Chris Kuethe 2 Felix Bartels + 2 Ganesh Kandu 2 Guillaume Virlet 2 Knah Tsaeb 2 Mathieu Chabanon @@ -39,6 +40,7 @@ 2 pips 2 trailjeep 2 yude + 2 yudete 1 Adrien Oliva 1 Adrien le Maire 1 Alexis J @@ -65,6 +67,7 @@ 1 Kevin Masson 1 Knah Tsaeb 1 Lionel Martin + 1 Loïc Carr 1 Mark Gerarts 1 Marsup 1 Paul van den Burg diff --git a/CHANGELOG.md b/CHANGELOG.md index f1686d67f..184040490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,55 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [v0.12.1]() - UNRELEASED +## [v0.12.2]() - UNRELEASED + +## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-11-12 + +> nginx ([#1628](https://github.com/shaarli/Shaarli/pull/1628)) and Apache ([#1630](https://github.com/shaarli/Shaarli/pull/1630)) configurations have been reviewed. It is recommended that you +> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/). +> Users using official Docker image will receive updated configuration automatically. + +### Added +- Bulk creation of bookmarks +- Server administration tool page (and install page requirements) +- Support any tag separator, not just whitespaces +- Share a private bookmark using a URL with a token +- Add a setting to retrieve bookmark metadata asynchronously (enabled by default) +- Highlight fulltext search results +- Weekly and monthly view/RSS feed for daily page +- MarkdownExtra formatter +- Default formatter: add a setting to disable auto-linkification +- Add mutex on datastore I/O operations to prevent data loss +- PHP 8.0 support +- REST API: allow override of creation and update dates +- Add strict types for bookmarks management + +### Changed +- Improve regex and performances to extract HTML metadata (title, description, etc.) +- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`) +- Improve the "Manage tags" tools page +- Use PSR-3 logger for login attempts +- Move utils classes to Shaarli\Helper namespace and folder +- Include php-simplexml in Docker image +- Raise 404 error instead of 500 if permalink access is denied +- Display error details even with dev.debug set to false +- Reviewed nginx configuration +- Reviewed Apache configuration +- Replace vimeo link in demo bookmarks due to IP ban on the demo instance +- Apply PSR-12 on code base, and add CI check using PHPCS + +### Fixed +- Compatiliby issue on login with PHP 7.1 +- Japanese translations update +- Redirect to referrer after bookmark deletion +- Inject ROOT_PATH in plugin instead of regenerating it everywhere +- Wallabag plugin: minor improvements +- REST API postLink: change relative path to absolute path +- Webpack: fix vintage theme images include +- Docker-compose: fix SSL certificate + add parameter for Docker tag + +### Removed +- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP ## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13 diff --git a/Dockerfile b/Dockerfile index e2ff71fde..f6120b71f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,6 +44,7 @@ RUN apk --update --no-cache add \ php7-openssl \ php7-session \ php7-xml \ + php7-simplexml \ php7-zlib \ s6 diff --git a/Makefile b/Makefile index 0ff6bd3f7..181b61c4c 100644 --- a/Makefile +++ b/Makefile @@ -27,10 +27,6 @@ PHPCS := $(BIN)/phpcs code_sniffer: @$(PHPCS) -### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend... -PHPCS_%: - @$(PHPCS) --report-full --report-width=200 --standard=$* - ### - errors by Git author code_sniffer_blame: @$(PHPCS) --report-gitblame @@ -175,6 +171,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: diff --git a/README.md b/README.md index 4fb0bfe0d..46dda8d5e 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ _Do you want to share the links you discover?_ _Shaarli is a minimalist link sharing service that you can install on your own server._ _It is designed to be personal (single-user), fast and handy._ -[![](https://img.shields.io/badge/stable-v0.10.4-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4) +[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli) • -[![](https://img.shields.io/badge/latest-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) +[![](https://img.shields.io/badge/latest-v0.12.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) [![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli) • -[![](https://img.shields.io/badge/master-v0.11.x-blue.svg)](https://github.com/shaarli/Shaarli) +[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli) [![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli) [![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli) diff --git a/application/History.php b/application/History.php index 4fd2f2944..d230f39de 100644 --- a/application/History.php +++ b/application/History.php @@ -1,9 +1,11 @@ language = $confLanguage; } - if (! extension_loaded('gettext') + if ( + ! extension_loaded('gettext') || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php']) ) { $this->initPhpTranslator(); @@ -98,7 +99,7 @@ protected function initGettextTranslator() $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages'); // Default extension translation from the current theme - $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language'; + $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language'; if (is_dir($themeTransFolder)) { $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false); } @@ -121,7 +122,9 @@ protected function initPhpTranslator() $translations = new Translations(); // Core translations try { - $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po'); + $translations = $translations->addFromPoFile( + 'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po' + ); $translations->setDomain('shaarli'); $this->translator->loadTranslations($translations); } catch (\InvalidArgumentException $e) { @@ -129,11 +132,11 @@ protected function initPhpTranslator() // Default extension translation from the current theme $theme = $this->conf->get('theme'); - $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language'; + $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language'; if (is_dir($themeTransFolder)) { try { $translations = Translations::fromPoFile( - $themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po' + $themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po' ); $translations->setDomain($theme); $this->translator->loadTranslations($translations); @@ -149,7 +152,7 @@ protected function initPhpTranslator() try { $extension = Translations::fromPoFile( - $translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po' + $translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po' ); $extension->setDomain($domain); $this->translator->loadTranslations($extension); diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php index 5aec23c8d..c4ff8d7ab 100644 --- a/application/Thumbnailer.php +++ b/application/Thumbnailer.php @@ -13,7 +13,7 @@ */ class Thumbnailer { - const COMMON_MEDIA_DOMAINS = [ + protected const COMMON_MEDIA_DOMAINS = [ 'imgur.com', 'flickr.com', 'youtube.com', @@ -31,9 +31,9 @@ class Thumbnailer 'deviantart.com', ]; - const MODE_ALL = 'all'; - const MODE_COMMON = 'common'; - const MODE_NONE = 'none'; + public const MODE_ALL = 'all'; + public const MODE_COMMON = 'common'; + public const MODE_NONE = 'none'; /** * @var WebThumbnailer instance. @@ -60,7 +60,7 @@ public function __construct($conf) // TODO: create a proper error handling system able to catch exceptions... die(t( 'php-gd extension must be loaded to use thumbnails. ' - .'Thumbnails are now disabled. Please reload the page.' + . 'Thumbnails are now disabled. Please reload the page.' )); } @@ -81,7 +81,8 @@ public function __construct($conf) */ public function get($url) { - if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON + if ( + $this->conf->get('thumbnails.mode') === self::MODE_COMMON && ! $this->isCommonMediaOrImage($url) ) { return false; diff --git a/application/TimeZone.php b/application/TimeZone.php index c1869ef87..a420eb967 100644 --- a/application/TimeZone.php +++ b/application/TimeZone.php @@ -1,4 +1,5 @@ $continent, 'city' => $city]; $continents[$continent] = true; } @@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '') function isTimeZoneValid($continent, $city) { return in_array( - $continent.'/'.$city, + $continent . '/' . $city, timezone_identifiers_list() ); } diff --git a/application/Utils.php b/application/Utils.php index bcfda65c9..952378ab8 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -1,24 +1,27 @@ $value) { $out[escape($key)] = escape($value); } @@ -161,7 +164,7 @@ function checkDateFormat($format, $string) * * @return string $referer - final referer. */ -function generateLocation($referer, $host, $loopTerms = array()) +function generateLocation($referer, $host, $loopTerms = []) { $finalReferer = './?'; @@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array()) function autoLocale($headerLocale) { // Default if browser does not send HTTP_ACCEPT_LANGUAGE - $locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8'); + $locales = ['en_US', 'en_US.utf8', 'en_US.UTF-8']; if (! empty($headerLocale)) { if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) { $attempts = []; @@ -324,6 +327,23 @@ function format_date($date, $time = true, $intl = true) return $formatter->format($date); } +/** + * Format the date month according to the locale. + * + * @param DateTimeInterface $date to format. + * + * @return bool|string Formatted date, or false if the input is invalid. + */ +function format_month(DateTimeInterface $date) +{ + if (! $date instanceof DateTimeInterface) { + return false; + } + + return strftime('%B', $date->getTimestamp()); +} + + /** * Check if the input is an integer, no matter its real type. * @@ -357,13 +377,15 @@ function return_bytes($val) return $val; } $val = trim($val); - $last = strtolower($val[strlen($val)-1]); + $last = strtolower($val[strlen($val) - 1]); $val = intval(substr($val, 0, -1)); switch ($last) { case 'g': $val *= 1024; + // do no break in order 1024^2 for each unit case 'm': $val *= 1024; + // do no break in order 1024^2 for each unit case 'k': $val *= 1024; } @@ -452,14 +474,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false) * Wrapper function for translation which match the API * of gettext()/_() and ngettext(). * - * @param string $text Text to translate. - * @param string $nText The plural message ID. - * @param int $nb The number of items for plural forms. - * @param string $domain The domain where the translation is stored (default: shaarli). + * @param string $text Text to translate. + * @param string $nText The plural message ID. + * @param int $nb The number of items for plural forms. + * @param string $domain The domain where the translation is stored (default: shaarli). + * @param array $variables Associative array of variables to replace in translated text. + * @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables. * * @return string Text translated. */ -function t($text, $nText = '', $nb = 1, $domain = 'shaarli') +function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false) +{ + $postFunction = $fixCase ? 'ucfirst' : function ($input) { + return $input; + }; + + return $postFunction(dn__($domain, $text, $nText, $nb, $variables)); +} + +/** + * Converts an exception into a printable stack trace string. + */ +function exception2text(Throwable $e): string { - return dn__($domain, $text, $nText, $nb); + return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString(); } diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index f5b53b01f..9fb883589 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -1,6 +1,8 @@ hasHeader('Authorization') + if ( + !$request->hasHeader('Authorization') && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) ) { throw new ApiAuthorizationException('JWT token not provided'); @@ -143,6 +146,7 @@ protected function setLinkDb($conf) $linkDb = new BookmarkFileService( $conf, $this->container->get('history'), + new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), true ); $this->container['db'] = $linkDb; diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index faebb8f5f..05a2840a6 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php @@ -1,4 +1,5 @@ iat) + if ( + empty($payload->iat) || $payload->iat > time() || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION ) { @@ -89,12 +91,12 @@ public static function formatLink($bookmark, $indexUrl) * If no URL is provided, it will generate a local note URL. * If no title is provided, it will use the URL as title. * - * @param array $input Request Link. - * @param bool $defaultPrivate Request Link. + * @param array|null $input Request Link. + * @param bool $defaultPrivate Setting defined if a bookmark is private by default. * * @return Bookmark instance. */ - public static function buildLinkFromRequest($input, $defaultPrivate) + public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark { $bookmark = new Bookmark(); $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; @@ -110,6 +112,15 @@ public static function buildLinkFromRequest($input, $defaultPrivate) $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); $bookmark->setPrivate($private); + $created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? ''); + if ($created instanceof \DateTimeInterface) { + $bookmark->setCreated($created); + } + $updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? ''); + if ($updated instanceof \DateTimeInterface) { + $bookmark->setUpdated($updated); + } + return $bookmark; } diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php index c4b3d0c3d..88a845ebc 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php @@ -4,6 +4,7 @@ use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; +use Shaarli\History; use Slim\Container; /** @@ -31,7 +32,7 @@ abstract class ApiController protected $bookmarkService; /** - * @var HistoryController + * @var History */ protected $history; diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php index 505647a95..d83a3a25c 100644 --- a/application/api/controllers/HistoryController.php +++ b/application/api/controllers/HistoryController.php @@ -1,6 +1,5 @@ $this->bookmarkService->count(), 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE), - 'settings' => array( + 'settings' => [ 'title' => $this->conf->get('general.title', 'Shaarli'), 'header_link' => $this->conf->get('general.header_link', '?'), 'timezone' => $this->conf->get('general.timezone', 'UTC'), 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), 'default_private_links' => $this->conf->get('privacy.default_private_links', false), - ), + ], ]; return $response->withJson($info, 200, $this->jsonStyle); diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php index 292479501..c379b9622 100644 --- a/application/api/controllers/Links.php +++ b/application/api/controllers/Links.php @@ -96,11 +96,12 @@ public function getLinks($request, $response) */ public function getLink($request, $response, $args) { - if (!$this->bookmarkService->exists($args['id'])) { + $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; + if ($id === null || ! $this->bookmarkService->exists($id)) { throw new ApiLinkNotFoundException(); } $index = index_url($this->ci['environment']); - $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index); + $out = ApiUtils::formatLink($this->bookmarkService->get($id), $index); return $response->withJson($out, 200, $this->jsonStyle); } @@ -115,10 +116,11 @@ public function getLink($request, $response, $args) */ public function postLink($request, $response) { - $data = $request->getParsedBody(); - $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); + $data = (array) ($request->getParsedBody() ?? []); + $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); // duplicate by URL, return 409 Conflict - if (! empty($bookmark->getUrl()) + if ( + ! empty($bookmark->getUrl()) && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) ) { return $response->withJson( @@ -130,7 +132,7 @@ public function postLink($request, $response) $this->bookmarkService->add($bookmark); $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); - $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]); + $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]); return $response->withAddedHeader('Location', $redirect) ->withJson($out, 201, $this->jsonStyle); } @@ -148,18 +150,20 @@ public function postLink($request, $response) */ public function putLink($request, $response, $args) { - if (! $this->bookmarkService->exists($args['id'])) { + $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; + if ($id === null || !$this->bookmarkService->exists($id)) { throw new ApiLinkNotFoundException(); } $index = index_url($this->ci['environment']); $data = $request->getParsedBody(); - $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); + $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); // duplicate URL on a different link, return 409 Conflict - if (! empty($requestBookmark->getUrl()) + if ( + ! empty($requestBookmark->getUrl()) && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) - && $dup->getId() != $args['id'] + && $dup->getId() != $id ) { return $response->withJson( ApiUtils::formatLink($dup, $index), @@ -168,7 +172,7 @@ public function putLink($request, $response, $args) ); } - $responseBookmark = $this->bookmarkService->get($args['id']); + $responseBookmark = $this->bookmarkService->get($id); $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); $this->bookmarkService->set($responseBookmark); @@ -189,10 +193,11 @@ public function putLink($request, $response, $args) */ public function deleteLink($request, $response, $args) { - if (! $this->bookmarkService->exists($args['id'])) { + $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null; + if ($id === null || !$this->bookmarkService->exists($id)) { throw new ApiLinkNotFoundException(); } - $bookmark = $this->bookmarkService->get($args['id']); + $bookmark = $this->bookmarkService->get($id); $this->bookmarkService->remove($bookmark); return $response->withStatus(204); diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php index 0e3f47769..c77e9eea8 100644 --- a/application/api/exceptions/ApiAuthorizationException.php +++ b/application/api/exceptions/ApiAuthorizationException.php @@ -28,7 +28,7 @@ public function getApiResponse() */ public function setMessage($message) { - $original = $this->debug === true ? ': '. $this->getMessage() : ''; + $original = $this->debug === true ? ': ' . $this->getMessage() : ''; $this->message = $message . $original; } } diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php index d6b663232..7deafb961 100644 --- a/application/api/exceptions/ApiException.php +++ b/application/api/exceptions/ApiException.php @@ -44,7 +44,7 @@ protected function getApiResponseBody() } return [ 'message' => $this->getMessage(), - 'stacktrace' => get_class($this) .': '. $this->getTraceAsString() + 'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString() ]; } diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php index 1beb8be2e..4238ef259 100644 --- a/application/bookmark/Bookmark.php +++ b/application/bookmark/Bookmark.php @@ -1,5 +1,7 @@ id = $data['id']; - $this->shortUrl = $data['shorturl']; - $this->url = $data['url']; - $this->title = $data['title']; - $this->description = $data['description']; - $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; - $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; - $this->created = $data['created']; + $this->id = $data['id'] ?? null; + $this->shortUrl = $data['shorturl'] ?? null; + $this->url = $data['url'] ?? null; + $this->title = $data['title'] ?? null; + $this->description = $data['description'] ?? null; + $this->thumbnail = $data['thumbnail'] ?? null; + $this->sticky = $data['sticky'] ?? false; + $this->created = $data['created'] ?? null; if (is_array($data['tags'])) { $this->tags = $data['tags']; } else { - $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); + $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator); } if (! empty($data['updated'])) { $this->updated = $data['updated']; } - $this->private = $data['private'] ? true : false; + $this->private = ($data['private'] ?? false) ? true : false; return $this; } @@ -93,24 +100,29 @@ public function fromArray($data) * - the URL with the permalink * - the title with the URL * + * Also make sure that we do not save search highlights in the datastore. + * * @throws InvalidBookmarkException */ - public function validate() + public function validate(): void { - if ($this->id === null + if ( + $this->id === null || ! is_int($this->id) || empty($this->shortUrl) || empty($this->created) - || ! $this->created instanceof DateTimeInterface ) { throw new InvalidBookmarkException($this); } if (empty($this->url)) { - $this->url = '/shaare/'. $this->shortUrl; + $this->url = '/shaare/' . $this->shortUrl; } if (empty($this->title)) { $this->title = $this->url; } + if (array_key_exists('search_highlight', $this->additionalContent)) { + unset($this->additionalContent['search_highlight']); + } } /** @@ -119,11 +131,11 @@ public function validate() * - created: with the current datetime * - shortUrl: with a generated small hash from the date and the given ID * - * @param int $id + * @param int|null $id * * @return Bookmark */ - public function setId($id) + public function setId(?int $id): Bookmark { $this->id = $id; if (empty($this->created)) { @@ -139,9 +151,9 @@ public function setId($id) /** * Get the Id. * - * @return int + * @return int|null */ - public function getId() + public function getId(): ?int { return $this->id; } @@ -149,9 +161,9 @@ public function getId() /** * Get the ShortUrl. * - * @return string + * @return string|null */ - public function getShortUrl() + public function getShortUrl(): ?string { return $this->shortUrl; } @@ -159,9 +171,9 @@ public function getShortUrl() /** * Get the Url. * - * @return string + * @return string|null */ - public function getUrl() + public function getUrl(): ?string { return $this->url; } @@ -171,7 +183,7 @@ public function getUrl() * * @return string */ - public function getTitle() + public function getTitle(): ?string { return $this->title; } @@ -181,7 +193,7 @@ public function getTitle() * * @return string */ - public function getDescription() + public function getDescription(): string { return ! empty($this->description) ? $this->description : ''; } @@ -191,7 +203,7 @@ public function getDescription() * * @return DateTimeInterface */ - public function getCreated() + public function getCreated(): ?DateTimeInterface { return $this->created; } @@ -201,7 +213,7 @@ public function getCreated() * * @return DateTimeInterface */ - public function getUpdated() + public function getUpdated(): ?DateTimeInterface { return $this->updated; } @@ -209,11 +221,11 @@ public function getUpdated() /** * Set the ShortUrl. * - * @param string $shortUrl + * @param string|null $shortUrl * * @return Bookmark */ - public function setShortUrl($shortUrl) + public function setShortUrl(?string $shortUrl): Bookmark { $this->shortUrl = $shortUrl; @@ -223,14 +235,14 @@ public function setShortUrl($shortUrl) /** * Set the Url. * - * @param string $url - * @param array $allowedProtocols + * @param string|null $url + * @param string[] $allowedProtocols * * @return Bookmark */ - public function setUrl($url, $allowedProtocols = []) + public function setUrl(?string $url, array $allowedProtocols = []): Bookmark { - $url = trim($url); + $url = $url !== null ? trim($url) : ''; if (! empty($url)) { $url = whitelist_protocols($url, $allowedProtocols); } @@ -242,13 +254,13 @@ public function setUrl($url, $allowedProtocols = []) /** * Set the Title. * - * @param string $title + * @param string|null $title * * @return Bookmark */ - public function setTitle($title) + public function setTitle(?string $title): Bookmark { - $this->title = trim($title); + $this->title = $title !== null ? trim($title) : ''; return $this; } @@ -256,11 +268,11 @@ public function setTitle($title) /** * Set the Description. * - * @param string $description + * @param string|null $description * * @return Bookmark */ - public function setDescription($description) + public function setDescription(?string $description): Bookmark { $this->description = $description; @@ -271,11 +283,11 @@ public function setDescription($description) * Set the Created. * Note: you shouldn't set this manually except for special cases (like bookmark import) * - * @param DateTimeInterface $created + * @param DateTimeInterface|null $created * * @return Bookmark */ - public function setCreated($created) + public function setCreated(?DateTimeInterface $created): Bookmark { $this->created = $created; @@ -285,11 +297,11 @@ public function setCreated($created) /** * Set the Updated. * - * @param DateTimeInterface $updated + * @param DateTimeInterface|null $updated * * @return Bookmark */ - public function setUpdated($updated) + public function setUpdated(?DateTimeInterface $updated): Bookmark { $this->updated = $updated; @@ -301,7 +313,7 @@ public function setUpdated($updated) * * @return bool */ - public function isPrivate() + public function isPrivate(): bool { return $this->private ? true : false; } @@ -309,11 +321,11 @@ public function isPrivate() /** * Set the Private. * - * @param bool $private + * @param bool|null $private * * @return Bookmark */ - public function setPrivate($private) + public function setPrivate(?bool $private): Bookmark { $this->private = $private ? true : false; @@ -323,9 +335,9 @@ public function setPrivate($private) /** * Get the Tags. * - * @return array + * @return string[] */ - public function getTags() + public function getTags(): array { return is_array($this->tags) ? $this->tags : []; } @@ -333,13 +345,18 @@ public function getTags() /** * Set the Tags. * - * @param array $tags + * @param string[]|null $tags * * @return Bookmark */ - public function setTags($tags) + public function setTags(?array $tags): Bookmark { - $this->setTagsString(implode(' ', $tags)); + $this->tags = array_map( + function (string $tag): string { + return $tag[0] === '-' ? substr($tag, 1) : $tag; + }, + tags_filter($tags, ' ') + ); return $this; } @@ -357,23 +374,41 @@ public function getThumbnail() /** * Set the Thumbnail. * - * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found + * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found * * @return Bookmark */ - public function setThumbnail($thumbnail) + public function setThumbnail($thumbnail): Bookmark { $this->thumbnail = $thumbnail; return $this; } + /** + * Return true if: + * - the bookmark's thumbnail is not already set to false (= not found) + * - it's not a note + * - it's an HTTP(S) link + * - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore + * + * @return bool True if the bookmark's thumbnail needs to be retrieved. + */ + public function shouldUpdateThumbnail(): bool + { + return $this->thumbnail !== false + && !$this->isNote() + && startsWith(strtolower($this->url), 'http') + && (null === $this->thumbnail || !is_file($this->thumbnail)) + ; + } + /** * Get the Sticky. * * @return bool */ - public function isSticky() + public function isSticky(): bool { return $this->sticky ? true : false; } @@ -381,11 +416,11 @@ public function isSticky() /** * Set the Sticky. * - * @param bool $sticky + * @param bool|null $sticky * * @return Bookmark */ - public function setSticky($sticky) + public function setSticky(?bool $sticky): Bookmark { $this->sticky = $sticky ? true : false; @@ -393,17 +428,19 @@ public function setSticky($sticky) } /** - * @return string Bookmark's tags as a string, separated by a space + * @param string $separator Tags separator loaded from the config file. + * + * @return string Bookmark's tags as a string, separated by a separator */ - public function getTagsString() + public function getTagsString(string $separator = ' '): string { - return implode(' ', $this->getTags()); + return tags_array2str($this->getTags(), $separator); } /** * @return bool */ - public function isNote() + public function isNote(): bool { // We check empty value to get a valid result if the link has not been saved yet return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?'; @@ -416,33 +453,65 @@ public function isNote() * - multiple spaces will be removed * - trailing dash in tags will be removed * - * @param string $tags + * @param string|null $tags + * @param string $separator Tags separator loaded from the config file. * * @return $this */ - public function setTagsString($tags) + public function setTagsString(?string $tags, string $separator = ' '): Bookmark { - // Remove first '-' char in tags. - $tags = preg_replace('/(^| )\-/', '$1', $tags); - // Explode all tags separted by spaces or commas - $tags = preg_split('/[\s,]+/', $tags); - // Remove eventual empty values - $tags = array_values(array_filter($tags)); + $this->setTags(tags_str2array($tags, $separator)); - $this->tags = $tags; + return $this; + } + + /** + * Get entire additionalContent array. + * + * @return mixed[] + */ + public function getAdditionalContent(): array + { + return $this->additionalContent; + } + + /** + * Set a single entry in additionalContent, by key. + * + * @param string $key + * @param mixed|null $value Any type of value can be set. + * + * @return $this + */ + public function addAdditionalContentEntry(string $key, $value): self + { + $this->additionalContent[$key] = $value; return $this; } + /** + * Get a single entry in additionalContent, by key. + * + * @param string $key + * @param mixed|null $default + * + * @return mixed|null can be any type or even null. + */ + public function getAdditionalContentEntry(string $key, $default = null) + { + return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default; + } + /** * Rename a tag in tags list. * * @param string $fromTag * @param string $toTag */ - public function renameTag($fromTag, $toTag) + public function renameTag(string $fromTag, string $toTag): void { - if (($pos = array_search($fromTag, $this->tags)) !== false) { + if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) { $this->tags[$pos] = trim($toTag); } } @@ -452,9 +521,9 @@ public function renameTag($fromTag, $toTag) * * @param string $tag */ - public function deleteTag($tag) + public function deleteTag(string $tag): void { - if (($pos = array_search($tag, $this->tags)) !== false) { + if (($pos = array_search($tag, $this->tags ?? [])) !== false) { unset($this->tags[$pos]); $this->tags = array_values($this->tags); } diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php index 3bd5eb20f..b93281166 100644 --- a/application/bookmark/BookmarkArray.php +++ b/application/bookmark/BookmarkArray.php @@ -1,5 +1,7 @@ getId() === null || empty($value->getUrl()) || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) || $offset !== null && $offset !== $value->getId() @@ -187,13 +190,13 @@ public function valid() /** * Returns a bookmark offset in bookmarks array from its unique ID. * - * @param int $id Persistent ID of a bookmark. + * @param int|null $id Persistent ID of a bookmark. * * @return int Real offset in local array, or null if doesn't exist. */ - protected function getBookmarkOffset($id) + protected function getBookmarkOffset(?int $id): ?int { - if (isset($this->ids[$id])) { + if ($id !== null && isset($this->ids[$id])) { return $this->ids[$id]; } return null; @@ -205,7 +208,7 @@ protected function getBookmarkOffset($id) * * @return int next ID. */ - public function getNextId() + public function getNextId(): int { if (!empty($this->ids)) { return max(array_keys($this->ids)) + 1; @@ -214,13 +217,14 @@ public function getNextId() } /** - * @param $url + * @param string $url * * @return Bookmark|null */ - public function getByUrl($url) + public function getByUrl(string $url): ?Bookmark { - if (! empty($url) + if ( + ! empty($url) && isset($this->urls[$url]) && isset($this->bookmarks[$this->urls[$url]]) ) { diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php index c9ec26093..6666a251c 100644 --- a/application/bookmark/BookmarkFileService.php +++ b/application/bookmark/BookmarkFileService.php @@ -1,10 +1,12 @@ conf = $conf; $this->history = $history; + $this->mutex = $mutex; $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn); - $this->bookmarksIO = new BookmarkIO($this->conf); + $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex); $this->isLoggedIn = $isLoggedIn; if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { @@ -63,7 +69,7 @@ public function __construct(ConfigManager $conf, History $history, $isLoggedIn) } else { try { $this->bookmarks = $this->bookmarksIO->read(); - } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { + } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) { $this->bookmarks = new BookmarkArray(); if ($this->isLoggedIn) { @@ -79,25 +85,29 @@ public function __construct(ConfigManager $conf, History $history, $isLoggedIn) if (! $this->bookmarks instanceof BookmarkArray) { $this->migrate(); exit( - 'Your data store has been migrated, please reload the page.'. PHP_EOL . + 'Your data store has been migrated, please reload the page.' . PHP_EOL . 'If this message keeps showing up, please delete data/updates.txt file.' ); } } - $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); + $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf); } /** * @inheritDoc */ - public function findByHash($hash) + public function findByHash(string $hash, string $privateKey = null): Bookmark { $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); // PHP 7.3 introduced array_key_first() to avoid this hack $first = reset($bookmark); - if (! $this->isLoggedIn && $first->isPrivate()) { - throw new Exception('Not authorized'); + if ( + !$this->isLoggedIn + && $first->isPrivate() + && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key')) + ) { + throw new BookmarkNotFoundException(); } return $first; @@ -106,7 +116,7 @@ public function findByHash($hash) /** * @inheritDoc */ - public function findByUrl($url) + public function findByUrl(string $url): ?Bookmark { return $this->bookmarks->getByUrl($url); } @@ -115,10 +125,10 @@ public function findByUrl($url) * @inheritDoc */ public function search( - $request = [], - $visibility = null, - $caseSensitive = false, - $untaggedOnly = false, + array $request = [], + string $visibility = null, + bool $caseSensitive = false, + bool $untaggedOnly = false, bool $ignoreSticky = false ) { if ($visibility === null) { @@ -126,8 +136,8 @@ public function search( } // Filter bookmark database according to parameters. - $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; - $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; + $searchTags = isset($request['searchtags']) ? $request['searchtags'] : ''; + $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : ''; if ($ignoreSticky) { $this->bookmarks->reorder('DESC', true); @@ -135,7 +145,7 @@ public function search( return $this->bookmarkFilter->filter( BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, - [$searchtags, $searchterm], + [$searchTags, $searchTerm], $caseSensitive, $visibility, $untaggedOnly @@ -145,7 +155,7 @@ public function search( /** * @inheritDoc */ - public function get($id, $visibility = null) + public function get(int $id, string $visibility = null): Bookmark { if (! isset($this->bookmarks[$id])) { throw new BookmarkNotFoundException(); @@ -156,7 +166,8 @@ public function get($id, $visibility = null) } $bookmark = $this->bookmarks[$id]; - if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') + if ( + ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') ) { throw new Exception('Unauthorized'); @@ -168,20 +179,17 @@ public function get($id, $visibility = null) /** * @inheritDoc */ - public function set($bookmark, $save = true) + public function set(Bookmark $bookmark, bool $save = true): Bookmark { if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } - if (! $bookmark instanceof Bookmark) { - throw new Exception(t('Provided data is invalid')); - } if (! isset($this->bookmarks[$bookmark->getId()])) { throw new BookmarkNotFoundException(); } $bookmark->validate(); - $bookmark->setUpdated(new \DateTime()); + $bookmark->setUpdated(new DateTime()); $this->bookmarks[$bookmark->getId()] = $bookmark; if ($save === true) { $this->save(); @@ -193,15 +201,12 @@ public function set($bookmark, $save = true) /** * @inheritDoc */ - public function add($bookmark, $save = true) + public function add(Bookmark $bookmark, bool $save = true): Bookmark { if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } - if (! $bookmark instanceof Bookmark) { - throw new Exception(t('Provided data is invalid')); - } - if (! empty($bookmark->getId())) { + if (!empty($bookmark->getId())) { throw new Exception(t('This bookmarks already exists')); } $bookmark->setId($this->bookmarks->getNextId()); @@ -218,14 +223,11 @@ public function add($bookmark, $save = true) /** * @inheritDoc */ - public function addOrSet($bookmark, $save = true) + public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark { if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } - if (! $bookmark instanceof Bookmark) { - throw new Exception('Provided data is invalid'); - } if ($bookmark->getId() === null) { return $this->add($bookmark, $save); } @@ -235,14 +237,11 @@ public function addOrSet($bookmark, $save = true) /** * @inheritDoc */ - public function remove($bookmark, $save = true) + public function remove(Bookmark $bookmark, bool $save = true): void { if (true !== $this->isLoggedIn) { throw new Exception(t('You\'re not authorized to alter the datastore')); } - if (! $bookmark instanceof Bookmark) { - throw new Exception(t('Provided data is invalid')); - } if (! isset($this->bookmarks[$bookmark->getId()])) { throw new BookmarkNotFoundException(); } @@ -257,7 +256,7 @@ public function remove($bookmark, $save = true) /** * @inheritDoc */ - public function exists($id, $visibility = null) + public function exists(int $id, string $visibility = null): bool { if (! isset($this->bookmarks[$id])) { return false; @@ -268,7 +267,8 @@ public function exists($id, $visibility = null) } $bookmark = $this->bookmarks[$id]; - if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') + if ( + ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') ) { return false; @@ -280,7 +280,7 @@ public function exists($id, $visibility = null) /** * @inheritDoc */ - public function count($visibility = null) + public function count(string $visibility = null): int { return count($this->search([], $visibility)); } @@ -288,7 +288,7 @@ public function count($visibility = null) /** * @inheritDoc */ - public function save() + public function save(): void { if (true !== $this->isLoggedIn) { // TODO: raise an Exception instead @@ -303,14 +303,15 @@ public function save() /** * @inheritDoc */ - public function bookmarksCountPerTag($filteringTags = [], $visibility = null) + public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array { $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); $tags = []; $caseMapping = []; foreach ($bookmarks as $bookmark) { foreach ($bookmark->getTags() as $tag) { - if (empty($tag) + if ( + empty($tag) || (! $this->isLoggedIn && startsWith($tag, '.')) || $tag === BookmarkMarkdownFormatter::NO_MD_TAG || in_array($tag, $filteringTags, true) @@ -339,38 +340,55 @@ public function bookmarksCountPerTag($filteringTags = [], $visibility = null) $keys = array_keys($tags); $tmpTags = array_combine($keys, $keys); array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); + return $tags; } /** * @inheritDoc */ - public function days() - { - $bookmarkDays = []; - foreach ($this->search() as $bookmark) { - $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; + public function findByDate( + \DateTimeInterface $from, + \DateTimeInterface $to, + ?\DateTimeInterface &$previous, + ?\DateTimeInterface &$next + ): array { + $out = []; + $previous = null; + $next = null; + + foreach ($this->search([], null, false, false, true) as $bookmark) { + if ($to < $bookmark->getCreated()) { + $next = $bookmark->getCreated(); + } elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) { + $out[] = $bookmark; + } else { + if ($previous !== null) { + break; + } + $previous = $bookmark->getCreated(); + } } - $bookmarkDays = array_keys($bookmarkDays); - sort($bookmarkDays); - return $bookmarkDays; + return $out; } /** * @inheritDoc */ - public function filterDay($request) + public function getLatest(): ?Bookmark { - $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; + foreach ($this->search([], null, false, false, true) as $bookmark) { + return $bookmark; + } - return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); + return null; } /** * @inheritDoc */ - public function initialize() + public function initialize(): void { $initializer = new BookmarkInitializer($this); $initializer->initialize(); @@ -383,7 +401,7 @@ public function initialize() /** * Handles migration to the new database format (BookmarksArray). */ - protected function migrate() + protected function migrate(): void { $bookmarkDb = new LegacyLinkDB( $this->conf->get('resource.datastore'), @@ -391,14 +409,14 @@ protected function migrate() false ); $updater = new LegacyUpdater( - UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), + UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')), $bookmarkDb, $this->conf, true ); $newUpdates = $updater->update(); if (! empty($newUpdates)) { - UpdaterUtils::write_updates_file( + UpdaterUtils::writeUpdatesFile( $this->conf->get('resource.updates'), $updater->getDoneUpdates() ); diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php index 6636bbfee..db83c51c1 100644 --- a/application/bookmark/BookmarkFilter.php +++ b/application/bookmark/BookmarkFilter.php @@ -1,9 +1,12 @@ bookmarks = $bookmarks; + $this->conf = $conf; } /** @@ -77,8 +84,13 @@ public function __construct($bookmarks) * * @throws BookmarkNotFoundException */ - public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) - { + public function filter( + string $type, + $request, + bool $casesensitive = false, + string $visibility = 'all', + bool $untaggedonly = false + ) { if (!in_array($visibility, ['all', 'public', 'private'])) { $visibility = 'all'; } @@ -100,10 +112,14 @@ public function filter($type, $request, $casesensitive = false, $visibility = 'a $filtered = $this->bookmarks; } if (!empty($request[0])) { - $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); + $filtered = (new BookmarkFilter($filtered, $this->conf)) + ->filterTags($request[0], $casesensitive, $visibility) + ; } if (!empty($request[1])) { - $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); + $filtered = (new BookmarkFilter($filtered, $this->conf)) + ->filterFulltext($request[1], $visibility) + ; } return $filtered; case self::$FILTER_TEXT: @@ -128,13 +144,13 @@ public function filter($type, $request, $casesensitive = false, $visibility = 'a * * @return Bookmark[] filtered bookmarks. */ - private function noFilter($visibility = 'all') + private function noFilter(string $visibility = 'all') { if ($visibility === 'all') { return $this->bookmarks; } - $out = array(); + $out = []; foreach ($this->bookmarks as $key => $value) { if ($value->isPrivate() && $visibility === 'private') { $out[$key] = $value; @@ -151,11 +167,11 @@ private function noFilter($visibility = 'all') * * @param string $smallHash permalink hash. * - * @return array $filtered array containing permalink data. + * @return Bookmark[] $filtered array containing permalink data. * - * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. + * @throws BookmarkNotFoundException if the smallhash doesn't match any link. */ - private function filterSmallHash($smallHash) + private function filterSmallHash(string $smallHash) { foreach ($this->bookmarks as $key => $l) { if ($smallHash == $l->getShortUrl()) { @@ -186,15 +202,15 @@ private function filterSmallHash($smallHash) * @param string $searchterms search query. * @param string $visibility Optional: return only all/private/public bookmarks. * - * @return array search results. + * @return Bookmark[] search results. */ - private function filterFulltext($searchterms, $visibility = 'all') + private function filterFulltext(string $searchterms, string $visibility = 'all') { if (empty($searchterms)) { return $this->noFilter($visibility); } - $filtered = array(); + $filtered = []; $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); $exactRegex = '/"([^"]+)"/'; // Retrieve exact search terms. @@ -206,8 +222,8 @@ private function filterFulltext($searchterms, $visibility = 'all') $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); // Filter excluding terms and update andSearch. - $excludeSearch = array(); - $andSearch = array(); + $excludeSearch = []; + $andSearch = []; foreach ($explodedSearchAnd as $needle) { if ($needle[0] == '-' && strlen($needle) > 1) { $excludeSearch[] = substr($needle, 1); @@ -227,33 +243,38 @@ private function filterFulltext($searchterms, $visibility = 'all') } } - // Concatenate link fields to search across fields. - // Adds a '\' separator for exact search terms. - $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; - $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; - $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; - $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; + $lengths = []; + $content = $this->buildFullTextSearchableLink($link, $lengths); // Be optimistic $found = true; + $foundPositions = []; // First, we look for exact term search - for ($i = 0; $i < count($exactSearch) && $found; $i++) { - $found = strpos($content, $exactSearch[$i]) !== false; - } - - // Iterate over keywords, if keyword is not found, + // Then iterate over keywords, if keyword is not found, // no need to check for the others. We want all or nothing. - for ($i = 0; $i < count($andSearch) && $found; $i++) { - $found = strpos($content, $andSearch[$i]) !== false; + foreach ([$exactSearch, $andSearch] as $search) { + for ($i = 0; $i < count($search) && $found !== false; $i++) { + $found = mb_strpos($content, $search[$i]); + if ($found === false) { + break; + } + + $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])]; + } } // Exclude terms. - for ($i = 0; $i < count($excludeSearch) && $found; $i++) { + for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) { $found = strpos($content, $excludeSearch[$i]) === false; } - if ($found) { + if ($found !== false) { + $link->addAdditionalContentEntry( + 'search_highlight', + $this->postProcessFoundPositions($lengths, $foundPositions) + ); + $filtered[$id] = $link; } } @@ -268,8 +289,9 @@ private function filterFulltext($searchterms, $visibility = 'all') * * @return string generated regex fragment */ - private static function tag2regex($tag) + protected function tag2regex(string $tag): string { + $tagsSeparator = $this->conf->get('general.tags_separator', ' '); $len = strlen($tag); if (!$len || $tag === "-" || $tag === "*") { // nothing to search, return empty regex @@ -283,12 +305,13 @@ private static function tag2regex($tag) $i = 0; // start at first character $regex = '(?='; // use positive lookahead } - $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning + // before tag may only be the separator or the beginning + $regex .= '.*(?:^|' . $tagsSeparator . ')'; // iterate over string, separating it into placeholder and content for (; $i < $len; $i++) { if ($tag[$i] === '*') { // placeholder found - $regex .= '[^ ]*?'; + $regex .= '[^' . $tagsSeparator . ']*?'; } else { // regular characters $offset = strpos($tag, '*', $i); @@ -304,7 +327,8 @@ private static function tag2regex($tag) $i = $offset; } } - $regex .= '(?:$| ))'; // after the tag may only be a space or the end + // after the tag may only be the separator or the end + $regex .= '(?:$|' . $tagsSeparator . '))'; return $regex; } @@ -314,22 +338,23 @@ private static function tag2regex($tag) * You can specify one or more tags, separated by space or a comma, e.g. * print_r($mydb->filterTags('linux programming')); * - * @param string $tags list of tags separated by commas or blank spaces. - * @param bool $casesensitive ignore case if false. - * @param string $visibility Optional: return only all/private/public bookmarks. + * @param string|array $tags list of tags, separated by commas or blank spaces if passed as string. + * @param bool $casesensitive ignore case if false. + * @param string $visibility Optional: return only all/private/public bookmarks. * - * @return array filtered bookmarks. + * @return Bookmark[] filtered bookmarks. */ - public function filterTags($tags, $casesensitive = false, $visibility = 'all') + public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all') { + $tagsSeparator = $this->conf->get('general.tags_separator', ' '); // get single tags (we may get passed an array, even though the docs say different) $inputTags = $tags; if (!is_array($tags)) { // we got an input string, split tags - $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); + $inputTags = tags_str2array($inputTags, $tagsSeparator); } - if (!count($inputTags)) { + if (count($inputTags) === 0) { // no input tags return $this->noFilter($visibility); } @@ -346,7 +371,7 @@ public function filterTags($tags, $casesensitive = false, $visibility = 'all') } // build regex from all tags - $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; + $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/'; if (!$casesensitive) { // make regex case insensitive $re .= 'i'; @@ -366,10 +391,11 @@ public function filterTags($tags, $casesensitive = false, $visibility = 'all') continue; } } - $search = $link->getTagsString(); // build search string, start with tags of current link + // build search string, start with tags of current link + $search = $link->getTagsString($tagsSeparator); if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { // description given and at least one possible tag found - $descTags = array(); + $descTags = []; // find all tags in the form of #tag in the description preg_match_all( '/(?bookmarks as $key => $link) { @@ -410,7 +436,7 @@ public function filterUntagged($visibility) } } - if (empty(trim($link->getTagsString()))) { + if (empty($link->getTags())) { $filtered[$key] = $link; } } @@ -427,11 +453,11 @@ public function filterUntagged($visibility) * @param string $day day to filter. * @param string $visibility return only all/private/public bookmarks. - * @return array all link matching given day. + * @return Bookmark[] all link matching given day. * * @throws Exception if date format is invalid. */ - public function filterDay($day, $visibility) + public function filterDay(string $day, string $visibility) { if (!checkDateFormat('Ymd', $day)) { throw new Exception('Invalid date format'); @@ -460,9 +486,9 @@ public function filterDay($day, $visibility) * @param string $tags string containing a list of tags. * @param bool $casesensitive will convert everything to lowercase if false. * - * @return array filtered tags string. + * @return string[] filtered tags string. */ - public static function tagsStrToArray($tags, $casesensitive) + public static function tagsStrToArray(string $tags, bool $casesensitive): array { // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); @@ -470,4 +496,75 @@ public static function tagsStrToArray($tags, $casesensitive) return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); } + + /** + * This method finalize the content of the foundPositions array, + * by associated all search results to their associated bookmark field, + * making sure that there is no overlapping results, etc. + * + * @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content. + * @param array $foundPositions Positions where the search results were found in the aggregated content. + * + * @return array Updated $foundPositions, by bookmark field. + */ + protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array + { + // Sort results by starting position ASC. + usort($foundPositions, function (array $entryA, array $entryB): int { + return $entryA['start'] > $entryB['start'] ? 1 : -1; + }); + + $out = []; + $currentMax = -1; + foreach ($foundPositions as $foundPosition) { + // we do not allow overlapping highlights + if ($foundPosition['start'] < $currentMax) { + continue; + } + + $currentMax = $foundPosition['end']; + foreach ($fieldLengths as $part => $length) { + if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) { + continue; + } + + $out[$part][] = [ + 'start' => $foundPosition['start'] - $length['start'], + 'end' => $foundPosition['end'] - $length['start'], + ]; + break; + } + } + + return $out; + } + + /** + * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms. + * Also populate $length array with starting and ending positions of every bookmark field + * inside concatenated content. + * + * @param Bookmark $link + * @param array $lengths (by reference) + * + * @return string Lowercase concatenated fields content. + */ + protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string + { + $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' ')); + $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\'; + $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\'; + $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\'; + $content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\'; + + $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())]; + $nextField = $lengths['title']['end'] + 1; + $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())]; + $nextField = $lengths['description']['end'] + 1; + $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())]; + $nextField = $lengths['url']['end'] + 1; + $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)]; + + return $content; + } } diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php index 6bf7f3654..c78dbe41f 100644 --- a/application/bookmark/BookmarkIO.php +++ b/application/bookmark/BookmarkIO.php @@ -1,7 +1,11 @@ conf = $conf; $this->datastore = $conf->get('resource.datastore'); + $this->mutex = $mutex; } /** * Reads database from disk to memory * - * @return BookmarkArray instance + * @return Bookmark[] * * @throws NotWritableDataStoreException Data couldn't be loaded * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark @@ -67,11 +79,16 @@ public function read() throw new NotWritableDataStoreException($this->datastore); } + $content = null; + $this->mutex->synchronized(function () use (&$content) { + $content = file_get_contents($this->datastore); + }); + // Note that gzinflate is faster than gzuncompress. // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 $links = unserialize(gzinflate(base64_decode( - substr(file_get_contents($this->datastore), - strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); + substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix)) + ))); if (empty($links)) { if (filesize($this->datastore) > 100) { @@ -86,7 +103,7 @@ public function read() /** * Saves the database from memory to disk * - * @param BookmarkArray $links instance. + * @param Bookmark[] $links * * @throws NotWritableDataStoreException the datastore is not writable */ @@ -95,14 +112,18 @@ public function write($links) if (is_file($this->datastore) && !is_writeable($this->datastore)) { // The datastore exists but is not writeable throw new NotWritableDataStoreException($this->datastore); - } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { + } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { // The datastore does not exist and its parent directory is not writeable throw new NotWritableDataStoreException(dirname($this->datastore)); } - file_put_contents( - $this->datastore, - self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix - ); + $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix; + + $this->mutex->synchronized(function () use ($data) { + file_put_contents( + $this->datastore, + $data + ); + }); } } diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php index 815047e38..8ab5c441a 100644 --- a/application/bookmark/BookmarkInitializer.php +++ b/application/bookmark/BookmarkInitializer.php @@ -1,5 +1,7 @@ bookmarkService = $bookmarkService; } @@ -31,13 +36,13 @@ public function __construct($bookmarkService) /** * Initialize the data store with default bookmarks */ - public function initialize() + public function initialize(): void { $bookmark = new Bookmark(); - $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); - $bookmark->setUrl('https://vimeo.com/153493904'); + $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)')); + $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c'); $bookmark->setDescription(t( -'Shaarli will automatically pick up the thumbnail for links to a variety of websites. + 'Shaarli will automatically pick up the thumbnail for links to a variety of websites. Explore your new Shaarli instance by trying out controls and menus. Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. @@ -52,7 +57,7 @@ public function initialize() $bookmark = new Bookmark(); $bookmark->setTitle(t('Note: Shaare descriptions')); $bookmark->setDescription(t( -'Adding a shaare without entering a URL creates a text-only "note" post such as this one. + 'Adding a shaare without entering a URL creates a text-only "note" post such as this one. This note is private, so you are the only one able to see it while logged in. You can use this to keep notes, post articles, code snippets, and much more. @@ -89,7 +94,7 @@ public function initialize() 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') ); $bookmark->setDescription(t( -'Welcome to Shaarli! + 'Welcome to Shaarli! Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. You can add a description to your bookmarks, such as this one, and tag them. diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php index b9b483eb8..08cdbb4ed 100644 --- a/application/bookmark/BookmarkServiceInterface.php +++ b/application/bookmark/BookmarkServiceInterface.php @@ -1,79 +1,73 @@ bookmarksCount */ - public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); + public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; /** - * Returns the list of days containing articles (oldest first) + * Return a list of bookmark matching provided period of time. + * It also update directly previous and next date outside of given period found in the datastore. + * + * @param \DateTimeInterface $from Starting date. + * @param \DateTimeInterface $to Ending date. + * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from. + * @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to. * - * @return array containing days (in format YYYYMMDD). + * @return array List of bookmarks matching provided period of time. */ - public function days(); + public function findByDate( + \DateTimeInterface $from, + \DateTimeInterface $to, + ?\DateTimeInterface &$previous, + ?\DateTimeInterface &$next + ): array; /** - * Returns the list of articles for a given day. + * Returns the latest bookmark by creation date. * - * @param string $request day to filter. Format: YYYYMMDD. - * - * @return Bookmark[] list of shaare found. - * - * @throws BookmarkNotFoundException + * @return Bookmark|null Found Bookmark or null if the datastore is empty. */ - public function filterDay($request); + public function getLatest(): ?Bookmark; /** * Creates the default database after a fresh install. */ - public function initialize(); + public function initialize(): void; } diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index e7af4d552..d65e97ed4 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php @@ -66,16 +66,19 @@ function html_extract_tag($tag, $html) { $propertiesKey = ['property', 'name', 'itemprop']; $properties = implode('|', $propertiesKey); - // Try to retrieve OpenGraph image. - $ogRegex = '#]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#'; + // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' + $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; + // Try to retrieve OpenGraph tag. + $ogRegex = '#]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*content=(["\'])([^\1]*?)\1.*?>#'; // If the attributes are not in the order property => content (e.g. Github) // New regex to keep this readable... more or less. - $ogRegexReverse = '#]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#'; + $ogRegexReverse = '#]+content=(["\'])([^\1]*?)\1[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#'; - if (preg_match($ogRegex, $html, $matches) > 0 + if ( + preg_match($ogRegex, $html, $matches) > 0 || preg_match($ogRegexReverse, $html, $matches) > 0 ) { - return $matches[1]; + return $matches[2]; } return false; @@ -114,7 +117,7 @@ function hashtag_autolink($description, $indexUrl = '') * \p{Mn} - any non marking space (accents, umlauts, etc) */ $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; - $replacement = '$1#$2'; + $replacement = '$1#$2'; return preg_replace($regex, $replacement, $description); } @@ -136,12 +139,17 @@ function space2nbsp($text) * * @param string $description shaare's description. * @param string $indexUrl URL to Shaarli's index. - + * @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags + * * @return string formatted description. */ -function format_description($description, $indexUrl = '') +function format_description($description, $indexUrl = '', $autolink = true) { - return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl))); + if ($autolink) { + $description = hashtag_autolink(text2clickable($description), $indexUrl); + } + + return nl2br(space2nbsp($description)); } /** @@ -169,3 +177,49 @@ function is_note($linkUrl) { return isset($linkUrl[0]) && $linkUrl[0] === '?'; } + +/** + * Extract an array of tags from a given tag string, with provided separator. + * + * @param string|null $tags String containing a list of tags separated by $separator. + * @param string $separator Shaarli's default: ' ' (whitespace) + * + * @return array List of tags + */ +function tags_str2array(?string $tags, string $separator): array +{ + // For whitespaces, we use the special \s regex character + $separator = $separator === ' ' ? '\s' : $separator; + + return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY); +} + +/** + * Return a tag string with provided separator from a list of tags. + * Note that given array is clean up by tags_filter(). + * + * @param array|null $tags List of tags + * @param string $separator + * + * @return string + */ +function tags_array2str(?array $tags, string $separator): string +{ + return implode($separator, tags_filter($tags, $separator)); +} + +/** + * Clean an array of tags: trim + remove empty entries + * + * @param array|null $tags List of tags + * @param string $separator + * + * @return array + */ +function tags_filter(?array $tags, string $separator): array +{ + $trimDefault = " \t\n\r\0\x0B"; + return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string { + return trim($entry, $trimDefault . $separator); + }, $tags ?? []))); +} diff --git a/application/bookmark/exception/BookmarkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php index 827a3d358..a91d1efaa 100644 --- a/application/bookmark/exception/BookmarkNotFoundException.php +++ b/application/bookmark/exception/BookmarkNotFoundException.php @@ -1,4 +1,5 @@ message = 'This bookmark is not valid'. PHP_EOL; - $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; - $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; - $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; - $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; - $this->message .= ' - Created: '. $created . PHP_EOL; + $this->message = 'This bookmark is not valid' . PHP_EOL; + $this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL; + $this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL; + $this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL; + $this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL; + $this->message .= ' - Created: ' . $created . PHP_EOL; } else { - $this->message = 'The provided data is not a bookmark'. PHP_EOL; + $this->message = 'The provided data is not a bookmark' . PHP_EOL; $this->message .= var_export($bookmark, true); } } diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php index 95f34b505..df91f3bce 100644 --- a/application/bookmark/exception/NotWritableDataStoreException.php +++ b/application/bookmark/exception/NotWritableDataStoreException.php @@ -1,9 +1,7 @@ message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. + $this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' . 'Your data might be corrupted, or your file isn\'t readable.'; } } diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php index 3efe5b6fb..a623bc8ba 100644 --- a/application/config/ConfigIO.php +++ b/application/config/ConfigIO.php @@ -1,4 +1,5 @@ '; + return '*/ ?>'; } } diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index 4c98be305..717a038f7 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -1,4 +1,5 @@ getConfigFileExt()) && !$isLoggedIn) { @@ -366,10 +367,12 @@ protected function setDefaultValues() $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('general.tags_separator', ' '); - $this->setEmpty('updates.check_updates', false); - $this->setEmpty('updates.check_updates_branch', 'stable'); + $this->setEmpty('updates.check_updates', true); + $this->setEmpty('updates.check_updates_branch', 'latest'); $this->setEmpty('updates.check_updates_interval', 86400); $this->setEmpty('feed.rss_permalinks', true); @@ -390,7 +393,7 @@ protected function setDefaultValues() $this->setEmpty('translation.mode', 'php'); $this->setEmpty('translation.extensions', []); - $this->setEmpty('plugins', array()); + $this->setEmpty('plugins', []); $this->setEmpty('formatter', 'markdown'); } diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php index cad345946..53d6a7a35 100644 --- a/application/config/ConfigPhp.php +++ b/application/config/ConfigPhp.php @@ -1,4 +1,5 @@ legacy key. */ - public static $LEGACY_KEYS_MAPPING = array( + public static $LEGACY_KEYS_MAPPING = [ 'credentials.login' => 'login', 'credentials.hash' => 'hash', 'credentials.salt' => 'salt', @@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS', 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS', 'security.open_shaarli' => 'config.OPEN_SHAARLI', - ); + ]; /** * @inheritdoc @@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO public function read($filepath) { if (! file_exists($filepath) || ! is_readable($filepath)) { - return array(); + return []; } include $filepath; - $out = array(); + $out = []; foreach (self::$ROOT_KEYS as $key) { $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : ''; } @@ -95,7 +96,7 @@ public function read($filepath) */ public function write($filepath, $conf) { - $configStr = ' $value) { $configStr .= '$GLOBALS[\'config\'][\'' . $key - .'\'] = ' - .var_export($conf['config'][$key], true).';' + . '\'] = ' + . var_export($conf['config'][$key], true) . ';' . PHP_EOL; } @@ -115,18 +116,19 @@ public function write($filepath, $conf) foreach ($conf['plugins'] as $key => $value) { $configStr .= '$GLOBALS[\'plugins\'][\'' . $key - .'\'] = ' - .var_export($conf['plugins'][$key], true).';' + . '\'] = ' + . var_export($conf['plugins'][$key], true) . ';' . PHP_EOL; } } - if (!file_put_contents($filepath, $configStr) + if ( + !file_put_contents($filepath, $configStr) || strcmp(file_get_contents($filepath), $configStr) != 0 ) { throw new \Shaarli\Exceptions\IOException( $filepath, - t('Shaarli could not create the config file. '. + t('Shaarli could not create the config file. ' . 'Please make sure Shaarli has the right to write in the folder is it installed in.') ); } diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php index ea8dfbdad..6cadef126 100644 --- a/application/config/ConfigPlugin.php +++ b/application/config/ConfigPlugin.php @@ -39,8 +39,8 @@ function ($value, string $key) use ($directories) { throw new PluginConfigOrderException(); } - $plugins = array(); - $newEnabledPlugins = array(); + $plugins = []; + $newEnabledPlugins = []; foreach ($formData as $key => $data) { if (startsWith($key, 'order')) { continue; @@ -62,7 +62,7 @@ function ($value, string $key) use ($directories) { throw new PluginConfigOrderException(); } - $finalPlugins = array(); + $finalPlugins = []; // Make plugins order continuous. foreach ($plugins as $plugin) { $finalPlugins[] = $plugin; @@ -81,7 +81,7 @@ function ($value, string $key) use ($directories) { */ function validate_plugin_order($formData) { - $orders = array(); + $orders = []; foreach ($formData as $key => $value) { // No duplicate order allowed. if (in_array($value, $orders, true)) { diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php index 9e0a93594..a5f4356ae 100644 --- a/application/config/exception/MissingFieldConfigException.php +++ b/application/config/exception/MissingFieldConfigException.php @@ -1,6 +1,5 @@ conf = $conf; $this->session = $session; $this->login = $login; $this->cookieManager = $cookieManager; + $this->logger = $logger; } public function build(): ShaarliContainer @@ -70,6 +78,7 @@ public function build(): ShaarliContainer $container['sessionManager'] = $this->session; $container['cookieManager'] = $this->cookieManager; $container['loginManager'] = $this->login; + $container['logger'] = $this->logger; $container['basePath'] = $this->basePath; $container['plugins'] = function (ShaarliContainer $container): PluginManager { @@ -84,14 +93,20 @@ public function build(): ShaarliContainer return new BookmarkFileService( $container->conf, $container->history, + new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), $container->loginManager->isLoggedIn() ); }; + $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever { + return new MetadataRetriever($container->conf, $container->httpAccess); + }; + $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { return new PageBuilder( $container->conf, $container->sessionManager->getSession(), + $container->logger, $container->bookmarkService, $container->sessionManager->generateToken(), $container->loginManager->isLoggedIn() @@ -143,7 +158,7 @@ public function build(): ShaarliContainer $container['updater'] = function (ShaarliContainer $container): Updater { return new Updater( - UpdaterUtils::read_updates_file($container->conf->get('resource.updates')), + UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')), $container->bookmarkService, $container->conf, $container->loginManager->isLoggedIn() diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php index 66e669aae..3e5bd2526 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -4,12 +4,14 @@ namespace Shaarli\Container; +use Psr\Log\LoggerInterface; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; 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 +37,8 @@ * @property History $history * @property HttpAccess $httpAccess * @property LoginManager $loginManager + * @property LoggerInterface $logger + * @property MetadataRetriever $metadataRetriever * @property NetscapeBookmarkUtils $netscapeBookmarkUtils * @property callable $notFoundHandler Overrides default Slim exception display * @property PageBuilder $pageBuilder diff --git a/application/exceptions/IOException.php b/application/exceptions/IOException.php index 2aa25e5c5..c1a9ffbe5 100644 --- a/application/exceptions/IOException.php +++ b/application/exceptions/IOException.php @@ -1,4 +1,5 @@ linkDB->search($userInput, null, false, false, true); + $linksToDisplay = $this->linkDB->search($userInput ?? [], null, false, false, true); $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput); // Can't use array_keys() because $link is a LinkDB instance and not a real array. - $keys = array(); + $keys = []; foreach ($linksToDisplay as $key => $value) { $keys[] = $key; } $pageaddr = escape(index_url($this->serverInfo)); $this->formatter->addContextData('index_url', $pageaddr); - $linkDisplayed = array(); + $linkDisplayed = []; for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr); } @@ -176,9 +177,9 @@ protected function buildItem(string $feedType, $link, $pageaddr) $data = $this->formatter->format($link); $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl']; if ($this->usePermalinks === true) { - $permalink = ''. t('Direct link') .''; + $permalink = '' . t('Direct link') . ''; } else { - $permalink = ''. t('Permalink') .''; + $permalink = '' . t('Permalink') . ''; } $data['description'] .= PHP_EOL . PHP_EOL . '
— ' . $permalink; diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php index 9d4a0fa02..7e0afafc8 100644 --- a/application/formatter/BookmarkDefaultFormatter.php +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -12,10 +12,13 @@ */ class BookmarkDefaultFormatter extends BookmarkFormatter { + protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT'; + protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|'; + /** * @inheritdoc */ - public function formatTitle($bookmark) + protected function formatTitle($bookmark) { return escape($bookmark->getTitle()); } @@ -23,10 +26,33 @@ public function formatTitle($bookmark) /** * @inheritdoc */ - public function formatDescription($bookmark) + protected function formatTitleHtml($bookmark) + { + $title = $this->tokenizeSearchHighlightField( + $bookmark->getTitle() ?? '', + $bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? [] + ); + + return $this->replaceTokens(escape($title)); + } + + /** + * @inheritdoc + */ + protected function formatDescription($bookmark) { $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; - return format_description(escape($bookmark->getDescription()), $indexUrl); + $description = $this->tokenizeSearchHighlightField( + $bookmark->getDescription() ?? '', + $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] + ); + $description = format_description( + escape($description), + $indexUrl, + $this->conf->get('formatter_settings.autolink', true) + ); + + return $this->replaceTokens($description); } /** @@ -40,15 +66,36 @@ protected function formatTagList($bookmark) /** * @inheritdoc */ - public function formatTagString($bookmark) + protected function formatTagListHtml($bookmark) { - return implode(' ', $this->formatTagList($bookmark)); + $tagsSeparator = $this->conf->get('general.tags_separator', ' '); + if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { + return $this->formatTagList($bookmark); + } + + $tags = $this->tokenizeSearchHighlightField( + $bookmark->getTagsString($tagsSeparator), + $bookmark->getAdditionalContentEntry('search_highlight')['tags'] + ); + $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator)); + $tags = escape($tags); + $tags = $this->replaceTokensArray($tags); + + return $tags; } /** * @inheritdoc */ - public function formatUrl($bookmark) + protected function formatTagString($bookmark) + { + return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark)); + } + + /** + * @inheritdoc + */ + protected function formatUrl($bookmark) { if ($bookmark->isNote() && isset($this->contextData['index_url'])) { return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/')); @@ -77,6 +124,19 @@ protected function formatRealUrl($bookmark) return escape($bookmark->getUrl()); } + /** + * @inheritdoc + */ + protected function formatUrlHtml($bookmark) + { + $url = $this->tokenizeSearchHighlightField( + $bookmark->getUrl() ?? '', + $bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? [] + ); + + return $this->replaceTokens(escape($url)); + } + /** * @inheritdoc */ @@ -84,4 +144,72 @@ protected function formatThumbnail($bookmark) { return escape($bookmark->getThumbnail()); } + + /** + * Insert search highlight token in provided field content based on a list of search result positions + * + * @param string $fieldContent + * @param array|null $positions List of of search results with 'start' and 'end' positions. + * + * @return string Updated $fieldContent. + */ + protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string + { + if (empty($positions)) { + return $fieldContent; + } + + $insertedTokens = 0; + $tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN); + foreach ($positions as $position) { + $position = [ + 'start' => $position['start'] + ($insertedTokens * $tokenLength), + 'end' => $position['end'] + ($insertedTokens * $tokenLength), + ]; + + $content = mb_substr($fieldContent, 0, $position['start']); + $content .= static::SEARCH_HIGHLIGHT_OPEN; + $content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']); + $content .= static::SEARCH_HIGHLIGHT_CLOSE; + $content .= mb_substr($fieldContent, $position['end']); + + $fieldContent = $content; + + $insertedTokens += 2; + } + + return $fieldContent; + } + + /** + * Replace search highlight tokens with HTML highlighted span. + * + * @param string $fieldContent + * + * @return string updated content. + */ + protected function replaceTokens(string $fieldContent): string + { + return str_replace( + [static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE], + ['', ''], + $fieldContent + ); + } + + /** + * Apply replaceTokens to an array of content strings. + * + * @param string[] $fieldContents + * + * @return array + */ + protected function replaceTokensArray(array $fieldContents): array + { + foreach ($fieldContents as &$entry) { + $entry = $this->replaceTokens($entry); + } + + return $fieldContents; + } } diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php index 0042dafe4..124ce78bd 100644 --- a/application/formatter/BookmarkFormatter.php +++ b/application/formatter/BookmarkFormatter.php @@ -2,7 +2,7 @@ namespace Shaarli\Formatter; -use DateTime; +use DateTimeInterface; use Shaarli\Bookmark\Bookmark; use Shaarli\Config\ConfigManager; @@ -11,6 +11,29 @@ * * Abstract class processing all bookmark attributes through methods designed to be overridden. * + * List of available formatted fields: + * - id ID + * - shorturl Unique identifier, used in permalinks + * - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy + * - real_url (legacy) same as `url` + * - url_html URL to be displayed in HTML content (it can contain HTML tags) + * - title Title + * - title_html Title to be displayed in HTML content (it can contain HTML tags) + * - description Description content. It most likely contains HTML tags + * - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved + * - taglist List of tags (array) + * - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag + * - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags) + * - tags Tags separated by a single whitespace + * - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link + * - sticky Is sticky (bool) + * - private Is private (bool) + * - class Additional CSS class + * - created Creation DateTime + * - updated Last edit DateTime + * - timestamp Creation timestamp + * - updated_timestamp Last edit timestamp + * * @package Shaarli\Formatter */ abstract class BookmarkFormatter @@ -55,13 +78,16 @@ public function format($bookmark) $out['shorturl'] = $this->formatShortUrl($bookmark); $out['url'] = $this->formatUrl($bookmark); $out['real_url'] = $this->formatRealUrl($bookmark); + $out['url_html'] = $this->formatUrlHtml($bookmark); $out['title'] = $this->formatTitle($bookmark); + $out['title_html'] = $this->formatTitleHtml($bookmark); $out['description'] = $this->formatDescription($bookmark); $out['thumbnail'] = $this->formatThumbnail($bookmark); - $out['urlencoded_taglist'] = $this->formatUrlEncodedTagList($bookmark); $out['taglist'] = $this->formatTagList($bookmark); - $out['urlencoded_tags'] = $this->formatUrlEncodedTagString($bookmark); + $out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark); + $out['taglist_html'] = $this->formatTagListHtml($bookmark); $out['tags'] = $this->formatTagString($bookmark); + $out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark); $out['sticky'] = $bookmark->isSticky(); $out['private'] = $bookmark->isPrivate(); $out['class'] = $this->formatClass($bookmark); @@ -69,6 +95,7 @@ public function format($bookmark) $out['updated'] = $this->formatUpdated($bookmark); $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); + return $out; } @@ -135,6 +162,18 @@ protected function formatRealUrl($bookmark) return $this->formatUrl($bookmark); } + /** + * Format Url Html: to be displayed in HTML content, it can contains HTML tags. + * + * @param Bookmark $bookmark instance + * + * @return string formatted Url HTML + */ + protected function formatUrlHtml($bookmark) + { + return $this->formatUrl($bookmark); + } + /** * Format Title * @@ -147,6 +186,18 @@ protected function formatTitle($bookmark) return $bookmark->getTitle(); } + /** + * Format Title HTML: to be displayed in HTML content, it can contains HTML tags. + * + * @param Bookmark $bookmark instance + * + * @return string formatted Title + */ + protected function formatTitleHtml($bookmark) + { + return $bookmark->getTitle(); + } + /** * Format Description * @@ -190,11 +241,23 @@ protected function formatTagList($bookmark) * * @return array formatted Tags */ - protected function formatUrlEncodedTagList($bookmark) + protected function formatTagListUrlEncoded($bookmark) { return array_map('urlencode', $this->filterTagList($bookmark->getTags())); } + /** + * Format Tags HTML: to be displayed in HTML content, it can contains HTML tags. + * + * @param Bookmark $bookmark instance + * + * @return array formatted Tags + */ + protected function formatTagListHtml($bookmark) + { + return $this->formatTagList($bookmark); + } + /** * Format TagString * @@ -204,7 +267,7 @@ protected function formatUrlEncodedTagList($bookmark) */ protected function formatTagString($bookmark) { - return implode(' ', $this->formatTagList($bookmark)); + return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark)); } /** @@ -214,9 +277,9 @@ protected function formatTagString($bookmark) * * @return string formatted TagString */ - protected function formatUrlEncodedTagString($bookmark) + protected function formatTagStringUrlEncoded($bookmark) { - return implode(' ', $this->formatUrlEncodedTagList($bookmark)); + return implode(' ', $this->formatTagListUrlEncoded($bookmark)); } /** @@ -237,7 +300,7 @@ protected function formatClass($bookmark) * * @param Bookmark $bookmark instance * - * @return DateTime instance + * @return DateTimeInterface instance */ protected function formatCreated(Bookmark $bookmark) { @@ -249,7 +312,7 @@ protected function formatCreated(Bookmark $bookmark) * * @param Bookmark $bookmark instance * - * @return DateTime instance + * @return DateTimeInterface instance */ protected function formatUpdated(Bookmark $bookmark) { @@ -288,6 +351,7 @@ protected function formatUpdatedTimestamp(Bookmark $bookmark) /** * Format tag list, e.g. remove private tags if the user is not logged in. + * TODO: this method is called multiple time to format tags, the result should be cached. * * @param array $tags * diff --git a/application/formatter/BookmarkMarkdownExtraFormatter.php b/application/formatter/BookmarkMarkdownExtraFormatter.php new file mode 100644 index 000000000..0694b23fe --- /dev/null +++ b/application/formatter/BookmarkMarkdownExtraFormatter.php @@ -0,0 +1,24 @@ +parsedown = new \ParsedownExtra(); + } +} diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php index 5d244d4c9..ee4e8dca4 100644 --- a/application/formatter/BookmarkMarkdownFormatter.php +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -16,7 +16,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter /** * When this tag is present in a bookmark, its description should not be processed with Markdown */ - const NO_MD_TAG = 'nomarkdown'; + public const NO_MD_TAG = 'nomarkdown'; /** @var \Parsedown instance */ protected $parsedown; @@ -56,7 +56,10 @@ public function formatDescription($bookmark) return parent::formatDescription($bookmark); } - $processedDescription = $bookmark->getDescription(); + $processedDescription = $this->tokenizeSearchHighlightField( + $bookmark->getDescription() ?? '', + $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] + ); $processedDescription = $this->filterProtocols($processedDescription); $processedDescription = $this->formatHashTags($processedDescription); $processedDescription = $this->reverseEscapedHtml($processedDescription); @@ -65,9 +68,10 @@ public function formatDescription($bookmark) ->setBreaksEnabled(true) ->text($processedDescription); $processedDescription = $this->sanitizeHtml($processedDescription); + $processedDescription = $this->replaceTokens($processedDescription); if (!empty($processedDescription)) { - $processedDescription = '
'. $processedDescription . '
'; + $processedDescription = '
' . $processedDescription . '
'; } return $processedDescription; @@ -106,7 +110,7 @@ protected function filterProtocols($description) function ($match) use ($allowedProtocols, $indexUrl) { $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; $link .= whitelist_protocols($match[1], $allowedProtocols); - return ']('. $link.')'; + return '](' . $link . ')'; }, $description ); @@ -133,7 +137,7 @@ protected function formatHashTags($description) * \p{Mn} - any non marking space (accents, umlauts, etc) */ $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; - $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)'; + $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)'; $descriptionLines = explode(PHP_EOL, $description); $descriptionOut = ''; @@ -174,17 +178,17 @@ protected function formatHashTags($description) */ protected function sanitizeHtml($description) { - $escapeTags = array( + $escapeTags = [ 'script', 'style', 'link', 'iframe', 'frameset', 'frame', - ); + ]; foreach ($escapeTags as $tag) { $description = preg_replace_callback( - '#<\s*'. $tag .'[^>]*>(.*]*>)?#is', + '#<\s*' . $tag . '[^>]*>(.*]*>)?#is', function ($match) { return escape($match[0]); }, diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php index bc3722737..4ff07cdf4 100644 --- a/application/formatter/BookmarkRawFormatter.php +++ b/application/formatter/BookmarkRawFormatter.php @@ -10,4 +10,6 @@ * * @package Shaarli\Formatter */ -class BookmarkRawFormatter extends BookmarkFormatter {} +class BookmarkRawFormatter extends BookmarkFormatter +{ +} diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php index a029579f6..bb865aedf 100644 --- a/application/formatter/FormatterFactory.php +++ b/application/formatter/FormatterFactory.php @@ -41,7 +41,7 @@ public function __construct(ConfigManager $conf, bool $isLoggedIn) public function getFormatter(string $type = null): BookmarkFormatter { $type = $type ? $type : $this->conf->get('formatter', 'default'); - $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; + $className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter'; if (!class_exists($className)) { $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; } diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php index d1aa13998..164217f4f 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -42,7 +42,8 @@ public function __invoke(Request $request, Response $response, callable $next): $this->initBasePath($request); try { - if (!is_file($this->container->conf->getConfigFileExt()) + if ( + !is_file($this->container->conf->getConfigFileExt()) && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) ) { return $response->withRedirect($this->container->basePath . '/install'); @@ -86,7 +87,8 @@ protected function runUpdates(): void */ protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool { - if (// if the user isn't logged in + if ( +// if the user isn't logged in !$this->container->loginManager->isLoggedIn() // and Shaarli doesn't have public content... && $this->container->conf->get('privacy.hide_public_links') diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php index e675fccab..dc421661c 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php @@ -30,7 +30,7 @@ public function index(Request $request, Response $response): Response 'theme_available', ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl')) ); - $this->assignView('formatter_available', ['default', 'markdown']); + $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']); list($continents, $cities) = generateTimeZoneData( timezone_identifiers_list(), $this->container->conf->get('general.timezone') @@ -51,7 +51,10 @@ public function index(Request $request, Response $response): Response $this->assignView('languages', Languages::getAvailableLanguages()); $this->assignView('gd_enabled', extension_loaded('gd')); $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); - $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + $this->assignView( + 'pagetitle', + t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') + ); return $response->write($this->render(TemplatePage::CONFIGURE)); } @@ -95,12 +98,15 @@ public function save(Request $request, Response $response): Response } $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; - if ($thumbnailsMode !== Thumbnailer::MODE_NONE + if ( + $thumbnailsMode !== Thumbnailer::MODE_NONE && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) ) { $this->saveWarningMessage( t('You have enabled or changed thumbnails mode.') . - '' . t('Please synchronize them.') .'' + '' . + t('Please synchronize them.') . + '' ); } $this->container->conf->set('thumbnails.mode', $thumbnailsMode); diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php index 2be957fae..f01d7e9be 100644 --- a/application/front/controller/admin/ExportController.php +++ b/application/front/controller/admin/ExportController.php @@ -23,7 +23,7 @@ class ExportController extends ShaarliAdminController */ public function index(Request $request, Response $response): Response { - $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + $this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')); return $response->write($this->render(TemplatePage::EXPORT)); } @@ -68,7 +68,7 @@ public function export(Request $request, Response $response): Response $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); $response = $response->withHeader( 'Content-disposition', - 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' + 'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html' ); $this->assignView('date', $now->format(DateTime::RFC822)); diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php index 758d5ef94..c2ad6a09f 100644 --- a/application/front/controller/admin/ImportController.php +++ b/application/front/controller/admin/ImportController.php @@ -38,7 +38,7 @@ public function index(Request $request, Response $response): Response true ) ); - $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + $this->assignView('pagetitle', t('Import') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')); return $response->write($this->render(TemplatePage::IMPORT)); } @@ -64,7 +64,7 @@ public function import(Request $request, Response $response): Response $msg = sprintf( t( 'The file you are trying to upload is probably bigger than what this webserver can accept' - .' (%s). Please upload in smaller chunks.' + . ' (%s). Please upload in smaller chunks.' ), get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) ); diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php deleted file mode 100644 index bb0834865..000000000 --- a/application/front/controller/admin/ManageShaareController.php +++ /dev/null @@ -1,371 +0,0 @@ -assignView( - 'pagetitle', - t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli') - ); - - return $response->write($this->render(TemplatePage::ADDLINK)); - } - - /** - * GET /admin/shaare - Displays the bookmark form for creation. - * Note that if the URL is found in existing bookmarks, then it will be in edit mode. - */ - public function displayCreateForm(Request $request, Response $response): Response - { - $url = cleanup_url($request->getParam('post')); - - $linkIsNew = false; - // Check if URL is not already in database (in this case, we will edit the existing link) - $bookmark = $this->container->bookmarkService->findByUrl($url); - if (null === $bookmark) { - $linkIsNew = true; - // Get shaare data if it was provided in URL (e.g.: by the bookmarklet). - $title = $request->getParam('title'); - $description = $request->getParam('description'); - $tags = $request->getParam('tags'); - $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN); - - // 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 (empty($url) && empty($title)) { - $title = $this->container->conf->get('general.default_note_title', t('Note: ')); - } - - $link = [ - 'title' => $title, - 'url' => $url ?? '', - 'description' => $description ?? '', - 'tags' => $tags ?? '', - 'private' => $private, - ]; - } else { - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $link = $formatter->format($bookmark); - } - - return $this->displayForm($link, $linkIsNew, $request, $response); - } - - /** - * GET /admin/shaare/{id} - Displays the bookmark form in edition mode. - */ - public function displayEditForm(Request $request, Response $response, array $args): Response - { - $id = $args['id'] ?? ''; - try { - if (false === ctype_digit($id)) { - throw new BookmarkNotFoundException(); - } - $bookmark = $this->container->bookmarkService->get((int) $id); // Read database - } catch (BookmarkNotFoundException $e) { - $this->saveErrorMessage(sprintf( - t('Bookmark with identifier %s could not be found.'), - $id - )); - - return $this->redirect($response, '/'); - } - - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $link = $formatter->format($bookmark); - - return $this->displayForm($link, false, $request, $response); - } - - /** - * POST /admin/shaare - */ - public function save(Request $request, Response $response): Response - { - $this->checkToken($request); - - // lf_id should only be present if the link exists. - $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null; - if (null !== $id && true === $this->container->bookmarkService->exists($id)) { - // Edit - $bookmark = $this->container->bookmarkService->get($id); - } else { - // New link - $bookmark = new Bookmark(); - } - - $bookmark->setTitle($request->getParam('lf_title')); - $bookmark->setDescription($request->getParam('lf_description')); - $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); - $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); - $bookmark->setTagsString($request->getParam('lf_tags')); - - if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE - && false === $bookmark->isNote() - ) { - $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); - } - $this->container->bookmarkService->addOrSet($bookmark, false); - - // To preserve backward compatibility with 3rd parties, plugins still use arrays - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $data = $formatter->format($bookmark); - $this->executePageHooks('save_link', $data); - - $bookmark->fromArray($data); - $this->container->bookmarkService->set($bookmark); - - // If we are called from the bookmarklet, we must close the popup: - if ($request->getParam('source') === 'bookmarklet') { - return $response->write(''); - } - - if (!empty($request->getParam('returnurl'))) { - $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl')); - } - - return $this->redirectFromReferer( - $request, - $response, - ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'], - $bookmark->getShortUrl() - ); - } - - /** - * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter). - */ - public function deleteBookmark(Request $request, Response $response): Response - { - $this->checkToken($request); - - $ids = escape(trim($request->getParam('id') ?? '')); - if (empty($ids) || strpos($ids, ' ') !== false) { - // multiple, space-separated ids provided - $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); - } else { - $ids = [$ids]; - } - - // assert at least one id is given - if (0 === count($ids)) { - $this->saveErrorMessage(t('Invalid bookmark ID provided.')); - - return $this->redirectFromReferer($request, $response, [], ['delete-shaare']); - } - - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $count = 0; - foreach ($ids as $id) { - try { - $bookmark = $this->container->bookmarkService->get((int) $id); - } catch (BookmarkNotFoundException $e) { - $this->saveErrorMessage(sprintf( - t('Bookmark with identifier %s could not be found.'), - $id - )); - - continue; - } - - $data = $formatter->format($bookmark); - $this->executePageHooks('delete_link', $data); - $this->container->bookmarkService->remove($bookmark, false); - ++ $count; - } - - if ($count > 0) { - $this->container->bookmarkService->save(); - } - - // If we are called from the bookmarklet, we must close the popup: - if ($request->getParam('source') === 'bookmarklet') { - return $response->write(''); - } - - // Don't redirect to where we were previously because the datastore has changed. - return $this->redirect($response, '/'); - } - - /** - * GET /admin/shaare/visibility - * - * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter). - */ - public function changeVisibility(Request $request, Response $response): Response - { - $this->checkToken($request); - - $ids = trim(escape($request->getParam('id') ?? '')); - if (empty($ids) || strpos($ids, ' ') !== false) { - // multiple, space-separated ids provided - $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); - } else { - // only a single id provided - $ids = [$ids]; - } - - // assert at least one id is given - if (0 === count($ids)) { - $this->saveErrorMessage(t('Invalid bookmark ID provided.')); - - return $this->redirectFromReferer($request, $response, [], ['change_visibility']); - } - - // assert that the visibility is valid - $visibility = $request->getParam('newVisibility'); - if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) { - $this->saveErrorMessage(t('Invalid visibility provided.')); - - return $this->redirectFromReferer($request, $response, [], ['change_visibility']); - } else { - $isPrivate = $visibility === 'private'; - } - - $formatter = $this->container->formatterFactory->getFormatter('raw'); - $count = 0; - - foreach ($ids as $id) { - try { - $bookmark = $this->container->bookmarkService->get((int) $id); - } catch (BookmarkNotFoundException $e) { - $this->saveErrorMessage(sprintf( - t('Bookmark with identifier %s could not be found.'), - $id - )); - - continue; - } - - $bookmark->setPrivate($isPrivate); - - // To preserve backward compatibility with 3rd parties, plugins still use arrays - $data = $formatter->format($bookmark); - $this->executePageHooks('save_link', $data); - $bookmark->fromArray($data); - - $this->container->bookmarkService->set($bookmark, false); - ++$count; - } - - if ($count > 0) { - $this->container->bookmarkService->save(); - } - - return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']); - } - - /** - * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark. - */ - public function pinBookmark(Request $request, Response $response, array $args): Response - { - $this->checkToken($request); - - $id = $args['id'] ?? ''; - try { - if (false === ctype_digit($id)) { - throw new BookmarkNotFoundException(); - } - $bookmark = $this->container->bookmarkService->get((int) $id); // Read database - } catch (BookmarkNotFoundException $e) { - $this->saveErrorMessage(sprintf( - t('Bookmark with identifier %s could not be found.'), - $id - )); - - return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); - } - - $formatter = $this->container->formatterFactory->getFormatter('raw'); - - $bookmark->setSticky(!$bookmark->isSticky()); - - // To preserve backward compatibility with 3rd parties, plugins still use arrays - $data = $formatter->format($bookmark); - $this->executePageHooks('save_link', $data); - $bookmark->fromArray($data); - - $this->container->bookmarkService->set($bookmark); - - return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); - } - - /** - * Helper function used to display the shaare form whether it's a new or existing bookmark. - * - * @param array $link data used in template, either from parameters or from the data store - */ - protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response - { - $tags = $this->container->bookmarkService->bookmarksCountPerTag(); - if ($this->container->conf->get('formatter') === 'markdown') { - $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; - } - - $data = escape([ - 'link' => $link, - 'link_is_new' => $isNew, - 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '', - 'source' => $request->getParam('source') ?? '', - 'tags' => $tags, - 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false), - ]); - - $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); - - foreach ($data as $key => $value) { - $this->assignView($key, $value); - } - - $editLabel = false === $isNew ? t('Edit') .' ' : ''; - $this->assignView( - 'pagetitle', - $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli') - ); - - return $response->write($this->render(TemplatePage::EDIT_LINK)); - } -} diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php index 2065c3e27..8675a0c58 100644 --- a/application/front/controller/admin/ManageTagController.php +++ b/application/front/controller/admin/ManageTagController.php @@ -24,9 +24,15 @@ public function index(Request $request, Response $response): Response $fromTag = $request->getParam('fromtag') ?? ''; $this->assignView('fromtag', escape($fromTag)); + $separator = escape($this->container->conf->get('general.tags_separator', ' ')); + if ($separator === ' ') { + $separator = ' '; + $this->assignView('tags_separator_desc', t('whitespace')); + } + $this->assignView('tags_separator', $separator); $this->assignView( 'pagetitle', - t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') + t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') ); return $response->write($this->render(TemplatePage::CHANGE_TAG)); @@ -81,8 +87,35 @@ public function save(Request $request, Response $response): Response $this->saveSuccessMessage($alert); - $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); + $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag); return $this->redirect($response, $redirect); } + + /** + * POST /admin/tags/change-separator - Change tag separator + */ + public function changeSeparator(Request $request, Response $response): Response + { + $this->checkToken($request); + + $reservedCharacters = ['-', '.', '*']; + $newSeparator = $request->getParam('separator'); + if ($newSeparator === null || mb_strlen($newSeparator) !== 1) { + $this->saveErrorMessage(t('Tags separator must be a single character.')); + } elseif (in_array($newSeparator, $reservedCharacters, true)) { + $reservedCharacters = implode(' ', array_map(function (string $character) { + return '' . $character . ''; + }, $reservedCharacters)); + $this->saveErrorMessage( + t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters + ); + } else { + $this->container->conf->set('general.tags_separator', $newSeparator, true, true); + + $this->saveSuccessMessage('Your tags separator setting has been updated!'); + } + + return $this->redirect($response, '/admin/tags'); + } } diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php new file mode 100644 index 000000000..ff8459449 --- /dev/null +++ b/application/front/controller/admin/MetadataController.php @@ -0,0 +1,29 @@ +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/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php index 5ec0d24b2..4aaf1f82c 100644 --- a/application/front/controller/admin/PasswordController.php +++ b/application/front/controller/admin/PasswordController.php @@ -25,7 +25,7 @@ public function __construct(ShaarliContainer $container) $this->assignView( 'pagetitle', - t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') + t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') ); } @@ -78,7 +78,7 @@ public function change(Request $request, Response $response): Response // Save new password // Salt renders rainbow-tables attacks useless. - $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); + $this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand())); $this->container->conf->set( 'credentials.hash', sha1( diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php index 8e0596819..ae47c1af1 100644 --- a/application/front/controller/admin/PluginsController.php +++ b/application/front/controller/admin/PluginsController.php @@ -42,7 +42,7 @@ function ($a, $b) { $this->assignView('disabledPlugins', $disabledPlugins); $this->assignView( 'pagetitle', - t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') + t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') ); return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); @@ -64,7 +64,7 @@ public function save(Request $request, Response $response): Response unset($parameters['parameters_form']); unset($parameters['token']); foreach ($parameters as $param => $value) { - $this->container->conf->set('plugins.'. $param, escape($value)); + $this->container->conf->set('plugins.' . $param, escape($value)); } } else { $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php new file mode 100644 index 000000000..fabeaf2f2 --- /dev/null +++ b/application/front/controller/admin/ServerController.php @@ -0,0 +1,96 @@ +container->conf->get('updates.check_updates', true)) { + $latestVersion = 'v' . ApplicationUtils::getVersion( + ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE + ); + $releaseUrl .= 'tag/' . $latestVersion; + } else { + $latestVersion = t('Check disabled'); + } + + $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php'); + $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion; + $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION)); + + $this->assignView('php_version', PHP_VERSION); + $this->assignView('php_eol', format_date($phpEol, false)); + $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); + $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); + $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); + $this->assignView('release_url', $releaseUrl); + $this->assignView('latest_version', $latestVersion); + $this->assignView('current_version', $currentVersion); + $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode')); + $this->assignView('index_url', index_url($this->container->environment)); + $this->assignView('client_ip', client_ip_id($this->container->environment)); + $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', [])); + + $this->assignView( + 'pagetitle', + t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render('server')); + } + + /** + * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails). + */ + public function clearCache(Request $request, Response $response): Response + { + $exclude = ['.htaccess']; + + if ($request->getQueryParam('type') === static::CACHE_THUMB) { + $folders = [$this->container->conf->get('resource.thumbnails_cache')]; + + $this->saveWarningMessage( + t('Thumbnails cache has been cleared.') . ' ' . + '' . + t('Please synchronize them.') . + '' + ); + } else { + $folders = [ + $this->container->conf->get('resource.page_cache'), + $this->container->conf->get('resource.raintpl_tmp'), + ]; + + $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!')); + } + + // Make sure that we don't delete root cache folder + $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders)))); + foreach ($folders as $folder) { + FileUtils::clearFolder($folder, false, $exclude); + } + + return $this->redirect($response, '/admin/server'); + } +} diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php index d9a7a2e09..0917b6d20 100644 --- a/application/front/controller/admin/SessionFilterController.php +++ b/application/front/controller/admin/SessionFilterController.php @@ -45,6 +45,4 @@ public function visibility(Request $request, Response $response, array $args): R return $this->redirectFromReferer($request, $response, ['visibility']); } - - } diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php new file mode 100644 index 000000000..ab8e7f408 --- /dev/null +++ b/application/front/controller/admin/ShaareAddController.php @@ -0,0 +1,34 @@ +container->bookmarkService->bookmarksCountPerTag(); + if ($this->container->conf->get('formatter') === 'markdown') { + $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; + } + + $this->assignView( + 'pagetitle', + t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') + ); + $this->assignView('tags', $tags); + $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false)); + $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true)); + + return $response->write($this->render(TemplatePage::ADDLINK)); + } +} diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php new file mode 100644 index 000000000..35837baac --- /dev/null +++ b/application/front/controller/admin/ShaareManageController.php @@ -0,0 +1,202 @@ +checkToken($request); + + $ids = escape(trim($request->getParam('id') ?? '')); + if (empty($ids) || strpos($ids, ' ') !== false) { + // multiple, space-separated ids provided + $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); + } else { + $ids = [$ids]; + } + + // assert at least one id is given + if (0 === count($ids)) { + $this->saveErrorMessage(t('Invalid bookmark ID provided.')); + + return $this->redirectFromReferer($request, $response, [], ['delete-shaare']); + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $count = 0; + foreach ($ids as $id) { + try { + $bookmark = $this->container->bookmarkService->get((int) $id); + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + continue; + } + + $data = $formatter->format($bookmark); + $this->executePageHooks('delete_link', $data); + $this->container->bookmarkService->remove($bookmark, false); + ++$count; + } + + if ($count > 0) { + $this->container->bookmarkService->save(); + } + + // If we are called from the bookmarklet, we must close the popup: + if ($request->getParam('source') === 'bookmarklet') { + return $response->write(''); + } + + // Don't redirect to permalink after deletion. + return $this->redirectFromReferer($request, $response, ['shaare/']); + } + + /** + * GET /admin/shaare/visibility + * + * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter). + */ + public function changeVisibility(Request $request, Response $response): Response + { + $this->checkToken($request); + + $ids = trim(escape($request->getParam('id') ?? '')); + if (empty($ids) || strpos($ids, ' ') !== false) { + // multiple, space-separated ids provided + $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit')); + } else { + // only a single id provided + $ids = [$ids]; + } + + // assert at least one id is given + if (0 === count($ids)) { + $this->saveErrorMessage(t('Invalid bookmark ID provided.')); + + return $this->redirectFromReferer($request, $response, [], ['change_visibility']); + } + + // assert that the visibility is valid + $visibility = $request->getParam('newVisibility'); + if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) { + $this->saveErrorMessage(t('Invalid visibility provided.')); + + return $this->redirectFromReferer($request, $response, [], ['change_visibility']); + } else { + $isPrivate = $visibility === 'private'; + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + $count = 0; + + foreach ($ids as $id) { + try { + $bookmark = $this->container->bookmarkService->get((int) $id); + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + continue; + } + + $bookmark->setPrivate($isPrivate); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $data = $formatter->format($bookmark); + $this->executePageHooks('save_link', $data); + $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' ')); + + $this->container->bookmarkService->set($bookmark, false); + ++$count; + } + + if ($count > 0) { + $this->container->bookmarkService->save(); + } + + return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']); + } + + /** + * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark. + */ + public function pinBookmark(Request $request, Response $response, array $args): Response + { + $this->checkToken($request); + + $id = $args['id'] ?? ''; + try { + if (false === ctype_digit($id)) { + throw new BookmarkNotFoundException(); + } + $bookmark = $this->container->bookmarkService->get((int) $id); // Read database + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); + } + + $formatter = $this->container->formatterFactory->getFormatter('raw'); + + $bookmark->setSticky(!$bookmark->isSticky()); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $data = $formatter->format($bookmark); + $this->executePageHooks('save_link', $data); + $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' ')); + + $this->container->bookmarkService->set($bookmark); + + return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']); + } + + /** + * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL. + */ + public function sharePrivate(Request $request, Response $response, array $args): Response + { + $this->checkToken($request); + + $hash = $args['hash'] ?? ''; + $bookmark = $this->container->bookmarkService->findByHash($hash); + + if ($bookmark->isPrivate() !== true) { + return $this->redirect($response, '/shaare/' . $hash); + } + + if (empty($bookmark->getAdditionalContentEntry('private_key'))) { + $privateKey = bin2hex(random_bytes(16)); + $bookmark->addAdditionalContentEntry('private_key', $privateKey); + $this->container->bookmarkService->set($bookmark); + } + + return $this->redirect( + $response, + '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key') + ); + } +} diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php new file mode 100644 index 000000000..4cbfcdc50 --- /dev/null +++ b/application/front/controller/admin/ShaarePublishController.php @@ -0,0 +1,274 @@ +getParam('post')); + $link = $this->buildLinkDataFromUrl($request, $url); + + return $this->displayForm($link, $link['linkIsNew'], $request, $response); + } + + /** + * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page. + */ + public function displayCreateBatchForms(Request $request, Response $response): Response + { + $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls'))); + + $links = []; + foreach ($urls as $url) { + if (empty($url)) { + continue; + } + $link = $this->buildLinkDataFromUrl($request, $url); + $data = $this->buildFormData($link, $link['linkIsNew'], $request); + $data['token'] = $this->container->sessionManager->generateToken(); + $data['source'] = 'batch'; + + $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); + + $links[] = $data; + } + + $this->assignView('links', $links); + $this->assignView('batch_mode', true); + $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true)); + + return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH)); + } + + /** + * GET /admin/shaare/{id} - Displays the bookmark form in edition mode. + */ + public function displayEditForm(Request $request, Response $response, array $args): Response + { + $id = $args['id'] ?? ''; + try { + if (false === ctype_digit($id)) { + throw new BookmarkNotFoundException(); + } + $bookmark = $this->container->bookmarkService->get((int) $id); // Read database + } catch (BookmarkNotFoundException $e) { + $this->saveErrorMessage(sprintf( + t('Bookmark with identifier %s could not be found.'), + $id + )); + + return $this->redirect($response, '/'); + } + + $formatter = $this->getFormatter('raw'); + $link = $formatter->format($bookmark); + + return $this->displayForm($link, false, $request, $response); + } + + /** + * POST /admin/shaare + */ + public function save(Request $request, Response $response): Response + { + $this->checkToken($request); + + // lf_id should only be present if the link exists. + $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null; + if (null !== $id && true === $this->container->bookmarkService->exists($id)) { + // Edit + $bookmark = $this->container->bookmarkService->get($id); + } else { + // New link + $bookmark = new Bookmark(); + } + + $bookmark->setTitle($request->getParam('lf_title')); + $bookmark->setDescription($request->getParam('lf_description')); + $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); + $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); + $bookmark->setTagsString( + $request->getParam('lf_tags'), + $this->container->conf->get('general.tags_separator', ' ') + ); + + if ( + $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE + && true !== $this->container->conf->get('general.enable_async_metadata', true) + && $bookmark->shouldUpdateThumbnail() + ) { + $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); + } + $this->container->bookmarkService->addOrSet($bookmark, false); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $formatter = $this->getFormatter('raw'); + $data = $formatter->format($bookmark); + $this->executePageHooks('save_link', $data); + + $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' ')); + $this->container->bookmarkService->set($bookmark); + + // If we are called from the bookmarklet, we must close the popup: + if ($request->getParam('source') === 'bookmarklet') { + return $response->write(''); + } elseif ($request->getParam('source') === 'batch') { + return $response; + } + + if (!empty($request->getParam('returnurl'))) { + $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl'); + } + + return $this->redirectFromReferer( + $request, + $response, + ['/admin/add-shaare', '/admin/shaare'], + ['addlink', 'post', 'edit_link'], + $bookmark->getShortUrl() + ); + } + + /** + * Helper function used to display the shaare form whether it's a new or existing bookmark. + * + * @param array $link data used in template, either from parameters or from the data store + */ + protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response + { + $data = $this->buildFormData($link, $isNew, $request); + + $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK); + + foreach ($data as $key => $value) { + $this->assignView($key, $value); + } + + $editLabel = false === $isNew ? t('Edit') . ' ' : ''; + $this->assignView( + 'pagetitle', + $editLabel . t('Shaare') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') + ); + + return $response->write($this->render(TemplatePage::EDIT_LINK)); + } + + protected function buildLinkDataFromUrl(Request $request, string $url): array + { + // Check if URL is not already in database (in this case, we will edit the existing link) + $bookmark = $this->container->bookmarkService->findByUrl($url); + if (null === $bookmark) { + // Get shaare data if it was provided in URL (e.g.: by the bookmarklet). + $title = $request->getParam('title'); + $description = $request->getParam('description'); + $tags = $request->getParam('tags'); + if ($request->getParam('private') !== null) { + $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN); + } else { + $private = $this->container->conf->get('privacy.default_private_links', false); + } + + // 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 ( + 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)) { + $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: ')); + } + + return [ + 'title' => $title ?? $metadata['title'] ?? '', + 'url' => $url ?? '', + 'description' => $description ?? $metadata['description'] ?? '', + 'tags' => $tags ?? $metadata['tags'] ?? '', + 'private' => $private, + 'linkIsNew' => true, + ]; + } + + $formatter = $this->getFormatter('raw'); + $link = $formatter->format($bookmark); + $link['linkIsNew'] = false; + + return $link; + } + + protected function buildFormData(array $link, bool $isNew, Request $request): array + { + $link['tags'] = strlen($link['tags']) > 0 + ? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ') + : $link['tags'] + ; + + return escape([ + 'link' => $link, + 'link_is_new' => $isNew, + 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '', + 'source' => $request->getParam('source') ?? '', + 'tags' => $this->getTags(), + '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), + ]); + } + + /** + * Memoize formatterFactory->getFormatter() calls. + */ + protected function getFormatter(string $type): BookmarkFormatter + { + if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) { + $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type); + } + + return $this->formatters[$type]; + } + + /** + * Memoize bookmarkService->bookmarksCountPerTag() calls. + */ + protected function getTags(): array + { + if ($this->tags === null) { + $this->tags = $this->container->bookmarkService->bookmarksCountPerTag(); + + if ($this->container->conf->get('formatter') === 'markdown') { + $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1; + } + } + + return $this->tags; + } +} diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php index 81c87ed03..94d97d4bd 100644 --- a/application/front/controller/admin/ThumbnailsController.php +++ b/application/front/controller/admin/ThumbnailsController.php @@ -34,7 +34,7 @@ public function index(Request $request, Response $response): Response $this->assignView('ids', $ids); $this->assignView( 'pagetitle', - t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') + t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') ); return $response->write($this->render(TemplatePage::THUMBNAILS)); @@ -52,7 +52,7 @@ public function ajaxUpdate(Request $request, Response $response, array $args): R } try { - $bookmark = $this->container->bookmarkService->get($id); + $bookmark = $this->container->bookmarkService->get((int) $id); } catch (BookmarkNotFoundException $e) { return $response->withStatus(404); } diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php index a87f20d29..560e5e3e7 100644 --- a/application/front/controller/admin/ToolsController.php +++ b/application/front/controller/admin/ToolsController.php @@ -28,7 +28,7 @@ public function index(Request $request, Response $response): Response $this->assignView($key, $value); } - $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); + $this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')); return $response->write($this->render(TemplatePage::TOOLS)); } diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php index 18368751b..fe8231be1 100644 --- a/application/front/controller/visitor/BookmarkListController.php +++ b/application/front/controller/visitor/BookmarkListController.php @@ -35,7 +35,8 @@ public function index(Request $request, Response $response): Response $formatter->addContextData('base_path', $this->container->basePath); $searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); - $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; + $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? '')); + ; // Filter bookmarks according search parameters. $visibility = $this->container->sessionManager->getSessionParameter('visibility'); @@ -95,6 +96,10 @@ public function index(Request $request, Response $response): Response $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; } + $tagsSeparator = $this->container->conf->get('general.tags_separator', ' '); + $searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator)); + $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : ''; + // Fill all template fields. $data = array_merge( $this->initializeTemplateVars(), @@ -106,7 +111,7 @@ public function index(Request $request, Response $response): Response 'result_count' => count($linksToDisplay), 'search_term' => escape($searchTerm), 'search_tags' => escape($searchTags), - 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), + 'search_tags_url' => $searchTagsUrlEncoded, 'visibility' => $visibility, 'links' => $linkDisp, ] @@ -119,8 +124,9 @@ public function index(Request $request, Response $response): Response return '[' . $tag . ']'; }; $data['pagetitle'] .= ! empty($searchTags) - ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' - : ''; + ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' ' + : '' + ; $data['pagetitle'] .= '- '; } @@ -137,8 +143,10 @@ public function index(Request $request, Response $response): Response */ public function permalink(Request $request, Response $response, array $args): Response { + $privateKey = $request->getParam('key'); + try { - $bookmark = $this->container->bookmarkService->findByHash($args['hash']); + $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey); } catch (BookmarkNotFoundException $e) { $this->assignView('error_message', $e->getMessage()); @@ -153,7 +161,7 @@ public function permalink(Request $request, Response $response, array $args): Re $data = array_merge( $this->initializeTemplateVars(), [ - 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), + 'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'), 'links' => [$formatter->format($bookmark)], ] ); @@ -169,19 +177,25 @@ public function permalink(Request $request, Response $response, array $args): Re */ protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool { - // Logged in, thumbnails enabled, not a note, is HTTP - // and (never retrieved yet or no valid cache file) - if ($this->container->loginManager->isLoggedIn() - && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE - && false !== $bookmark->getThumbnail() - && !$bookmark->isNote() - && (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail())) - && startsWith(strtolower($bookmark->getUrl()), 'http') - ) { - $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); - $this->container->bookmarkService->set($bookmark, $writeDatastore); - - return true; + if (false === $this->container->loginManager->isLoggedIn()) { + return false; + } + + // If thumbnail should be updated, we reset it to null + if ($bookmark->shouldUpdateThumbnail()) { + $bookmark->setThumbnail(null); + + // Requires an update, not async retrieval, thumbnails enabled + if ( + $bookmark->shouldUpdateThumbnail() + && true !== $this->container->conf->get('general.enable_async_metadata', true) + && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE + ) { + $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); + $this->container->bookmarkService->set($bookmark, $writeDatastore); + + return true; + } } return false; @@ -198,6 +212,7 @@ protected function initializeTemplateVars(): array 'page_max' => '', 'search_tags' => '', 'result_count' => '', + 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true) ]; } diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php index 07617cf11..846cfe22a 100644 --- a/application/front/controller/visitor/DailyController.php +++ b/application/front/controller/visitor/DailyController.php @@ -5,8 +5,8 @@ namespace Shaarli\Front\Controller\Visitor; use DateTime; -use DateTimeImmutable; use Shaarli\Bookmark\Bookmark; +use Shaarli\Helper\DailyPageHelper; use Shaarli\Render\TemplatePage; use Slim\Http\Request; use Slim\Http\Response; @@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController */ public function index(Request $request, Response $response): Response { - $day = $request->getQueryParam('day') ?? date('Ymd'); - - $availableDates = $this->container->bookmarkService->days(); - $nbAvailableDates = count($availableDates); - $index = array_search($day, $availableDates); - - if ($index === false) { - // no bookmarks for day, but at least one day with bookmarks - $day = $availableDates[$nbAvailableDates - 1] ?? $day; - $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; - } else { - $previousDay = $availableDates[$index - 1] ?? ''; - $nextDay = $availableDates[$index + 1] ?? ''; - } - - if ($day === date('Ymd')) { - $this->assignView('dayDesc', t('Today')); - } elseif ($day === date('Ymd', strtotime('-1 days'))) { - $this->assignView('dayDesc', t('Yesterday')); - } - - try { - $linksToDisplay = $this->container->bookmarkService->filterDay($day); - } catch (\Exception $exc) { - $linksToDisplay = []; - } + $type = DailyPageHelper::extractRequestedType($request); + $format = DailyPageHelper::getFormatByType($type); + $latestBookmark = $this->container->bookmarkService->getLatest(); + $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark); + $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime); + $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime); + $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime); + + $linksToDisplay = $this->container->bookmarkService->findByDate( + $start, + $end, + $previousDay, + $nextDay + ); $formatter = $this->container->formatterFactory->getFormatter(); $formatter->addContextData('base_path', $this->container->basePath); @@ -63,13 +51,15 @@ public function index(Request $request, Response $response): Response $linksToDisplay[$key]['description'] = $bookmark->getDescription(); } - $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); $data = [ 'linksToDisplay' => $linksToDisplay, - 'day' => $dayDate->getTimestamp(), - 'dayDate' => $dayDate, - 'previousday' => $previousDay ?? '', - 'nextday' => $nextDay ?? '', + 'dayDate' => $start, + 'day' => $start->getTimestamp(), + 'previousday' => $previousDay ? $previousDay->format($format) : '', + 'nextday' => $nextDay ? $nextDay->format($format) : '', + 'dayDesc' => $dailyDesc, + 'type' => $type, + 'localizedType' => $this->translateType($type), ]; // Hooks are called before column construction so that plugins don't have to deal with columns. @@ -82,7 +72,7 @@ public function index(Request $request, Response $response): Response $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); $this->assignView( 'pagetitle', - t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle + $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle ); return $response->write($this->render(TemplatePage::DAILY)); @@ -106,11 +96,14 @@ public function rss(Request $request, Response $response): Response } $days = []; + $type = DailyPageHelper::extractRequestedType($request); + $format = DailyPageHelper::getFormatByType($type); + $length = DailyPageHelper::getRssLengthByType($type); foreach ($this->container->bookmarkService->search() as $bookmark) { - $day = $bookmark->getCreated()->format('Ymd'); + $day = $bookmark->getCreated()->format($format); // Stop iterating after DAILY_RSS_NB_DAYS entries - if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { + if (count($days) === $length && !isset($days[$day])) { break; } @@ -127,12 +120,19 @@ public function rss(Request $request, Response $response): Response /** @var Bookmark[] $bookmarks */ foreach ($days as $day => $bookmarks) { - $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); + $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day); + $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime); + + // We only want the RSS entry to be published when the period is over. + if (new DateTime() < $endDateTime) { + continue; + } + $dataPerDay[$day] = [ - 'date' => $dayDatetime, - 'date_rss' => $dayDatetime->format(DateTime::RSS), - 'date_human' => format_date($dayDatetime, false, true), - 'absolute_url' => $indexUrl . 'daily?day=' . $day, + 'date' => $endDateTime, + 'date_rss' => $endDateTime->format(DateTime::RSS), + 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime), + 'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day, 'links' => [], ]; @@ -141,16 +141,20 @@ public function rss(Request $request, Response $response): Response // Make permalink URL absolute if ($bookmark->isNote()) { - $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); + $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl(); } } } - $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); - $this->assignView('index_url', $indexUrl); - $this->assignView('page_url', $pageUrl); - $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); - $this->assignView('days', $dataPerDay); + $this->assignAllView([ + 'title' => $this->container->conf->get('general.title', 'Shaarli'), + 'index_url' => $indexUrl, + 'page_url' => $pageUrl, + 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false), + 'days' => $dataPerDay, + 'type' => $type, + 'localizedType' => $this->translateType($type), + ]); $rssContent = $this->render(TemplatePage::DAILY_RSS); @@ -189,4 +193,13 @@ protected function calculateColumns(array $links): array return $columns; } + + protected function translateType($type): string + { + return [ + t('day') => t('Daily'), + t('week') => t('Weekly'), + t('month') => t('Monthly'), + ][t($type)] ?? t('Daily'); + } } diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php index 10aa84c80..428e82542 100644 --- a/application/front/controller/visitor/ErrorController.php +++ b/application/front/controller/visitor/ErrorController.php @@ -26,12 +26,15 @@ public function __invoke(Request $request, Response $response, \Throwable $throw $response = $response->withStatus($throwable->getCode()); } else { // Internal error (any other Throwable) - if ($this->container->conf->get('dev.debug', false)) { - $this->assignView('message', $throwable->getMessage()); + if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) { + $this->assignView('message', t('Error: ') . $throwable->getMessage()); $this->assignView( - 'stacktrace', - nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString()) + 'text', + '' + . t('Please report it on Github.') + . '' ); + $this->assignView('stacktrace', exception2text($throwable)); } else { $this->assignView('message', t('An unexpected error occurred.')); } @@ -39,7 +42,6 @@ public function __invoke(Request $request, Response $response, \Throwable $throw $response = $response->withStatus(500); } - return $response->write($this->render('error')); } } diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php index 8d8b546aa..edc7ef43a 100644 --- a/application/front/controller/visitor/FeedController.php +++ b/application/front/controller/visitor/FeedController.php @@ -27,7 +27,7 @@ public function rss(Request $request, Response $response): Response protected function processRequest(string $feedType, Request $request, Response $response): Response { - $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); + $response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8'); $pageUrl = page_url($this->container->environment); $cache = $this->container->pageCacheManager->getCachePage($pageUrl); diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php index 7cb327779..bf9659294 100644 --- a/application/front/controller/visitor/InstallController.php +++ b/application/front/controller/visitor/InstallController.php @@ -4,10 +4,10 @@ namespace Shaarli\Front\Controller\Visitor; -use Shaarli\ApplicationUtils; use Shaarli\Container\ShaarliContainer; use Shaarli\Front\Exception\AlreadyInstalledException; use Shaarli\Front\Exception\ResourcePermissionException; +use Shaarli\Helper\ApplicationUtils; use Shaarli\Languages; use Shaarli\Security\SessionManager; use Slim\Http\Request; @@ -39,7 +39,8 @@ public function index(Request $request, Response $response): Response // Before installation, we'll make sure that permissions are set properly, and sessions are working. $this->checkPermissions(); - if (static::SESSION_TEST_VALUE + if ( + static::SESSION_TEST_VALUE !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) ) { $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); @@ -53,6 +54,16 @@ public function index(Request $request, Response $response): Response $this->assignView('cities', $cities); $this->assignView('languages', Languages::getAvailableLanguages()); + $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION)); + + $this->assignView('php_version', PHP_VERSION); + $this->assignView('php_eol', format_date($phpEol, false)); + $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable()); + $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement()); + $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf)); + + $this->assignView('pagetitle', t('Install Shaarli')); + return $response->write($this->render('install')); } @@ -65,17 +76,18 @@ public function sessionTest(Request $request, Response $response): Response // This part makes sure sessions works correctly. // (Because on some hosts, session.save_path may not be set correctly, // or we may not have write access to it.) - if (static::SESSION_TEST_VALUE + if ( + static::SESSION_TEST_VALUE !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) ) { // Step 2: Check if data in session is correct. $msg = t( - '
Sessions do not seem to work correctly on your server.
'. - 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. - 'and that you have write access to it.
'. - 'It currently points to %s.
'. - 'On some browsers, accessing your server via a hostname like \'localhost\' '. - 'or any custom hostname without a dot causes cookie storage to fail. '. + '
Sessions do not seem to work correctly on your server.
' . + 'Make sure the variable "session.save_path" is set correctly in your PHP config, ' . + 'and that you have write access to it.
' . + 'It currently points to %s.
' . + 'On some browsers, accessing your server via a hostname like \'localhost\' ' . + 'or any custom hostname without a dot causes cookie storage to fail. ' . 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.
' ); $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); @@ -94,7 +106,8 @@ public function sessionTest(Request $request, Response $response): Response public function save(Request $request, Response $response): Response { $timezone = 'UTC'; - if (!empty($request->getParam('continent')) + if ( + !empty($request->getParam('continent')) && !empty($request->getParam('city')) && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) ) { @@ -104,7 +117,7 @@ public function save(Request $request, Response $response): Response $login = $request->getParam('setlogin'); $this->container->conf->set('credentials.login', $login); - $salt = sha1(uniqid('', true) .'_'. mt_rand()); + $salt = sha1(uniqid('', true) . '_' . mt_rand()); $this->container->conf->set('credentials.salt', $salt); $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); @@ -113,7 +126,7 @@ public function save(Request $request, Response $response): Response } else { $this->container->conf->set( 'general.title', - 'Shared bookmarks on '.escape(index_url($this->container->environment)) + 'Shared bookmarks on ' . escape(index_url($this->container->environment)) ); } @@ -150,7 +163,7 @@ public function save(Request $request, Response $response): Response protected function checkPermissions(): bool { // Ensure Shaarli has proper access to its resources - $errors = ApplicationUtils::checkResourcePermissions($this->container->conf); + $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true); if (empty($errors)) { return true; } diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php index 121ba40be..4b881535c 100644 --- a/application/front/controller/visitor/LoginController.php +++ b/application/front/controller/visitor/LoginController.php @@ -43,7 +43,7 @@ public function index(Request $request, Response $response): Response $this ->assignView('returnurl', escape($returnUrl)) ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) - ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) + ->assignView('pagetitle', t('Login') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')) ; return $response->write($this->render(TemplatePage::LOGIN)); @@ -64,8 +64,8 @@ public function login(Request $request, Response $response): Response return $this->redirect($response, '/'); } - if (!$this->container->loginManager->checkCredentials( - $this->container->environment['REMOTE_ADDR'], + if ( + !$this->container->loginManager->checkCredentials( client_ip_id($this->container->environment), $request->getParam('login'), $request->getParam('password') @@ -102,7 +102,8 @@ public function login(Request $request, Response $response): Response */ protected function checkLoginState(): bool { - if ($this->container->loginManager->isLoggedIn() + if ( + $this->container->loginManager->isLoggedIn() || $this->container->conf->get('security.open_shaarli', false) ) { throw new CantLoginException(); diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php index 3c57f8dd6..23553ee63 100644 --- a/application/front/controller/visitor/PictureWallController.php +++ b/application/front/controller/visitor/PictureWallController.php @@ -26,7 +26,7 @@ public function index(Request $request, Response $response): Response $this->assignView( 'pagetitle', - t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') + t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli') ); // Optionally filter the results: diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php index 55c075a2a..ae946c592 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -106,6 +106,7 @@ protected function buildPluginParameters(?string $template): array 'target' => $template, 'loggedin' => $this->container->loginManager->isLoggedIn(), 'basePath' => $this->container->basePath, + 'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath), 'bookmarkService' => $this->container->bookmarkService ]; } @@ -143,7 +144,8 @@ protected function redirectFromReferer( if (null !== $referer) { $currentUrl = parse_url($referer); // If the referer is not related to Shaarli instance, redirect to default - if (isset($currentUrl['host']) + if ( + isset($currentUrl['host']) && strpos(index_url($this->container->environment), $currentUrl['host']) === false ) { return $response->withRedirect($defaultPath); @@ -172,7 +174,7 @@ protected function redirectFromReferer( } } - $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; + $queryString = count($params) > 0 ? '?' . http_build_query($params) : ''; $anchor = $anchor ? '#' . $anchor : ''; return $response->withRedirect($path . $queryString . $anchor); diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php index 76ed76900..46d62779d 100644 --- a/application/front/controller/visitor/TagCloudController.php +++ b/application/front/controller/visitor/TagCloudController.php @@ -47,13 +47,14 @@ public function list(Request $request, Response $response): Response */ protected function processRequest(string $type, Request $request, Response $response): Response { + $tagsSeparator = $this->container->conf->get('general.tags_separator', ' '); if ($this->container->loginManager->isLoggedIn() === true) { $visibility = $this->container->sessionManager->getSessionParameter('visibility'); } $sort = $request->getQueryParam('sort'); $searchTags = $request->getQueryParam('searchtags'); - $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; + $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : []; $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); @@ -71,8 +72,9 @@ protected function processRequest(string $type, Request $request, Response $resp $tagsUrl[escape($tag)] = urlencode((string) $tag); } - $searchTags = implode(' ', escape($filteringTags)); - $searchTagsUrl = urlencode(implode(' ', $filteringTags)); + $searchTags = tags_array2str($filteringTags, $tagsSeparator); + $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : ''; + $searchTagsUrl = urlencode($searchTags); $data = [ 'search_tags' => escape($searchTags), 'search_tags_url' => $searchTagsUrl, @@ -82,10 +84,10 @@ protected function processRequest(string $type, Request $request, Response $resp $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); $this->assignAllView($data); - $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; + $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) . ' - ' : ''; $this->assignView( 'pagetitle', - $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') + $searchTags . t('Tag ' . $type) . ' - ' . $this->container->conf->get('general.title', 'Shaarli') ); return $response->write($this->render('tag.' . $type)); diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php index de4e7ea28..3aa58542b 100644 --- a/application/front/controller/visitor/TagController.php +++ b/application/front/controller/visitor/TagController.php @@ -27,7 +27,7 @@ public function addTag(Request $request, Response $response, array $args): Respo // In case browser does not send HTTP_REFERER, we search a single tag if (null === $referer) { if (null !== $newTag) { - return $this->redirect($response, '/?searchtags='. urlencode($newTag)); + return $this->redirect($response, '/?searchtags=' . urlencode($newTag)); } return $this->redirect($response, '/'); @@ -37,7 +37,7 @@ public function addTag(Request $request, Response $response, array $args): Respo parse_str($currentUrl['query'] ?? '', $params); if (null === $newTag) { - return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params)); } // Prevent redirection loop @@ -45,9 +45,10 @@ public function addTag(Request $request, Response $response, array $args): Respo unset($params['addtag']); } + $tagsSeparator = $this->container->conf->get('general.tags_separator', ' '); // Check if this tag is already in the search query and ignore it if it is. // Each tag is always separated by a space - $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; + $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator); $addtag = true; foreach ($currentTags as $value) { @@ -62,12 +63,12 @@ public function addTag(Request $request, Response $response, array $args): Respo $currentTags[] = trim($newTag); } - $params['searchtags'] = trim(implode(' ', $currentTags)); + $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator); // We also remove page (keeping the same page has no sense, since the results are different) unset($params['page']); - return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params)); } /** @@ -89,7 +90,7 @@ public function removeTag(Request $request, Response $response, array $args): Re parse_str($currentUrl['query'] ?? '', $params); if (null === $tagToRemove) { - return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); + return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params)); } // Prevent redirection loop @@ -98,10 +99,11 @@ public function removeTag(Request $request, Response $response, array $args): Re } if (isset($params['searchtags'])) { - $tags = explode(' ', $params['searchtags']); + $tagsSeparator = $this->container->conf->get('general.tags_separator', ' '); + $tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator); // Remove value from array $tags. $tags = array_diff($tags, [$tagToRemove]); - $params['searchtags'] = implode(' ', $tags); + $params['searchtags'] = tags_array2str($tags, $tagsSeparator); if (empty($params['searchtags'])) { unset($params['searchtags']); diff --git a/application/ApplicationUtils.php b/application/helper/ApplicationUtils.php similarity index 64% rename from application/ApplicationUtils.php rename to application/helper/ApplicationUtils.php index 3aa218295..212dd8e2d 100644 --- a/application/ApplicationUtils.php +++ b/application/helper/ApplicationUtils.php @@ -1,5 +1,6 @@ '; @@ -63,8 +65,8 @@ public static function getVersion($remote, $timeout = 2) } return str_replace( - array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), - array('', '', ''), + [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL], + ['', '', ''], $data ); } @@ -125,7 +127,7 @@ public static function checkUpdate( // Late Static Binding allows overriding within tests // See http://php.net/manual/en/language.oop5.late-static-bindings.php $latestVersion = static::getVersion( - self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE + self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE ); if (!$latestVersion) { @@ -171,35 +173,47 @@ public static function checkPHPVersion($minVersion, $curVersion) /** * Checks Shaarli has the proper access permissions to its resources * - * @param ConfigManager $conf Configuration Manager instance. + * @param ConfigManager $conf Configuration Manager instance. + * @param bool $minimalMode In minimal mode we only check permissions to be able to display a template. + * Currently we only need to be able to read the theme and write in raintpl cache. * * @return array A list of the detected configuration issues */ - public static function checkResourcePermissions($conf) + public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array { - $errors = array(); + $errors = []; $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); // Check script and template directories are readable - foreach (array( - 'application', - 'inc', - 'plugins', - $rainTplDir, - $rainTplDir . '/' . $conf->get('resource.theme'), - ) as $path) { + foreach ( + [ + 'application', + 'inc', + 'plugins', + $rainTplDir, + $rainTplDir . '/' . $conf->get('resource.theme'), + ] as $path + ) { if (!is_readable(realpath($path))) { $errors[] = '"' . $path . '" ' . t('directory is not readable'); } } // Check cache and data directories are readable and writable - foreach (array( - $conf->get('resource.thumbnails_cache'), - $conf->get('resource.data_dir'), - $conf->get('resource.page_cache'), - $conf->get('resource.raintpl_tmp'), - ) as $path) { + if ($minimalMode) { + $folders = [ + $conf->get('resource.raintpl_tmp'), + ]; + } else { + $folders = [ + $conf->get('resource.thumbnails_cache'), + $conf->get('resource.data_dir'), + $conf->get('resource.page_cache'), + $conf->get('resource.raintpl_tmp'), + ]; + } + + foreach ($folders as $path) { if (!is_readable(realpath($path))) { $errors[] = '"' . $path . '" ' . t('directory is not readable'); } @@ -208,14 +222,20 @@ public static function checkResourcePermissions($conf) } } + if ($minimalMode) { + return $errors; + } + // Check configuration files are readable and writable - foreach (array( - $conf->getConfigFileExt(), - $conf->get('resource.datastore'), - $conf->get('resource.ban_file'), - $conf->get('resource.log'), - $conf->get('resource.update_check'), - ) as $path) { + foreach ( + [ + $conf->getConfigFileExt(), + $conf->get('resource.datastore'), + $conf->get('resource.ban_file'), + $conf->get('resource.log'), + $conf->get('resource.update_check'), + ] as $path + ) { if (!is_file(realpath($path))) { # the file may not exist yet continue; @@ -246,4 +266,54 @@ public static function getVersionHash($currentVersion, $salt) { return hash_hmac('sha256', $currentVersion, $salt); } + + /** + * Get a list of PHP extensions used by Shaarli. + * + * @return array[] List of extension with following keys: + * - name: extension name + * - required: whether the extension is required to use Shaarli + * - desc: short description of extension usage in Shaarli + * - loaded: whether the extension is properly loaded or not + */ + public static function getPhpExtensionsRequirement(): array + { + $extensions = [ + ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')], + ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')], + ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')], + ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')], + ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')], + ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')], + ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')], + ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')], + ]; + + foreach ($extensions as &$extension) { + $extension['loaded'] = extension_loaded($extension['name']); + } + + return $extensions; + } + + /** + * Return the EOL date of given PHP version. If the version is unknown, + * we return today + 2 years. + * + * @param string $fullVersion PHP version, e.g. 7.4.7 + * + * @return string Date format: YYYY-MM-DD + */ + public static function getPhpEol(string $fullVersion): string + { + preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches); + + return [ + '7.1' => '2019-12-01', + '7.2' => '2020-11-30', + '7.3' => '2021-12-06', + '7.4' => '2022-11-28', + '8.0' => '2023-12-01', + ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d'); + } } diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php new file mode 100644 index 000000000..5fabc9078 --- /dev/null +++ b/application/helper/DailyPageHelper.php @@ -0,0 +1,208 @@ +getQueryParam(static::MONTH) !== null) { + return static::MONTH; + } elseif ($request->getQueryParam(static::WEEK) !== null) { + return static::WEEK; + } + + return static::DAY; + } + + /** + * Extracts a DateTimeImmutable from provided HTTP request. + * If no parameter is provided, we rely on the creation date of the latest provided created bookmark. + * If the datastore is empty or no bookmark is provided, we use the current date. + * + * @param string $type month/week/day + * @param string|null $requestedDate Input string extracted from the request + * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date) + * + * @return \DateTimeImmutable from input or latest bookmark. + * + * @throws \Exception Type not supported. + */ + public static function extractRequestedDateTime( + string $type, + ?string $requestedDate, + Bookmark $latestBookmark = null + ): \DateTimeImmutable { + $format = static::getFormatByType($type); + if (empty($requestedDate)) { + return $latestBookmark instanceof Bookmark + ? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM)) + : new \DateTimeImmutable() + ; + } + + // W is not supported by createFromFormat... + if ($type === static::WEEK) { + return (new \DateTimeImmutable()) + ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2)) + ; + } + + return \DateTimeImmutable::createFromFormat($format, $requestedDate); + } + + /** + * Get the DateTime format used by provided type + * Examples: + * - day: 20201016 () + * - week: 202041 () + * - month: 202010 () + * + * @param string $type month/week/day + * + * @return string DateTime compatible format + * + * @see https://www.php.net/manual/en/datetime.format.php + * + * @throws \Exception Type not supported. + */ + public static function getFormatByType(string $type): string + { + switch ($type) { + case static::MONTH: + return 'Ym'; + case static::WEEK: + return 'YW'; + case static::DAY: + return 'Ymd'; + default: + throw new \Exception('Unsupported daily format type'); + } + } + + /** + * Get the first DateTime of the time period depending on given datetime and type. + * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax + * and we don't want to alter original datetime. + * + * @param string $type month/week/day + * @param \DateTimeImmutable $requested DateTime extracted from request input + * (should come from extractRequestedDateTime) + * + * @return \DateTimeInterface First DateTime of the time period + * + * @throws \Exception Type not supported. + */ + public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface + { + switch ($type) { + case static::MONTH: + return $requested->modify('first day of this month midnight'); + case static::WEEK: + return $requested->modify('Monday this week midnight'); + case static::DAY: + return $requested->modify('Today midnight'); + default: + throw new \Exception('Unsupported daily format type'); + } + } + + /** + * Get the last DateTime of the time period depending on given datetime and type. + * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax + * and we don't want to alter original datetime. + * + * @param string $type month/week/day + * @param \DateTimeImmutable $requested DateTime extracted from request input + * (should come from extractRequestedDateTime) + * + * @return \DateTimeInterface Last DateTime of the time period + * + * @throws \Exception Type not supported. + */ + public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface + { + switch ($type) { + case static::MONTH: + return $requested->modify('last day of this month 23:59:59'); + case static::WEEK: + return $requested->modify('Sunday this week 23:59:59'); + case static::DAY: + return $requested->modify('Today 23:59:59'); + default: + throw new \Exception('Unsupported daily format type'); + } + } + + /** + * Get localized description of the time period depending on given datetime and type. + * Example: for a month period, it returns `October, 2020`. + * + * @param string $type month/week/day + * @param \DateTimeImmutable $requested DateTime extracted from request input + * (should come from extractRequestedDateTime) + * + * @return string Localized time period description + * + * @throws \Exception Type not supported. + */ + public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string + { + switch ($type) { + case static::MONTH: + return $requested->format('F') . ', ' . $requested->format('Y'); + case static::WEEK: + $requested = $requested->modify('Monday this week'); + return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')'; + case static::DAY: + $out = ''; + if ($requested->format('Ymd') === date('Ymd')) { + $out = t('Today') . ' - '; + } elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) { + $out = t('Yesterday') . ' - '; + } + return $out . format_date($requested, false); + default: + throw new \Exception('Unsupported daily format type'); + } + } + + /** + * Get the number of items to display in the RSS feed depending on the given type. + * + * @param string $type month/week/day + * + * @return int number of elements + * + * @throws \Exception Type not supported. + */ + public static function getRssLengthByType(string $type): int + { + switch ($type) { + case static::MONTH: + return 12; // 1 year + case static::WEEK: + return 26; // ~6 months + case static::DAY: + return 30; // ~1 month + default: + throw new \Exception('Unsupported daily format type'); + } + } +} diff --git a/application/FileUtils.php b/application/helper/FileUtils.php similarity index 57% rename from application/FileUtils.php rename to application/helper/FileUtils.php index 30560bfc3..e8a2168cc 100644 --- a/application/FileUtils.php +++ b/application/helper/FileUtils.php @@ -1,6 +1,6 @@ isDot()) { + continue; + } + + if (in_array($file->getBasename(), $exclude, true)) { + $skipped = true; + continue; + } + + if ($file->isFile()) { + unlink($file->getPathname()); + } elseif ($file->isDir()) { + $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped; + } + } + + if ($selfDelete && !$skipped) { + rmdir($path); + } + + return $skipped; + } + + /** + * Checks that the given path is inside Shaarli directory. + */ + public static function isPathInShaarliFolder(string $path): bool + { + $rootDirectory = dirname(dirname(dirname(__FILE__))); + + return strpos(realpath($path), $rootDirectory) !== false; + } } diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php index 81d9e0762..e80e0c014 100644 --- a/application/http/HttpAccess.php +++ b/application/http/HttpAccess.php @@ -14,9 +14,14 @@ */ class HttpAccess { - public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) - { - return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction); + public function getHttpResponse( + $url, + $timeout = 30, + $maxBytes = 4194304, + $curlHeaderFunction = null, + $curlWriteFunction = null + ) { + return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction); } public function getCurlDownloadCallback( @@ -25,7 +30,7 @@ public function getCurlDownloadCallback( &$description, &$keywords, $retrieveDescription, - $curlGetInfo = 'curl_getinfo' + $tagsSeparator ) { return get_curl_download_callback( $charset, @@ -33,7 +38,12 @@ public function getCurlDownloadCallback( $description, $keywords, $retrieveDescription, - $curlGetInfo + $tagsSeparator ); } + + public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo') + { + return get_curl_header_callback($charset, $curlGetInfo); + } } diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php index 9f4140735..4bde1d5b8 100644 --- a/application/http/HttpUtils.php +++ b/application/http/HttpUtils.php @@ -6,12 +6,14 @@ * GET an HTTP URL to retrieve its content * Uses the cURL library or a fallback method * - * @param string $url URL to get (http://...) - * @param int $timeout network timeout (in seconds) - * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) - * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). - * Can be used to add download conditions on the - * headers (response code, content type, etc.). + * @param string $url URL to get (http://...) + * @param int $timeout network timeout (in seconds) + * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) + * @param callable|string $curlHeaderFunction Optional callback called during the download of headers + * (CURLOPT_HEADERFUNCTION) + * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). + * Can be used to add download conditions on the + * headers (response code, content type, etc.). * * @return array HTTP response headers, downloaded content * @@ -35,13 +37,18 @@ * @see http://stackoverflow.com/q/9183178 * @see http://stackoverflow.com/q/1462720 */ -function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) -{ +function get_http_response( + $url, + $timeout = 30, + $maxBytes = 4194304, + $curlHeaderFunction = null, + $curlWriteFunction = null +) { $urlObj = new Url($url); $cleanUrl = $urlObj->idnToAscii(); if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) { - return array(array(0 => 'Invalid HTTP UrlUtils'), false); + return [[0 => 'Invalid HTTP UrlUtils'], false]; } $userAgent = @@ -64,42 +71,39 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF $ch = curl_init($cleanUrl); if ($ch === false) { - return array(array(0 => 'curl_init() error'), false); + return [[0 => 'curl_init() error'], false]; } // General cURL settings curl_setopt($ch, CURLOPT_AUTOREFERER, true); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($ch, CURLOPT_HEADER, true); + // Default header download if the $curlHeaderFunction is not defined + curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction)); curl_setopt( $ch, CURLOPT_HTTPHEADER, - array('Accept-Language: ' . $acceptLanguage) + ['Accept-Language: ' . $acceptLanguage] ); curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); + // Max download size management + curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16); + curl_setopt($ch, CURLOPT_NOPROGRESS, false); + if (is_callable($curlHeaderFunction)) { + curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction); + } if (is_callable($curlWriteFunction)) { curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); } - - // Max download size management - curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16); - curl_setopt($ch, CURLOPT_NOPROGRESS, false); curl_setopt( $ch, CURLOPT_PROGRESSFUNCTION, - function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { - if (version_compare(phpversion(), '5.5', '<')) { - // PHP version lower than 5.5 - // Callback has 4 arguments - $downloaded = $arg1; - } else { - // Callback has 5 arguments - $downloaded = $arg2; - } + function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) { + $downloaded = $arg2; + // Non-zero return stops downloading return ($downloaded > $maxBytes) ? 1 : 0; } @@ -118,9 +122,9 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { * Removing this would require updating * GetHttpUrlTest::testGetInvalidRemoteUrl() */ - return array(false, false); + return [false, false]; } - return array(array(0 => 'curl_exec() error: ' . $errorStr), false); + return [[0 => 'curl_exec() error: ' . $errorStr], false]; } // Formatting output like the fallback method @@ -131,7 +135,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { $rawHeadersLastRedir = end($rawHeadersArrayRedirs); $content = substr($response, $headSize); - $headers = array(); + $headers = []; foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { if (empty($line) || ctype_space($line)) { continue; @@ -142,7 +146,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { $value = $splitLine[1]; if (array_key_exists($key, $headers)) { if (!is_array($headers[$key])) { - $headers[$key] = array(0 => $headers[$key]); + $headers[$key] = [0 => $headers[$key]]; } $headers[$key][] = $value; } else { @@ -153,7 +157,7 @@ function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { } } - return array($headers, $content); + return [$headers, $content]; } /** @@ -184,15 +188,15 @@ function get_http_response_fallback( $acceptLanguage, $maxRedr ) { - $options = array( - 'http' => array( + $options = [ + 'http' => [ 'method' => 'GET', 'timeout' => $timeout, 'user_agent' => $userAgent, 'header' => "Accept: */*\r\n" . 'Accept-Language: ' . $acceptLanguage - ) - ); + ] + ]; stream_context_set_default($options); list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); @@ -203,7 +207,7 @@ function get_http_response_fallback( } if (! $headers) { - return array($headers, false); + return [$headers, false]; } try { @@ -211,10 +215,10 @@ function get_http_response_fallback( $context = stream_context_create($options); $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes); } catch (Exception $exc) { - return array(array(0 => 'HTTP Error'), $exc->getMessage()); + return [[0 => 'HTTP Error'], $exc->getMessage()]; } - return array($headers, $content); + return [$headers, $content]; } /** @@ -233,10 +237,12 @@ function get_redirected_headers($url, $redirectionLimit = 3) } // Headers found, redirection found, and limit not reached. - if ($redirectionLimit-- > 0 + if ( + $redirectionLimit-- > 0 && !empty($headers) && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) - && !empty($headers['Location'])) { + && !empty($headers['Location']) + ) { $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; if ($redirection != $url) { $redirection = getAbsoluteUrl($url, $redirection); @@ -244,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3) } } - return array($headers, $url); + return [$headers, $url]; } /** @@ -266,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl) } $parts = parse_url($originalUrl); - $final = $parts['scheme'] .'://'. $parts['host']; + $final = $parts['scheme'] . '://' . $parts['host']; $final .= (!empty($parts['port'])) ? $parts['port'] : ''; $final .= '/'; if ($newUrl[0] != '/') { @@ -319,7 +325,8 @@ function server_url($server) $scheme = 'https'; } - if (($scheme == 'http' && $port != '80') + if ( + ($scheme == 'http' && $port != '80') || ($scheme == 'https' && $port != '443') ) { $port = ':' . $port; @@ -340,22 +347,26 @@ function server_url($server) $host = $server['SERVER_NAME']; } - return $scheme.'://'.$host.$port; + return $scheme . '://' . $host . $port; } // SSL detection - if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') - || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) { + if ( + (! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') + || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443') + ) { $scheme = 'https'; } // Do not append standard port values - if (($scheme == 'http' && $server['SERVER_PORT'] != '80') - || ($scheme == 'https' && $server['SERVER_PORT'] != '443')) { - $port = ':'.$server['SERVER_PORT']; + if ( + ($scheme == 'http' && $server['SERVER_PORT'] != '80') + || ($scheme == 'https' && $server['SERVER_PORT'] != '443') + ) { + $port = ':' . $server['SERVER_PORT']; } - return $scheme.'://'.$server['SERVER_NAME'].$port; + return $scheme . '://' . $server['SERVER_NAME'] . $port; } /** @@ -489,6 +500,46 @@ function is_https($server) return ! empty($server['HTTPS']); } +/** + * Get cURL callback function for CURLOPT_WRITEFUNCTION + * + * @param string $charset to extract from the downloaded page (reference) + * @param string $curlGetInfo Optionally overrides curl_getinfo function + * + * @return Closure + */ +function get_curl_header_callback( + &$charset, + $curlGetInfo = 'curl_getinfo' +) { + $isRedirected = false; + + return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) { + $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); + $chunkLength = strlen($data); + if (!empty($responseCode) && in_array($responseCode, [301, 302])) { + $isRedirected = true; + return $chunkLength; + } + if (!empty($responseCode) && $responseCode !== 200) { + return false; + } + // After a redirection, the content type will keep the previous request value + // until it finds the next content-type header. + if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) { + $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); + } + if (!empty($contentType) && strpos($contentType, 'text/html') === false) { + return false; + } + if (!empty($contentType) && empty($charset)) { + $charset = header_extract_charset($contentType); + } + + return $chunkLength; + }; +} + /** * Get cURL callback function for CURLOPT_WRITEFUNCTION * @@ -507,9 +558,8 @@ function get_curl_download_callback( &$description, &$keywords, $retrieveDescription, - $curlGetInfo = 'curl_getinfo' + $tagsSeparator ) { - $isRedirected = false; $currentChunk = 0; $foundChunk = null; @@ -524,37 +574,22 @@ function get_curl_download_callback( * * @return int|bool length of $data or false if we need to stop the download */ - return function (&$ch, $data) use ( + return function ( + $ch, + $data + ) use ( $retrieveDescription, - $curlGetInfo, + $tagsSeparator, &$charset, &$title, &$description, &$keywords, - &$isRedirected, &$currentChunk, &$foundChunk ) { + $chunkLength = strlen($data); $currentChunk++; - $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); - if (!empty($responseCode) && in_array($responseCode, [301, 302])) { - $isRedirected = true; - return strlen($data); - } - if (!empty($responseCode) && $responseCode !== 200) { - return false; - } - // After a redirection, the content type will keep the previous request value - // until it finds the next content-type header. - if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) { - $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE); - } - if (!empty($contentType) && strpos($contentType, 'text/html') === false) { - return false; - } - if (!empty($contentType) && empty($charset)) { - $charset = header_extract_charset($contentType); - } + if (empty($charset)) { $charset = html_extract_charset($data); } @@ -562,6 +597,10 @@ function get_curl_download_callback( $title = html_extract_title($data); $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; } + if (empty($title)) { + $title = html_extract_tag('title', $data); + $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; + } if ($retrieveDescription && empty($description)) { $description = html_extract_tag('description', $data); $foundChunk = ! empty($description) ? $currentChunk : $foundChunk; @@ -571,10 +610,10 @@ function get_curl_download_callback( if (! empty($keywords)) { $foundChunk = $currentChunk; // Keywords use the format tag1, tag2 multiple words, tag - // So we format them to match Shaarli's separator and glue multiple words with '-' - $keywords = implode(' ', array_map(function($keyword) { - return implode('-', preg_split('/\s+/', trim($keyword))); - }, explode(',', $keywords))); + // So we split the result with `,`, then if a tag contains the separator we replace it by `-`. + $keywords = tags_array2str(array_map(function (string $keyword) use ($tagsSeparator): string { + return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-'); + }, tags_str2array($keywords, ',')), $tagsSeparator); } } @@ -582,7 +621,8 @@ function get_curl_download_callback( // If we already found either the title, description or keywords, // it's highly unlikely that we'll found the other metas further than // in the same chunk of data or the next one. So we also stop the download after that. - if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null + if ( + (!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null && (! $retrieveDescription || $foundChunk < $currentChunk || (!empty($title) && !empty($description) && !empty($keywords)) @@ -591,6 +631,6 @@ function get_curl_download_callback( return false; } - return strlen($data); + return $chunkLength; }; } diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php new file mode 100644 index 000000000..2e1401eca --- /dev/null +++ b/application/http/MetadataRetriever.php @@ -0,0 +1,69 @@ +conf = $conf; + $this->httpAccess = $httpAccess; + } + + /** + * Retrieve metadata for given URL. + * + * @return array [ + * 'title' => , + * 'description' => , + * 'tags' => , + * ] + */ + public function retrieve(string $url): array + { + $charset = null; + $title = null; + $description = null; + $tags = null; + + // 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->getCurlHeaderCallback($charset), + $this->httpAccess->getCurlDownloadCallback( + $charset, + $title, + $description, + $tags, + $this->conf->get('general.retrieve_description'), + $this->conf->get('general.tags_separator', ' ') + ) + ); + + 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/application/http/Url.php b/application/http/Url.php index 90444a2f4..fe87088f2 100644 --- a/application/http/Url.php +++ b/application/http/Url.php @@ -17,7 +17,7 @@ */ class Url { - private static $annoyingQueryParams = array( + private static $annoyingQueryParams = [ // Facebook 'action_object_map=', 'action_ref_map=', @@ -37,15 +37,15 @@ class Url // Other 'campaign_' - ); + ]; - private static $annoyingFragments = array( + private static $annoyingFragments = [ // ATInternet 'xtor=RSS-', // Misc. 'tk.rss_all' - ); + ]; /* * URL parts represented as an array @@ -120,7 +120,7 @@ protected function cleanupQuery() foreach (self::$annoyingQueryParams as $annoying) { foreach ($queryParams as $param) { if (startsWith($param, $annoying)) { - $queryParams = array_diff($queryParams, array($param)); + $queryParams = array_diff($queryParams, [$param]); continue; } } diff --git a/application/http/UrlUtils.php b/application/http/UrlUtils.php index e8d1a283f..de5b7db16 100644 --- a/application/http/UrlUtils.php +++ b/application/http/UrlUtils.php @@ -1,4 +1,5 @@ container->loginManager->isLoggedIn()) { $parameters = $buildParameters($request->getQueryParams(), true); - return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); + return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route . $parameters); } $parameters = $buildParameters($request->getQueryParams(), false); diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php index 7bf76fd47..d3beafe0d 100644 --- a/application/legacy/LegacyLinkDB.php +++ b/application/legacy/LegacyLinkDB.php @@ -8,7 +8,7 @@ use Iterator; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Exceptions\IOException; -use Shaarli\FileUtils; +use Shaarli\Helper\FileUtils; use Shaarli\Render\PageCacheManager; /** @@ -62,7 +62,7 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess private $datastore; // Link date storage format - const LINK_DATE_FORMAT = 'Ymd_His'; + public const LINK_DATE_FORMAT = 'Ymd_His'; // List of bookmarks (associative array) // - key: link date (e.g. "20110823_124546"), @@ -240,8 +240,8 @@ private function check() } // Create a dummy database for example - $this->links = array(); - $link = array( + $this->links = []; + $link = [ 'id' => 1, 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), 'url' => 'https://shaarli.readthedocs.io', @@ -257,11 +257,11 @@ private function check() 'created' => new DateTime(), 'tags' => 'opensource software', 'sticky' => false, - ); + ]; $link['shorturl'] = link_small_hash($link['created'], $link['id']); $this->links[1] = $link; - $link = array( + $link = [ 'id' => 0, 'title' => t('My secret stuff... - Pastebin.com'), 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', @@ -270,7 +270,7 @@ private function check() 'created' => new DateTime('1 minute ago'), 'tags' => 'secretstuff', 'sticky' => false, - ); + ]; $link['shorturl'] = link_small_hash($link['created'], $link['id']); $this->links[0] = $link; @@ -285,7 +285,7 @@ private function read() { // Public bookmarks are hidden and user not logged in => nothing to show if ($this->hidePublicLinks && !$this->loggedIn) { - $this->links = array(); + $this->links = []; return; } @@ -293,7 +293,7 @@ private function read() $this->ids = []; $this->links = FileUtils::readFlatDB($this->datastore, []); - $toremove = array(); + $toremove = []; foreach ($this->links as $key => &$link) { if (!$this->loggedIn && $link['private'] != 0) { // Transition for not upgraded databases. @@ -414,7 +414,7 @@ public function filterDay($request) * @return array filtered bookmarks, all bookmarks if no suitable filter was provided. */ public function filterSearch( - $filterRequest = array(), + $filterRequest = [], $casesensitive = false, $visibility = 'all', $untaggedonly = false @@ -512,7 +512,7 @@ public function renameTag($from, $to) */ public function days() { - $linkDays = array(); + $linkDays = []; foreach ($this->links as $link) { $linkDays[$link['created']->format('Ymd')] = 0; } diff --git a/application/legacy/LegacyLinkFilter.php b/application/legacy/LegacyLinkFilter.php index 7cf93d60c..e6d186c44 100644 --- a/application/legacy/LegacyLinkFilter.php +++ b/application/legacy/LegacyLinkFilter.php @@ -120,7 +120,7 @@ private function noFilter($visibility = 'all') return $this->links; } - $out = array(); + $out = []; foreach ($this->links as $key => $value) { if ($value['private'] && $visibility === 'private') { $out[$key] = $value; @@ -143,7 +143,7 @@ private function noFilter($visibility = 'all') */ private function filterSmallHash($smallHash) { - $filtered = array(); + $filtered = []; foreach ($this->links as $key => $l) { if ($smallHash == $l['shorturl']) { // Yes, this is ugly and slow @@ -186,7 +186,7 @@ private function filterFulltext($searchterms, $visibility = 'all') return $this->noFilter($visibility); } - $filtered = array(); + $filtered = []; $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); $exactRegex = '/"([^"]+)"/'; // Retrieve exact search terms. @@ -198,8 +198,8 @@ private function filterFulltext($searchterms, $visibility = 'all') $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); // Filter excluding terms and update andSearch. - $excludeSearch = array(); - $andSearch = array(); + $excludeSearch = []; + $andSearch = []; foreach ($explodedSearchAnd as $needle) { if ($needle[0] == '-' && strlen($needle) > 1) { $excludeSearch[] = substr($needle, 1); @@ -208,7 +208,7 @@ private function filterFulltext($searchterms, $visibility = 'all') } } - $keys = array('title', 'description', 'url', 'tags'); + $keys = ['title', 'description', 'url', 'tags']; // Iterate over every stored link. foreach ($this->links as $id => $link) { @@ -336,7 +336,7 @@ public function filterTags($tags, $casesensitive = false, $visibility = 'all') } // create resulting array - $filtered = array(); + $filtered = []; // iterate over each link foreach ($this->links as $key => $link) { @@ -352,7 +352,7 @@ public function filterTags($tags, $casesensitive = false, $visibility = 'all') $search = $link['tags']; // build search string, start with tags of current link if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { // description given and at least one possible tag found - $descTags = array(); + $descTags = []; // find all tags in the form of #tag in the description preg_match_all( '/(?links as $key => $l) { if ($l['created']->format('Ymd') == $day) { $filtered[$key] = $l; diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php index 0ab3a55bd..9bda54b8d 100644 --- a/application/legacy/LegacyUpdater.php +++ b/application/legacy/LegacyUpdater.php @@ -7,7 +7,6 @@ use ReflectionClass; use ReflectionException; use ReflectionMethod; -use Shaarli\ApplicationUtils; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkArray; use Shaarli\Bookmark\BookmarkFilter; @@ -17,6 +16,7 @@ use Shaarli\Config\ConfigManager; use Shaarli\Config\ConfigPhp; use Shaarli\Exceptions\IOException; +use Shaarli\Helper\ApplicationUtils; use Shaarli\Thumbnailer; use Shaarli\Updater\Exception\UpdaterException; @@ -93,7 +93,7 @@ public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session */ public function update() { - $updatesRan = array(); + $updatesRan = []; // If the user isn't logged in, exit without updating. if ($this->isLoggedIn !== true) { @@ -106,7 +106,8 @@ public function update() foreach ($this->methods as $method) { // Not an update method or already done, pass. - if (!startsWith($method->getName(), 'updateMethod') + if ( + !startsWith($method->getName(), 'updateMethod') || in_array($method->getName(), $this->doneUpdates) ) { continue; @@ -189,7 +190,7 @@ public function updateMethodConfigToJson() } // Set sub config keys (config and plugins) - $subConfig = array('config', 'plugins'); + $subConfig = ['config', 'plugins']; foreach ($subConfig as $sub) { foreach ($oldConfig[$sub] as $key => $value) { if (isset($legacyMap[$sub . '.' . $key])) { @@ -259,7 +260,7 @@ public function updateMethodDatastoreIds() $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; copy($this->conf->get('resource.datastore'), $save); - $links = array(); + $links = []; foreach ($this->linkDB as $offset => $value) { $links[] = $value; unset($this->linkDB[$offset]); @@ -498,7 +499,8 @@ public function updateMethodVisibilitySession() */ public function updateMethodDownloadSizeAndTimeoutConf() { - if ($this->conf->exists('general.download_max_size') + if ( + $this->conf->exists('general.download_max_size') && $this->conf->exists('general.download_timeout') ) { return true; @@ -585,7 +587,7 @@ public function updateMethodMigrateDatabase() $linksArray = new BookmarkArray(); foreach ($this->linkDB as $key => $link) { - $linksArray[$key] = (new Bookmark())->fromArray($link); + $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' ')); } $linksIo = new BookmarkIO($this->conf); $linksIo->write($linksArray); diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index b83f16f8e..2d97b4c85 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -59,11 +59,11 @@ public function filterAndFormat( $indexUrl ) { // see tpl/export.html for possible values - if (!in_array($selection, array('all', 'public', 'private'))) { + if (!in_array($selection, ['all', 'public', 'private'])) { throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); } - $bookmarkLinks = array(); + $bookmarkLinks = []; foreach ($this->bookmarkService->search([], $selection) as $bookmark) { $link = $formatter->format($bookmark); $link['taglist'] = implode(',', $bookmark->getTags()); @@ -101,11 +101,11 @@ public function import($post, UploadedFileInterface $file) // Add tags to all imported bookmarks? if (empty($post['default_tags'])) { - $defaultTags = array(); + $defaultTags = []; } else { - $defaultTags = preg_split( - '/[\s,]+/', - escape($post['default_tags']) + $defaultTags = tags_str2array( + escape($post['default_tags']), + $this->conf->get('general.tags_separator', ' ') ); } @@ -171,7 +171,7 @@ public function import($post, UploadedFileInterface $file) $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); $link->setDescription($bkm['note']); $link->setPrivate($private); - $link->setTagsString($bkm['tags']); + $link->setTags($bkm['tags']); $this->bookmarkService->addOrSet($link, false); $importCount++; diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php index 1b2197c9d..3ea55728c 100644 --- a/application/plugin/PluginManager.php +++ b/application/plugin/PluginManager.php @@ -1,4 +1,5 @@ conf = $conf; - $this->errors = array(); + $this->errors = []; } /** @@ -98,12 +99,13 @@ public function load($authorizedPlugins) * * @return void */ - public function executeHooks($hook, &$data, $params = array()) + public function executeHooks($hook, &$data, $params = []) { $metadataParameters = [ 'target' => '_PAGE_', 'loggedin' => '_LOGGEDIN_', 'basePath' => '_BASE_PATH_', + 'rootPath' => '_ROOT_PATH_', 'bookmarkService' => '_BOOKMARK_SERVICE_', ]; @@ -195,7 +197,7 @@ public function buildHookName($hook, $pluginName) */ public function getPluginsMeta() { - $metaData = array(); + $metaData = []; $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); // Browse all plugin directories. @@ -216,9 +218,9 @@ public function getPluginsMeta() if (isset($metaData[$plugin]['parameters'])) { $params = explode(';', $metaData[$plugin]['parameters']); } else { - $params = array(); + $params = []; } - $metaData[$plugin]['parameters'] = array(); + $metaData[$plugin]['parameters'] = []; foreach ($params as $param) { if (empty($param)) { continue; diff --git a/application/plugin/exception/PluginFileNotFoundException.php b/application/plugin/exception/PluginFileNotFoundException.php index e5386f026..21ac6604f 100644 --- a/application/plugin/exception/PluginFileNotFoundException.php +++ b/application/plugin/exception/PluginFileNotFoundException.php @@ -1,4 +1,5 @@ tpl = false; $this->conf = $conf; $this->session = $session; + $this->logger = $logger; $this->bookmarkService = $linkDB; $this->token = $token; $this->isLoggedIn = $isLoggedIn; @@ -98,7 +109,7 @@ private function initialize() $this->tpl->assign('newVersion', escape($version)); $this->tpl->assign('versionError', ''); } catch (Exception $exc) { - logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); + $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER))); $this->tpl->assign('newVersion', ''); $this->tpl->assign('versionError', escape($exc->getMessage())); } @@ -149,7 +160,8 @@ private function initialize() $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); - $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']); + $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20); + $this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' ')); // To be removed with a proper theme configuration. $this->tpl->assign('conf', $this->conf); @@ -174,10 +186,12 @@ protected function finalize(string $basePath): void } } + $rootPath = preg_replace('#/index\.php$#', '', $basePath); $this->assign('base_path', $basePath); + $this->assign('root_path', $rootPath); $this->assign( 'asset_path', - $basePath . '/' . + $rootPath . '/' . rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' . $this->conf->get('resource.theme', 'default') ); diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php index 8af8228a0..03b424f3c 100644 --- a/application/render/TemplatePage.php +++ b/application/render/TemplatePage.php @@ -14,6 +14,7 @@ interface TemplatePage public const DAILY = 'daily'; public const DAILY_RSS = 'dailyrss'; public const EDIT_LINK = 'editlink'; + public const EDIT_LINK_BATCH = 'editlink.batch'; public const ERROR = 'error'; public const EXPORT = 'export'; public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks'; diff --git a/application/render/ThemeUtils.php b/application/render/ThemeUtils.php index 86096c644..18471f0a2 100644 --- a/application/render/ThemeUtils.php +++ b/application/render/ThemeUtils.php @@ -23,10 +23,10 @@ class ThemeUtils public static function getThemes($tplDir) { $tplDir = rtrim($tplDir, '/'); - $allTheme = glob($tplDir.'/*', GLOB_ONLYDIR); + $allTheme = glob($tplDir . '/*', GLOB_ONLYDIR); $themes = []; foreach ($allTheme as $value) { - $themes[] = str_replace($tplDir.'/', '', $value); + $themes[] = str_replace($tplDir . '/', '', $value); } return $themes; diff --git a/application/security/BanManager.php b/application/security/BanManager.php index 68190c54f..7077af5b5 100644 --- a/application/security/BanManager.php +++ b/application/security/BanManager.php @@ -1,9 +1,9 @@ trustedProxies = $trustedProxies; $this->nbAttempts = $nbAttempts; $this->banDuration = $banDuration; $this->banFile = $banFile; - $this->logFile = $logFile; + $this->logger = $logger; + $this->readBanFile(); } @@ -78,11 +80,7 @@ public function handleFailedAttempt($server) if ($this->failures[$ip] >= $this->nbAttempts) { $this->bans[$ip] = time() + $this->banDuration; - logm( - $this->logFile, - $server['REMOTE_ADDR'], - 'IP address banned from login: '. $ip - ); + $this->logger->info(format_log('IP address banned from login: ' . $ip, $ip)); } $this->writeBanFile(); } @@ -138,7 +136,7 @@ public function isBanned($server) unset($this->failures[$ip]); } unset($this->bans[$ip]); - logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip); + $this->logger->info(format_log('Ban lifted for: ' . $ip, $ip)); $this->writeBanFile(); return false; diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php index d74c3118c..b795b80e7 100644 --- a/application/security/LoginManager.php +++ b/application/security/LoginManager.php @@ -1,7 +1,9 @@ configManager = $configManager; $this->sessionManager = $sessionManager; $this->cookieManager = $cookieManager; - $this->banManager = new BanManager( - $this->configManager->get('security.trusted_proxies', []), - $this->configManager->get('security.ban_after'), - $this->configManager->get('security.ban_duration'), - $this->configManager->get('resource.ban_file', 'data/ipbans.php'), - $this->configManager->get('resource.log') - ); + $this->banManager = $banManager; + $this->logger = $logger; if ($this->configManager->get('security.open_shaarli') === true) { $this->openShaarli = true; @@ -101,7 +107,8 @@ public function checkLoginState($clientIpId) // The user client has a valid stay-signed-in cookie // Session information is updated with the current client information $this->sessionManager->storeLoginInfo($clientIpId); - } elseif ($this->sessionManager->hasSessionExpired() + } elseif ( + $this->sessionManager->hasSessionExpired() || $this->sessionManager->hasClientIpChanged($clientIpId) ) { $this->sessionManager->logout(); @@ -118,7 +125,7 @@ public function checkLoginState($clientIpId) * * @return true when the user is logged in, false otherwise */ - public function isLoggedIn() + public function isLoggedIn(): bool { if ($this->openShaarli) { return true; @@ -129,48 +136,35 @@ public function isLoggedIn() /** * Check user credentials are valid * - * @param string $remoteIp Remote client IP address * @param string $clientIpId Client IP address identifier * @param string $login Username * @param string $password Password * * @return bool true if the provided credentials are valid, false otherwise */ - public function checkCredentials($remoteIp, $clientIpId, $login, $password) + public function checkCredentials($clientIpId, $login, $password) { - // Check login matches config - if ($login !== $this->configManager->get('credentials.login')) { - return false; - } - // Check credentials try { $useLdapLogin = !empty($this->configManager->get('ldap.host')); - if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) - || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) + if ( + $login === $this->configManager->get('credentials.login') + && ( + (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) + || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) + ) ) { - $this->sessionManager->storeLoginInfo($clientIpId); - logm( - $this->configManager->get('resource.log'), - $remoteIp, - 'Login successful' - ); - return true; + $this->sessionManager->storeLoginInfo($clientIpId); + $this->logger->info(format_log('Login successful', $clientIpId)); + + return true; } - } - catch(Exception $exception) { - logm( - $this->configManager->get('resource.log'), - $remoteIp, - 'Exception while checking credentials: ' . $exception - ); + } catch (Exception $exception) { + $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId)); } - logm( - $this->configManager->get('resource.log'), - $remoteIp, - 'Login failed for user ' . $login - ); + $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId)); + return false; } @@ -183,7 +177,8 @@ public function checkCredentials($remoteIp, $clientIpId, $login, $password) * * @return bool true if the provided credentials are valid, false otherwise */ - public function checkCredentialsFromLocalConfig($login, $password) { + public function checkCredentialsFromLocalConfig($login, $password) + { $hash = sha1($password . $login . $this->configManager->get('credentials.salt')); return $login == $this->configManager->get('credentials.login') @@ -202,14 +197,14 @@ public function checkCredentialsFromLocalConfig($login, $password) { */ public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null) { - $connect = $connect ?? function($host) { + $connect = $connect ?? function ($host) { $resource = ldap_connect($host); ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3); return $resource; }; - $bind = $bind ?? function($handle, $dn, $password) { + $bind = $bind ?? function ($handle, $dn, $password) { return ldap_bind($handle, $dn, $password); }; diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php index 36df8c1c9..f957b91a0 100644 --- a/application/security/SessionManager.php +++ b/application/security/SessionManager.php @@ -1,4 +1,5 @@ conf->get('credentials.salt')); + $token = sha1(uniqid('', true) . '_' . mt_rand() . $this->conf->get('credentials.salt')); $this->session['tokens'][$token] = 1; return $token; } @@ -293,9 +294,12 @@ public function start(): bool return session_start(); } - public function cookieParameters(int $lifeTime, string $path, string $domain): bool + /** + * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2. + */ + public function cookieParameters(int $lifeTime, string $path, string $domain): void { - return session_set_cookie_params($lifeTime, $path, $domain); + session_set_cookie_params($lifeTime, $path, $domain); } public function regenerateId(bool $deleteOldSession = false): bool diff --git a/application/updater/Updater.php b/application/updater/Updater.php index 88a7bc7b2..4f557d0f5 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php @@ -88,7 +88,8 @@ public function update(string $basePath = null) foreach ($this->methods as $method) { // Not an update method or already done, pass. - if (! startsWith($method->getName(), 'updateMethod') + if ( + ! startsWith($method->getName(), 'updateMethod') || in_array($method->getName(), $this->doneUpdates) ) { continue; @@ -121,12 +122,12 @@ public function getDoneUpdates() public function readUpdates(string $updatesFilepath): array { - return UpdaterUtils::read_updates_file($updatesFilepath); + return UpdaterUtils::readUpdatesFile($updatesFilepath); } public function writeUpdates(string $updatesFilepath, array $updates): void { - UpdaterUtils::write_updates_file($updatesFilepath, $updates); + UpdaterUtils::writeUpdatesFile($updatesFilepath, $updates); } /** @@ -152,7 +153,8 @@ public function updateMethodMigrateExistingNotesUrl(): bool $updated = false; foreach ($this->bookmarkService->search() as $bookmark) { - if ($bookmark->isNote() + if ( + $bookmark->isNote() && startsWith($bookmark->getUrl(), '?') && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) ) { diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php index 828a49fc0..206f826ed 100644 --- a/application/updater/UpdaterUtils.php +++ b/application/updater/UpdaterUtils.php @@ -11,7 +11,7 @@ class UpdaterUtils * * @return array Already done update methods. */ - public static function read_updates_file($updatesFilepath) + public static function readUpdatesFile($updatesFilepath) { if (! empty($updatesFilepath) && is_file($updatesFilepath)) { $content = file_get_contents($updatesFilepath); @@ -19,7 +19,7 @@ public static function read_updates_file($updatesFilepath) return explode(';', $content); } } - return array(); + return []; } /** @@ -30,7 +30,7 @@ public static function read_updates_file($updatesFilepath) * * @throws \Exception Couldn't write version number. */ - public static function write_updates_file($updatesFilepath, $updates) + public static function writeUpdatesFile($updatesFilepath, $updates) { if (empty($updatesFilepath)) { throw new \Exception('Updates file path is not set, can\'t write updates.'); @@ -38,7 +38,7 @@ public static function write_updates_file($updatesFilepath, $updates) $res = file_put_contents($updatesFilepath, implode(';', $updates)); if ($res === false) { - throw new \Exception('Unable to write updates in '. $updatesFilepath . '.'); + throw new \Exception('Unable to write updates in ' . $updatesFilepath . '.'); } } } diff --git a/assets/common/js/metadata.js b/assets/common/js/metadata.js new file mode 100644 index 000000000..d5a28a35e --- /dev/null +++ b/assets/common/js/metadata.js @@ -0,0 +1,107 @@ +import he from 'he'; + +/** + * This script is used to retrieve bookmarks metadata asynchronously: + * - title, description and keywords while creating a new bookmark + * - thumbnails while visiting the bookmark list + * + * Note: it should only be included if the user is logged in + * and the setting general.enable_async_metadata is enabled. + */ + +/** + * Removes given input loaders - used in edit link template. + * + * @param {object} loaders List of input DOM element that need to be cleared + */ +function clearLoaders(loaders) { + if (loaders != null && loaders.length > 0) { + [...loaders].forEach((loader) => { + loader.classList.remove('loading-input'); + }); + } +} + +/** + * AJAX request to update the thumbnail of a bookmark with the provided ID. + * If a thumbnail is retrieved, it updates the divElement with the image src, and displays it. + * + * @param {string} basePath Shaarli subfolder for XHR requests + * @param {object} divElement Main
DOM element containing the thumbnail placeholder + * @param {int} id Bookmark ID to update + */ +function updateThumb(basePath, divElement, id) { + const xhr = new XMLHttpRequest(); + xhr.open('PATCH', `${basePath}/admin/shaare/${id}/update-thumbnail`); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + xhr.responseType = 'json'; + xhr.onload = () => { + if (xhr.status !== 200) { + alert(`An error occurred. Return code: ${xhr.status}`); + } else { + const { response } = xhr; + + if (response.thumbnail !== false) { + const imgElement = divElement.querySelector('img'); + + imgElement.src = response.thumbnail; + imgElement.dataset.src = response.thumbnail; + imgElement.style.opacity = '1'; + divElement.classList.remove('hidden'); + } + } + }; + xhr.send(); +} + +(() => { + const basePath = document.querySelector('input[name="js_base_path"]').value; + + /* + * METADATA FOR EDIT BOOKMARK PAGE + */ + const inputTitles = document.querySelectorAll('input[name="lf_title"]'); + if (inputTitles != null) { + [...inputTitles].forEach((inputTitle) => { + const form = inputTitle.closest('form[name="linkform"]'); + const loaders = form.querySelectorAll('.loading-input'); + + if (inputTitle.value.length > 0) { + clearLoaders(loaders); + return; + } + + const url = form.querySelector('input[name="lf_url"]').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 = form.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(); + }); + } + + /* + * METADATA FOR THUMBNAIL RETRIEVAL + */ + const thumbsToLoad = document.querySelectorAll('div[data-async-thumbnail]'); + if (thumbsToLoad != null) { + [...thumbsToLoad].forEach((divElement) => { + const { id } = divElement.closest('[data-id]').dataset; + + updateThumb(basePath, divElement, id); + }); + } +})(); diff --git a/assets/common/js/shaare-batch.js b/assets/common/js/shaare-batch.js new file mode 100644 index 000000000..557325ee3 --- /dev/null +++ b/assets/common/js/shaare-batch.js @@ -0,0 +1,121 @@ +const sendBookmarkForm = (basePath, formElement) => { + const inputs = formElement + .querySelectorAll('input[type="text"], textarea, input[type="checkbox"], input[type="hidden"]'); + + const formData = new FormData(); + [...inputs].forEach((input) => { + formData.append(input.getAttribute('name'), input.value); + }); + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${basePath}/admin/shaare`); + xhr.onload = () => { + if (xhr.status !== 200) { + alert(`An error occurred. Return code: ${xhr.status}`); + reject(); + } else { + formElement.closest('.edit-link-container').remove(); + resolve(); + } + }; + xhr.send(formData); + }); +}; + +const sendBookmarkDelete = (buttonElement, formElement) => ( + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', buttonElement.href); + xhr.onload = () => { + if (xhr.status !== 200) { + alert(`An error occurred. Return code: ${xhr.status}`); + reject(); + } else { + formElement.closest('.edit-link-container').remove(); + resolve(); + } + }; + xhr.send(); + }) +); + +const redirectIfEmptyBatch = (basePath, formElements, path) => { + if (formElements == null || formElements.length === 0) { + window.location.href = `${basePath}${path}`; + } +}; + +(() => { + const basePath = document.querySelector('input[name="js_base_path"]').value; + const getForms = () => document.querySelectorAll('form[name="linkform"]'); + + const cancelButtons = document.querySelectorAll('[name="cancel-batch-link"]'); + if (cancelButtons != null) { + [...cancelButtons].forEach((cancelButton) => { + cancelButton.addEventListener('click', (e) => { + e.preventDefault(); + e.target.closest('form[name="linkform"]').remove(); + redirectIfEmptyBatch(basePath, getForms(), '/admin/add-shaare'); + }); + }); + } + + const saveButtons = document.querySelectorAll('[name="save_edit"]'); + if (saveButtons != null) { + [...saveButtons].forEach((saveButton) => { + saveButton.addEventListener('click', (e) => { + e.preventDefault(); + + const formElement = e.target.closest('form[name="linkform"]'); + sendBookmarkForm(basePath, formElement) + .then(() => redirectIfEmptyBatch(basePath, getForms(), '/')); + }); + }); + } + + const saveAllButtons = document.querySelectorAll('[name="save_edit_batch"]'); + if (saveAllButtons != null) { + [...saveAllButtons].forEach((saveAllButton) => { + saveAllButton.addEventListener('click', (e) => { + e.preventDefault(); + + const forms = [...getForms()]; + const nbForm = forms.length; + let current = 0; + const progressBar = document.querySelector('.progressbar > div'); + const progressBarCurrent = document.querySelector('.progressbar-current'); + + document.querySelector('.dark-layer').style.display = 'block'; + document.querySelector('.progressbar-max').innerHTML = nbForm; + progressBarCurrent.innerHTML = current; + + const promises = []; + forms.forEach((formElement) => { + promises.push(sendBookmarkForm(basePath, formElement).then(() => { + current += 1; + progressBar.style.width = `${(current * 100) / nbForm}%`; + progressBarCurrent.innerHTML = current; + })); + }); + + Promise.all(promises).then(() => { + window.location.href = basePath || '/'; + }); + }); + }); + } + + const deleteButtons = document.querySelectorAll('[name="delete_link"]'); + if (deleteButtons != null) { + [...deleteButtons].forEach((deleteButton) => { + deleteButton.addEventListener('click', (e) => { + e.preventDefault(); + + const formElement = e.target.closest('form[name="linkform"]'); + sendBookmarkDelete(e.target, formElement) + .then(() => redirectIfEmptyBatch(basePath, getForms(), '/')); + }); + }); + } +})(); diff --git a/assets/default/js/base.js b/assets/default/js/base.js index be986ae01..dd532bb71 100644 --- a/assets/default/js/base.js +++ b/assets/default/js/base.js @@ -1,4 +1,5 @@ import Awesomplete from 'awesomplete'; +import he from 'he'; /** * Find a parent element according to its tag and its attributes @@ -41,19 +42,21 @@ function refreshToken(basePath, callback) { xhr.send(); } -function createAwesompleteInstance(element, tags = []) { +function createAwesompleteInstance(element, separator, tags = []) { const awesome = new Awesomplete(Awesomplete.$(element)); - // Tags are separated by a space - awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); + + // Tags are separated by separator + awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(new RegExp(`[^${separator}]*$`))[0]); // Insert new selected tag in the input awesome.replace = (text) => { - const before = awesome.input.value.match(/^.+ \s*|/)[0]; - awesome.input.value = `${before}${text} `; + const before = awesome.input.value.match(new RegExp(`^.+${separator}+|`))[0]; + awesome.input.value = `${before}${text}${separator}`; }; // Highlight found items - awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]); + awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${separator}]*$`))[0]); // Don't display already selected items - const reg = /(\w+) /g; + // WARNING: pseudo classes does not seem to work with string litterals... + const reg = new RegExp(`([^${separator}]+)${separator}`, 'g'); let match; awesome.data = (item, input) => { while ((match = reg.exec(input))) { @@ -77,13 +80,14 @@ function createAwesompleteInstance(element, tags = []) { * @param selector CSS selector * @param tags Array of tags * @param instances List of existing awesomplete instances + * @param separator Tags separator character */ -function updateAwesompleteList(selector, tags, instances) { +function updateAwesompleteList(selector, tags, instances, separator) { if (instances.length === 0) { // First load: create Awesomplete instances const elements = document.querySelectorAll(selector); [...elements].forEach((element) => { - instances.push(createAwesompleteInstance(element, tags)); + instances.push(createAwesompleteInstance(element, separator, tags)); }); } else { // Update awesomplete tag list @@ -95,15 +99,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. * @@ -222,6 +217,8 @@ function init(description) { (() => { const basePath = document.querySelector('input[name="js_base_path"]').value; + const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]'); + const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' '; /** * Handle responsive menu. @@ -302,7 +299,8 @@ function init(description) { const deleteLinks = document.querySelectorAll('.confirm-delete'); [...deleteLinks].forEach((deleteLink) => { deleteLink.addEventListener('click', (event) => { - if (!confirm(document.getElementById('translation-delete-link').innerHTML)) { + const type = event.currentTarget.getAttribute('data-type') || 'link'; + if (!confirm(document.getElementById(`translation-delete-${type}`).innerHTML)) { event.preventDefault(); } }); @@ -569,7 +567,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)}`); @@ -582,7 +580,7 @@ function init(description) { // Refresh awesomplete values existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag)); - awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); + awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator); } }; xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`); @@ -622,14 +620,14 @@ function init(description) { refreshToken(basePath); existingTags = existingTags.filter((tagItem) => tagItem !== tag); - awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); + awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator); } }); }); const autocompleteFields = document.querySelectorAll('input[data-multiple]'); [...autocompleteFields].forEach((autocompleteField) => { - awesomepletes.push(createAwesompleteInstance(autocompleteField)); + awesomepletes.push(createAwesompleteInstance(autocompleteField, tagsSeparator)); }); const exportForm = document.querySelector('#exportform'); @@ -642,4 +640,33 @@ function init(description) { }); }); } + + const bulkCreationButton = document.querySelector('.addlink-batch-show-more-block'); + if (bulkCreationButton != null) { + const toggleBulkCreationVisibility = (showMoreBlockElement, formElement) => { + if (bulkCreationButton.classList.contains('pure-u-0')) { + showMoreBlockElement.classList.remove('pure-u-0'); + formElement.classList.add('pure-u-0'); + } else { + showMoreBlockElement.classList.add('pure-u-0'); + formElement.classList.remove('pure-u-0'); + } + }; + + const bulkCreationForm = document.querySelector('.addlink-batch-form-block'); + + toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm); + bulkCreationButton.querySelector('a').addEventListener('click', (e) => { + e.preventDefault(); + toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm); + }); + + // Force to send falsy value if the checkbox is not checked. + const privateButton = bulkCreationForm.querySelector('input[type="checkbox"][name="private"]'); + const privateHiddenButton = bulkCreationForm.querySelector('input[type="hidden"][name="private"]'); + privateButton.addEventListener('click', () => { + privateHiddenButton.disabled = !privateHiddenButton.disabled; + }); + privateHiddenButton.disabled = privateButton.checked; + } })(); diff --git a/assets/default/scss/shaarli.scss b/assets/default/scss/shaarli.scss index a528adb0d..cc8ccc1e0 100644 --- a/assets/default/scss/shaarli.scss +++ b/assets/default/scss/shaarli.scss @@ -139,6 +139,16 @@ body, } } +.page-form, +.pure-alert { + code { + display: inline-block; + padding: 0 2px; + color: $dark-grey; + background-color: var(--background-color); + } +} + // Make pure-extras alert closable. .pure-alert-closable { .fa-times { @@ -671,6 +681,10 @@ body, content: ''; } } + + .search-highlight { + background-color: yellow; + } } .linklist-item-buttons { @@ -1019,6 +1033,10 @@ body, &.button-red { background: $red; } + + &.button-grey { + background: $light-grey; + } } .submit-buttons { @@ -1043,7 +1061,7 @@ body, } table { - margin: auto; + margin: 10px auto 25px auto; width: 90%; .order { @@ -1079,6 +1097,11 @@ body, position: absolute; right: 5%; } + + &.button-grey { + position: absolute; + left: 5%; + } } } } @@ -1253,11 +1276,15 @@ form { margin: 70px 0 25px; } + a { + color: var(--main-color); + } + pre { margin: 0 20%; padding: 20px 0; text-align: left; - line-height: .7em; + line-height: 1em; } } @@ -1269,6 +1296,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 { @@ -1641,6 +1719,123 @@ form { } } +// SERVER PAGE + +.server-tables-page, +.server-tables { + .window-subtitle { + &::before { + display: block; + margin: 8px auto; + background: linear-gradient(to right, var(--background-color), $dark-grey, var(--background-color)); + width: 50%; + height: 1px; + content: ''; + } + } + + .server-row { + p { + height: 25px; + padding: 0 10px; + } + } + + .server-label { + text-align: right; + font-weight: bold; + } + + i { + &.fa-color-green { + color: $main-green; + } + + &.fa-color-orange { + color: $orange; + } + + &.fa-color-red { + color: $red; + } + } + + @media screen and (max-width: 64em) { + .server-label { + text-align: center; + } + + .server-row { + p { + text-align: center; + } + } + } +} + +// Batch creation +input[name='save_edit_batch'] { + @extend %page-form-button; +} + +.addlink-batch-show-more { + display: flex; + align-items: center; + margin: 20px 0 8px; + + a { + color: var(--main-color); + text-decoration: none; + } + + &::before, + &::after { + content: ""; + flex-grow: 1; + background: rgba(0, 0, 0, 0.35); + height: 1px; + font-size: 0; + line-height: 0; + } + + &::before { + margin: 0 16px 0 0; + } + + &::after { + margin: 0 0 0 16px; + } +} + +.dark-layer { + display: none; + position: fixed; + height: 100%; + width: 100%; + z-index: 998; + background-color: rgba(0, 0, 0, .75); + color: #fff; + + .screen-center { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + min-height: 100vh; + } + + .progressbar { + width: 33%; + } +} + +.addlink-batch-form-block { + .pure-alert { + margin: 25px 0 0 0; + } +} + // Print rules @media print { .shaarli-menu { diff --git a/assets/vintage/css/shaarli.css b/assets/vintage/css/shaarli.css index 1688dce07..33e178afe 100644 --- a/assets/vintage/css/shaarli.css +++ b/assets/vintage/css/shaarli.css @@ -1122,6 +1122,16 @@ ul.errors { float: left; } +ul.warnings { + color: orange; + float: left; +} + +ul.successes { + color: green; + float: left; +} + #pluginsadmin { width: 80%; padding: 20px 0 0 20px; @@ -1248,3 +1258,54 @@ ul.errors { width: 0%; height: 10px; } + +.loading-input { + position: relative; +} + +@keyframes around { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +.loading-input .icon-container { + position: absolute; + right: 60px; + top: calc(50% - 10px); +} + +.loading-input .loader { + position: relative; + height: 20px; + width: 20px; + display: inline-block; + animation: around 5.4s infinite; +} + +.loading-input .loader::after, +.loading-input .loader::before { + content: ""; + background: #eee; + 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; +} + +.loading-input .loader::after { + animation: around 0.7s ease-in-out 0.1s infinite; + background: transparent; +} diff --git a/assets/vintage/js/base.js b/assets/vintage/js/base.js index 66830b59d..55f1c37df 100644 --- a/assets/vintage/js/base.js +++ b/assets/vintage/js/base.js @@ -2,29 +2,38 @@ import Awesomplete from 'awesomplete'; import 'awesomplete/awesomplete.css'; (() => { - const awp = Awesomplete.$; const autocompleteFields = document.querySelectorAll('input[data-multiple]'); + const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]'); + const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' '; + [...autocompleteFields].forEach((autocompleteField) => { - const awesomplete = new Awesomplete(awp(autocompleteField)); - awesomplete.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); - awesomplete.replace = (text) => { - const before = awesomplete.input.value.match(/^.+ \s*|/)[0]; - awesomplete.input.value = `${before}${text} `; + const awesome = new Awesomplete(Awesomplete.$(autocompleteField)); + + // Tags are separated by separator + awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS( + text, + input.match(new RegExp(`[^${tagsSeparator}]*$`))[0], + ); + // Insert new selected tag in the input + awesome.replace = (text) => { + const before = awesome.input.value.match(new RegExp(`^.+${tagsSeparator}+|`))[0]; + awesome.input.value = `${before}${text}${tagsSeparator}`; }; - awesomplete.minChars = 1; + // Highlight found items + awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${tagsSeparator}]*$`))[0]); - autocompleteField.addEventListener('input', () => { - const proposedTags = autocompleteField.getAttribute('data-list').replace(/,/g, '').split(' '); - const reg = /(\w+) /g; - let match; - while ((match = reg.exec(autocompleteField.value)) !== null) { - const id = proposedTags.indexOf(match[1]); - if (id !== -1) { - proposedTags.splice(id, 1); + // Don't display already selected items + // WARNING: pseudo classes does not seem to work with string litterals... + const reg = new RegExp(`([^${tagsSeparator}]+)${tagsSeparator}`, 'g'); + let match; + awesome.data = (item, input) => { + while ((match = reg.exec(input))) { + if (item === match[1]) { + return ''; } } - - awesomplete.list = proposedTags; - }); + return item; + }; + awesome.minChars = 1; }); })(); diff --git a/composer.json b/composer.json index cd9fcf5b2..138319cab 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ }, "keywords": ["bookmark", "link", "share", "web"], "config": { + "sort-packages": true, "platform": { "php": "7.1.29" } @@ -18,12 +19,15 @@ "php": ">=7.1", "ext-json": "*", "ext-zlib": "*", - "shaarli/netscape-bookmark-parser": "^2.1", - "erusev/parsedown": "^1.6", - "slim/slim": "^3.0", "arthurhoaro/web-thumbnailer": "^2.0", + "erusev/parsedown": "^1.6", + "erusev/parsedown-extra": "^0.8.1", + "gettext/gettext": "^4.4", + "katzgrau/klogger": "^1.2", + "malkusch/lock": "^2.1", "pubsubhubbub/publisher": "dev-master", - "gettext/gettext": "^4.4" + "shaarli/netscape-bookmark-parser": "^3.0", + "slim/slim": "^3.0" }, "require-dev": { "roave/security-advisories": "dev-master", @@ -55,6 +59,7 @@ "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin", "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor", "Shaarli\\Front\\Exception\\": "application/front/exceptions", + "Shaarli\\Helper\\": "application/helper", "Shaarli\\Http\\": "application/http", "Shaarli\\Legacy\\": "application/legacy", "Shaarli\\Netscape\\": "application/netscape", diff --git a/composer.lock b/composer.lock index 2c8b0ea7b..0023df880 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "98520a05a7185503ee13d05ffaa535f6", + "content-hash": "83852dec81e299a117a81206a5091472", "packages": [ { "name": "arthurhoaro/web-thumbnailer", @@ -107,6 +107,57 @@ }, "time": "2019-12-30T22:54:17+00:00" }, + { + "name": "erusev/parsedown-extra", + "version": "0.8.1", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown-extra.git", + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef", + "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef", + "shasum": "" + }, + "require": { + "erusev/parsedown": "^1.7.4" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "ParsedownExtra": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "An extension of Parsedown that adds support for Markdown Extra.", + "homepage": "https://github.com/erusev/parsedown-extra", + "keywords": [ + "markdown", + "markdown extra", + "parsedown", + "parser" + ], + "support": { + "issues": "https://github.com/erusev/parsedown-extra/issues", + "source": "https://github.com/erusev/parsedown-extra/tree/0.8.x" + }, + "time": "2019-12-30T23:20:37+00:00" + }, { "name": "gettext/gettext", "version": "v4.8.2", @@ -293,6 +344,91 @@ }, "time": "2016-11-07T19:29:14+00:00" }, + { + "name": "malkusch/lock", + "version": "v2.1", + "source": { + "type": "git", + "url": "https://github.com/php-lock/lock.git", + "reference": "093f389ec2f38fc8686d2f70e23378182fce7714" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-lock/lock/zipball/093f389ec2f38fc8686d2f70e23378182fce7714", + "reference": "093f389ec2f38fc8686d2f70e23378182fce7714", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/log": "^1" + }, + "require-dev": { + "eloquent/liberator": "^2.0", + "ext-memcached": "*", + "ext-pcntl": "*", + "ext-pdo_mysql": "*", + "ext-pdo_sqlite": "*", + "ext-redis": "*", + "ext-sysvsem": "*", + "johnkary/phpunit-speedtrap": "^3.0", + "kriswallsmith/spork": "^0.3", + "mikey179/vfsstream": "^1.6", + "php-mock/php-mock-phpunit": "^2.1", + "phpunit/phpunit": "^7.4", + "predis/predis": "^1.1", + "squizlabs/php_codesniffer": "^3.3" + }, + "suggest": { + "ext-pnctl": "Enables locking with flock without busy waiting in CLI scripts.", + "ext-redis": "To use this library with the PHP Redis extension.", + "ext-sysvsem": "Enables locking using semaphores.", + "predis/predis": "To use this library with predis." + }, + "type": "library", + "autoload": { + "psr-4": { + "malkusch\\lock\\": "classes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "WTFPL" + ], + "authors": [ + { + "name": "Markus Malkusch", + "email": "markus@malkusch.de", + "homepage": "http://markus.malkusch.de", + "role": "Developer" + }, + { + "name": "Willem Stuursma-Ruwen", + "email": "willem@stuursma.name", + "role": "Developer" + } + ], + "description": "Mutex library for exclusive code execution.", + "homepage": "https://github.com/malkusch/lock", + "keywords": [ + "advisory-locks", + "cas", + "flock", + "lock", + "locking", + "memcache", + "mutex", + "mysql", + "postgresql", + "redis", + "redlock", + "semaphore" + ], + "support": { + "issues": "https://github.com/php-lock/lock/issues", + "source": "https://github.com/php-lock/lock/tree/v2.1" + }, + "time": "2018-12-12T19:53:29+00:00" + }, { "name": "nikic/fast-route", "version": "v1.3.0", @@ -650,24 +786,25 @@ }, { "name": "shaarli/netscape-bookmark-parser", - "version": "v2.2.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/shaarli/netscape-bookmark-parser.git", - "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df" + "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/432a010af2bb1832d6fbc4763e6b0100b980a1df", - "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df", + "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/d2321f30413944b2d0a9844bf8cc588c71ae6305", + "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305", "shasum": "" }, "require": { "katzgrau/klogger": "~1.0", - "php": ">=5.6" + "php": ">=7.1" }, "require-dev": { - "phpunit/phpunit": "^5.0" + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.5" }, "type": "library", "autoload": { @@ -703,9 +840,9 @@ ], "support": { "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues", - "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v2.2.0" + "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v3.0.1" }, - "time": "2020-06-06T15:53:53+00:00" + "time": "2020-11-03T12:27:58+00:00" }, { "name": "slim/slim", @@ -1577,12 +1714,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "0749ceaf15c136d085b722a5bb88141398a54142" + "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/0749ceaf15c136d085b722a5bb88141398a54142", - "reference": "0749ceaf15c136d085b722a5bb88141398a54142", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/065a018d3b5c2c84a53db3347cca4e1b7fa362a6", + "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6", "shasum": "" }, "conflict": { @@ -1598,7 +1735,7 @@ "bagisto/bagisto": "<0.1.5", "barrelstrength/sprout-base-email": "<1.2.7", "barrelstrength/sprout-forms": "<3.9", - "baserproject/basercms": ">=4,<=4.3.6", + "baserproject/basercms": ">=4,<=4.3.6|>=4.4,<4.4.1", "bolt/bolt": "<3.7.1", "brightlocal/phpwhois": "<=4.2.5", "buddypress/buddypress": "<5.1.2", @@ -1642,7 +1779,7 @@ "ezsystems/ezplatform-kernel": ">=1,<1.0.2.1", "ezsystems/ezplatform-user": ">=1,<1.0.1", "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.14.2|>=6,<6.7.9.1|>=6.8,<6.13.6.3|>=7,<7.2.4.1|>=7.3,<7.3.2.1|>=7.5,<7.5.7.1", - "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.1|>=2011,<2017.12.7.2|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.4.2", + "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.2|>=2011,<2017.12.7.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.5.1", "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3", "ezsystems/repository-forms": ">=2.3,<2.3.2.1", "ezyang/htmlpurifier": "<4.1.1", @@ -1682,9 +1819,12 @@ "magento/magento1ee": ">=1,<1.14.4.3", "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", "marcwillmann/turn": "<0.3.3", + "mediawiki/core": ">=1.31,<1.31.9|>=1.32,<1.32.4|>=1.33,<1.33.3|>=1.34,<1.34.3|>=1.34.99,<1.35", "mittwald/typo3_forum": "<1.2.1", "monolog/monolog": ">=1.8,<1.12", "namshi/jose": "<2.2", + "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6", + "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13", "nystudio107/craft-seomatic": "<3.3", "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1", "october/backend": ">=1.0.319,<1.0.467", @@ -1694,7 +1834,8 @@ "onelogin/php-saml": "<2.10.4", "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5", "openid/php-openid": "<2.3", - "openmage/magento-lts": "<19.4.6|>=20,<20.0.2", + "openmage/magento-lts": "<19.4.8|>=20,<20.0.4", + "orchid/platform": ">=9,<9.4.4", "oro/crm": ">=1.7,<1.7.4", "oro/platform": ">=1.7,<1.7.4", "padraic/humbug_get_contents": "<1.1.2", @@ -1720,6 +1861,7 @@ "privatebin/privatebin": "<1.2.2|>=1.3,<1.3.2", "propel/propel": ">=2-alpha.1,<=2-alpha.7", "propel/propel1": ">=1,<=1.7.1", + "pterodactyl/panel": "<0.7.19|>=1-rc.0,<=1-rc.6", "pusher/pusher-php-server": "<2.2.1", "rainlab/debugbar-plugin": "<3.1", "robrichards/xmlseclibs": "<3.0.4", @@ -1728,8 +1870,8 @@ "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", "sensiolabs/connect": "<4.2.3", "serluck/phpwhois": "<=4.2.6", - "shopware/core": "<=6.3.1", - "shopware/platform": "<=6.3.1", + "shopware/core": "<=6.3.2", + "shopware/platform": "<=6.3.2", "shopware/shopware": "<5.3.7", "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1", "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2", @@ -1762,7 +1904,7 @@ "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", - "sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5", + "sylius/sylius": "<1.6.9|>=1.7,<1.7.9|>=1.8,<1.8.3", "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99", "symbiote/silverstripe-versionedfiles": "<=2.0.3", "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", @@ -1805,6 +1947,7 @@ "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.10|>=3.1,<3.1.7|>=3.2,<3.2.7|>=3.3,<3.3.5", "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4", "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1", + "typo3fluid/fluid": ">=2,<2.0.5|>=2.1,<2.1.4|>=2.2,<2.2.1|>=2.3,<2.3.5|>=2.4,<2.4.1|>=2.5,<2.5.5|>=2.6,<2.6.1", "ua-parser/uap-php": "<3.8", "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2", "verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4", @@ -1878,7 +2021,7 @@ "type": "tidelift" } ], - "time": "2020-09-24T17:02:11+00:00" + "time": "2020-11-01T20:01:47+00:00" }, { "name": "sebastian/code-unit-reverse-lookup", @@ -2492,16 +2635,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.5.6", + "version": "3.5.8", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "e97627871a7eab2f70e59166072a6b767d5834e0" + "reference": "9d583721a7157ee997f235f327de038e7ea6dac4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0", - "reference": "e97627871a7eab2f70e59166072a6b767d5834e0", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/9d583721a7157ee997f235f327de038e7ea6dac4", + "reference": "9d583721a7157ee997f235f327de038e7ea6dac4", "shasum": "" }, "require": { @@ -2544,24 +2687,24 @@ "source": "https://github.com/squizlabs/PHP_CodeSniffer", "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" }, - "time": "2020-08-10T04:50:15+00:00" + "time": "2020-10-23T02:01:07+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.18.1", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "1c302646f6efc070cd46856e600e5e0684d6b454" + "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454", - "reference": "1c302646f6efc070cd46856e600e5e0684d6b454", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41", + "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41", "shasum": "" }, "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "suggest": { "ext-ctype": "For best performance" @@ -2569,7 +2712,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.18-dev" + "dev-main": "1.20-dev" }, "thanks": { "name": "symfony/polyfill", @@ -2607,7 +2750,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.18.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0" }, "funding": [ { @@ -2623,7 +2766,7 @@ "type": "tidelift" } ], - "time": "2020-07-14T12:35:20+00:00" + "time": "2020-10-23T14:02:19+00:00" }, { "name": "theseer/tokenizer", diff --git a/doc/md/Docker.md b/doc/md/Docker.md index c152fe923..fc406c00d 100644 --- a/doc/md/Docker.md +++ b/doc/md/Docker.md @@ -1,3 +1,4 @@ + # Docker [Docker](https://docs.docker.com/get-started/overview/) is an open platform for developing, shipping, and running applications @@ -113,9 +114,11 @@ $ mkdir shaarli && cd shaarli # Download the latest version of Shaarli's docker-compose.yml $ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/latest/docker-compose.yml -o docker-compose.yml # Create the .env file and fill in your VPS and domain information -# (replace and with your actual information) +# (replace , and with your actual information) $ echo 'SHAARLI_VIRTUAL_HOST=shaarli.mydomain.org' > .env $ echo 'SHAARLI_LETSENCRYPT_EMAIL=admin@mydomain.org' >> .env +# Available Docker tags can be found at https://hub.docker.com/r/shaarli/shaarli/tags +$ echo 'SHAARLI_DOCKER_TAG=latest' >> .env # Pull the Docker images $ docker-compose pull # Run! @@ -224,4 +227,4 @@ $ docker system prune - [docker pull](https://docs.docker.com/engine/reference/commandline/pull/) - [docker run](https://docs.docker.com/engine/reference/commandline/run/) - [docker-compose logs](https://docs.docker.com/compose/reference/logs/) -- Træfik: [Getting Started](https://docs.traefik.io/), [Docker backend](https://docs.traefik.io/configuration/backends/docker/), [Let's Encrypt](https://docs.traefik.io/user-guide/docker-and-lets-encrypt/), [Docker image](https://hub.docker.com/_/traefik/) \ No newline at end of file +- Træfik: [Getting Started](https://docs.traefik.io/), [Docker backend](https://docs.traefik.io/configuration/backends/docker/), [Let's Encrypt](https://docs.traefik.io/user-guide/docker-and-lets-encrypt/), [Docker image](https://hub.docker.com/_/traefik/) diff --git a/doc/md/Server-configuration.md b/doc/md/Server-configuration.md index 297d7c291..a49b60334 100644 --- a/doc/md/Server-configuration.md +++ b/doc/md/Server-configuration.md @@ -40,6 +40,8 @@ Supported PHP versions: Version | Status | Shaarli compatibility :---:|:---:|:---: +8.0 | Supported | Yes +7.4 | Supported | Yes 7.3 | Supported | Yes 7.2 | Supported | Yes 7.1 | Supported | Yes @@ -53,7 +55,7 @@ Required PHP extensions: Extension | Required? | Usage ---|:---:|--- -[`openssl`](http://php.net/manual/en/book.openssl.php) | requires | OpenSSL, HTTPS +[`openssl`](http://php.net/manual/en/book.openssl.php) | required | OpenSSL, HTTPS [`php-json`](http://php.net/manual/en/book.json.php) | required | configuration parsing [`php-simplexml`](https://www.php.net/manual/en/book.simplexml.php) | required | REST API (Slim framework) [`php-mbstring`](http://php.net/manual/en/book.mbstring.php) | CentOS, Fedora, RHEL, Windows, some hosting providers | multibyte (Unicode) string support @@ -191,19 +193,24 @@ sudo nano /etc/apache2/sites-available/shaarli.mydomain.org.conf Require all granted - - # Prevent accessing dotfiles - RedirectMatch 404 ".*" - + # BE CAREFUL: directives order matter! - + + Require all denied + + + + Require all granted + + + # allow client-side caching of static files Header set Cache-Control "max-age=2628000, public, must-revalidate, proxy-revalidate" - + + # serve the Shaarli favicon from its custom location Alias favicon.ico /var/www/shaarli.mydomain.org/images/favicon.ico - ``` @@ -294,7 +301,7 @@ server { location / { # default index file when no file URI is requested index index.php; - try_files $uri /index.php$is_args$args; + try_files _ /index.php$is_args$args; } location ~ (index)\.php$ { @@ -307,20 +314,9 @@ server { include fastcgi.conf; } - location ~ \.php$ { - # deny access to all other PHP scripts - # disable this if you host other PHP applications on the same virtualhost - deny all; - } - - location ~ /\. { - # deny access to dotfiles - deny all; - } - - location ~ ~$ { - # deny access to temp editor files, e.g. "script.php~" - deny all; + location ~ /doc/html/ { + default_type "text/html"; + try_files $uri $uri/ $uri.html =404; } location = /favicon.ico { @@ -329,13 +325,12 @@ server { } # allow client-side caching of static files - location ~* \.(?:ico|css|js|gif|jpe?g|png)$ { + location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ { expires max; add_header Cache-Control "public, must-revalidate, proxy-revalidate"; # HTTP 1.0 compatibility add_header Pragma public; } - } ``` @@ -360,7 +355,23 @@ sudo systemctl reload nginx If Shaarli is hosted on a server behind a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) (i.e. there is a proxy server between clients and the web server hosting Shaarli), configure it accordingly. See [Reverse proxy](Reverse-proxy.md) configuration. +## Using Shaarli without URL rewriting + +By default, Shaarli uses Slim framework's URL, which requires +URL rewriting. + +If you can't use URL rewriting for any reason (not supported by +your web server, shared hosting, etc.), you *can* use Shaarli +without URL rewriting. + +You just need to prefix your URL by `/index.php/`. +Example: instead of accessing `https://shaarli.mydomain.org/`, +use `https://shaarli.mydomain.org/index.php/`. +**Recommended:** + * after installation, in the configuration page, set your header link to `/index.php/`. + * in your configuration file `config.json.php` set `general.root_url` to + `https://shaarli.mydomain.org/index.php/`. ## Allow import of large browser bookmarks export @@ -421,7 +432,7 @@ By default Shaarli already disallows indexing of your local copy of the document before = common.conf [Definition] failregex = \s-\s\s-\sLogin failed for user.*$ -ignoreregex = +ignoreregex = ``` ```ini diff --git a/doc/md/Shaarli-configuration.md b/doc/md/Shaarli-configuration.md index 263fb7616..b1326ccee 100644 --- a/doc/md/Shaarli-configuration.md +++ b/doc/md/Shaarli-configuration.md @@ -74,6 +74,7 @@ Some settings can be configured directly from a web browser by accesing the `Too "timezone": "Europe\/Paris", "title": "My Shaarli", "header_link": "?" + "tags_separator": " " }, "dev": { "debug": false, @@ -150,8 +151,10 @@ _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/`. +- **tags_separator**: Defines your tags separator (default: whitespace). ### Security @@ -163,6 +166,22 @@ _These settings should not be edited_ - **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy. - **allowed_protocols**: List of allowed protocols in shaare URLs or markdown-rendered descriptions. Useful if you want to store `javascript:` links (bookmarklets) in Shaarli (default: `["ftp", "ftps", "magnet"]`). +### Formatter + +Single string value. Default available: + + - `default`: supports line breaks, URL and hashtag auto-links. + - `markdown`: supports [Markdown](https://daringfireball.net/projects/markdown/syntax). + - `markdownExtra`: adds [extra](https://michelf.ca/projects/php-markdown/extra/) flavor to Markdown. + +### Formatter Settings + +Additional settings applied to formatters. + +#### default + + - **autolink**: boolean to enable or disable automatic linkification of URL and hashtags. + ### Resources - **data_dir**: Data directory. diff --git a/doc/md/dev/Development.md b/doc/md/dev/Development.md index 5c085e039..c42e8ffef 100644 --- a/doc/md/dev/Development.md +++ b/doc/md/dev/Development.md @@ -6,7 +6,7 @@ Please read [Contributing to Shaarli](https://github.com/shaarli/Shaarli/tree/ma - [Unit tests](Unit-tests) -- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). +- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). Run `make eslint` to check JS style. - [GnuPG signature](GnuPG-signature) for tags/releases @@ -51,12 +51,12 @@ PHP (managed through [`composer.json`](https://github.com/shaarli/Shaarli/blob/m ## Link structure -Every link available through the `LinkDB` object is represented as an array +Every link available through the `LinkDB` object is represented as an array containing the following fields: * `id` (integer): Unique identifier. * `title` (string): Title of the link. - * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.). + * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.). Can be absolute or relative for Notes. * `real_url` (string): Real destination URL, can be redirected, encoded, etc. * `shorturl` (string): Permalink small hash. @@ -66,7 +66,7 @@ containing the following fields: * `thumbnail` (string|boolean): relative path of the thumbnail cache file, or false if there isn't any. * `created` (DateTime): link creation date time. * `updated` (DateTime): last modification date time. - + Small hashes are used to make a link to an entry in Shaarli. They are unique: the date of the item (eg. `20110923_150523`) is hashed with CRC32, then converted to base64 and some characters are replaced. They are always 6 characters longs and use only `A-Z a-z 0-9 - _` and `@`. @@ -163,11 +163,13 @@ See [`.travis.yml`](https://github.com/shaarli/Shaarli/blob/master/.travis.yml). ## Static analysis -Patches should try to stick to the [PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), especially: +Patches should try to stick to the [PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), and must follow: - [PSR-1](http://www.php-fig.org/psr/psr-1/) - Basic Coding Standard - [PSR-2](http://www.php-fig.org/psr/psr-2/) - Coding Style Guide +- [PSR-12](http://www.php-fig.org/psr/psr-12/) - Extended Coding Style Guide +These are enforced on pull requests using our Continuous Integration tools. **Work in progress:** Static analysis is currently being discussed here: in [#95 - Fix coding style (static analysis)](https://github.com/shaarli/Shaarli/issues/95), [#130 - Continuous Integration tools & features](https://github.com/shaarli/Shaarli/issues/130) diff --git a/doc/md/dev/Plugin-system.md b/doc/md/dev/Plugin-system.md index c29774de0..f09fadc29 100644 --- a/doc/md/dev/Plugin-system.md +++ b/doc/md/dev/Plugin-system.md @@ -148,11 +148,16 @@ If a file needs to be included in server end, use simple relative path: `PluginManager::$PLUGINS_PATH . '/mything/template.html'`. If it needs to be included in front end side (e.g. an image), -the relative path must be prefixed with special data `_BASE_PATH_`: -`($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH . '/mything/picture.png`. +the relative path must be prefixed with special data: + + * if it's a link that will need to be processed by Shaarli, use `_BASE_PATH_`: + for e.g. `$data['_BASE_PATH_'] . '/admin/tools`. + * if you want to include an asset, you need to add the root URL (base path without `/index.php`, for people using Shaarli without URL rewriting), then use `_ROOT_PATH_`: + for e.g +`$['_ROOT_PATH_'] . '/' . PluginManager::$PLUGINS_PATH . '/mything/picture.png`. Note that special placeholders for CSS and JS files (respectively `css_files` and `js_files`) are already prefixed -with the base path in template files. +with the root path in template files. ### It's not working! diff --git a/docker-compose.yml b/docker-compose.yml index a3de4b1c4..4ebae447e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,12 +2,13 @@ # Shaarli - Docker Compose example configuration # # See: -# - https://shaarli.readthedocs.io/en/master/docker/shaarli-images/ -# - https://shaarli.readthedocs.io/en/master/guides/install-shaarli-with-debian9-and-docker/ +# - https://shaarli.readthedocs.io/en/master/Docker/#docker-compose # # Environment variables: # - SHAARLI_VIRTUAL_HOST Fully Qualified Domain Name for the Shaarli instance # - SHAARLI_LETSENCRYPT_EMAIL Contact email for certificate renewal +# - SHAARLI_DOCKER_TAG Shaarli docker tag to use +# See: https://hub.docker.com/r/shaarli/shaarli/tags version: '3' networks: @@ -20,7 +21,7 @@ volumes: services: shaarli: - image: shaarli/shaarli:master + image: shaarli/shaarli:${SHAARLI_DOCKER_TAG} build: ./ networks: - http-proxy @@ -40,7 +41,7 @@ services: - "--entrypoints=Name:https Address::443 TLS" - "--retry" - "--docker" - - "--docker.domain=docker.localhost" + - "--docker.domain=${SHAARLI_VIRTUAL_HOST}" - "--docker.exposedbydefault=true" - "--docker.watch=true" - "--acme" diff --git a/inc/languages/fr/LC_MESSAGES/shaarli.po b/inc/languages/fr/LC_MESSAGES/shaarli.po index 9a6e3958c..26dede4e2 100644 --- a/inc/languages/fr/LC_MESSAGES/shaarli.po +++ b/inc/languages/fr/LC_MESSAGES/shaarli.po @@ -1,8 +1,8 @@ msgid "" msgstr "" "Project-Id-Version: Shaarli\n" -"POT-Creation-Date: 2020-09-10 16:06+0200\n" -"PO-Revision-Date: 2020-09-10 16:07+0200\n" +"POT-Creation-Date: 2020-11-09 14:39+0100\n" +"PO-Revision-Date: 2020-11-09 14:42+0100\n" "Last-Translator: \n" "Language-Team: Shaarli\n" "Language: fr_FR\n" @@ -20,38 +20,11 @@ msgstr "" "X-Poedit-SearchPath-3: init.php\n" "X-Poedit-SearchPath-4: plugins\n" -#: application/ApplicationUtils.php:161 -#, php-format -msgid "" -"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus " -"cannot run. Your PHP version has known security vulnerabilities and should " -"be updated as soon as possible." -msgstr "" -"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne " -"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités " -"connues et devrait être mise à jour au plus tôt." - -#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204 -msgid "directory is not readable" -msgstr "le répertoire n'est pas accessible en lecture" - -#: application/ApplicationUtils.php:207 -msgid "directory is not writable" -msgstr "le répertoire n'est pas accessible en écriture" - -#: application/ApplicationUtils.php:225 -msgid "file is not readable" -msgstr "le fichier n'est pas accessible en lecture" - -#: application/ApplicationUtils.php:228 -msgid "file is not writable" -msgstr "le fichier n'est pas accessible en écriture" - -#: application/History.php:179 +#: application/History.php:180 msgid "History file isn't readable or writable" msgstr "Le fichier d'historique n'est pas accessible en lecture ou en écriture" -#: application/History.php:190 +#: application/History.php:191 msgid "Could not parse history file" msgstr "Format incorrect pour le fichier d'historique" @@ -83,52 +56,46 @@ msgstr "" "l'extension php-gd doit être chargée pour utiliser les miniatures. Les " "miniatures sont désormais désactivées. Rechargez la page." -#: application/Utils.php:383 +#: application/Utils.php:402 msgid "Setting not set" msgstr "Paramètre non défini" -#: application/Utils.php:390 +#: application/Utils.php:409 msgid "Unlimited" msgstr "Illimité" -#: application/Utils.php:393 +#: application/Utils.php:412 msgid "B" msgstr "o" -#: application/Utils.php:393 +#: application/Utils.php:412 msgid "kiB" msgstr "ko" -#: application/Utils.php:393 +#: application/Utils.php:412 msgid "MiB" msgstr "Mo" -#: application/Utils.php:393 +#: application/Utils.php:412 msgid "GiB" msgstr "Go" -#: application/bookmark/BookmarkFileService.php:174 -#: application/bookmark/BookmarkFileService.php:199 -#: application/bookmark/BookmarkFileService.php:224 +#: application/bookmark/BookmarkFileService.php:183 +#: application/bookmark/BookmarkFileService.php:205 +#: application/bookmark/BookmarkFileService.php:227 #: application/bookmark/BookmarkFileService.php:241 msgid "You're not authorized to alter the datastore" msgstr "Vous n'êtes pas autorisé à modifier les données" -#: application/bookmark/BookmarkFileService.php:177 -#: application/bookmark/BookmarkFileService.php:202 -#: application/bookmark/BookmarkFileService.php:244 -msgid "Provided data is invalid" -msgstr "Les informations fournies ne sont pas valides" - -#: application/bookmark/BookmarkFileService.php:205 +#: application/bookmark/BookmarkFileService.php:208 msgid "This bookmarks already exists" -msgstr "Ce marque-page existe déjà." +msgstr "Ce marque-page existe déjà" -#: application/bookmark/BookmarkInitializer.php:37 +#: application/bookmark/BookmarkInitializer.php:39 msgid "(private bookmark with thumbnail demo)" msgstr "(marque page privé avec une miniature)" -#: application/bookmark/BookmarkInitializer.php:40 +#: application/bookmark/BookmarkInitializer.php:42 msgid "" "Shaarli will automatically pick up the thumbnail for links to a variety of " "websites.\n" @@ -151,11 +118,11 @@ msgstr "" "\n" "Maintenant, vous pouvez modifier ou supprimer les shaares créés par défaut.\n" -#: application/bookmark/BookmarkInitializer.php:53 +#: application/bookmark/BookmarkInitializer.php:55 msgid "Note: Shaare descriptions" msgstr "Note : Description des Shaares" -#: application/bookmark/BookmarkInitializer.php:55 +#: application/bookmark/BookmarkInitializer.php:57 msgid "" "Adding a shaare without entering a URL creates a text-only \"note\" post " "such as this one.\n" @@ -219,19 +186,19 @@ msgstr "" "| Citron | Fruit | Jaune | 30 |\n" "| Carotte | Légume | Orange | 14 |\n" -#: application/bookmark/BookmarkInitializer.php:89 +#: application/bookmark/BookmarkInitializer.php:91 #: application/legacy/LegacyLinkDB.php:246 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49 +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:49 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48 msgid "" "The personal, minimalist, super-fast, database free, bookmarking service" msgstr "" "Le gestionnaire de marque-pages personnel, minimaliste, et sans base de " "données" -#: application/bookmark/BookmarkInitializer.php:92 +#: application/bookmark/BookmarkInitializer.php:94 msgid "" "Welcome to Shaarli!\n" "\n" @@ -320,7 +287,8 @@ msgid "Direct link" msgstr "Liens directs" #: application/feed/FeedBuilder.php:181 -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96 +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103 +#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179 msgid "Permalink" msgstr "Permalien" @@ -336,12 +304,13 @@ msgid "You have enabled or changed thumbnails mode." msgstr "Vous avez activé ou changé le mode de miniatures." #: application/front/controller/admin/ConfigureController.php:103 +#: application/front/controller/admin/ServerController.php:75 #: application/legacy/LegacyUpdater.php:538 msgid "Please synchronize them." msgstr "Merci de les synchroniser." #: application/front/controller/admin/ConfigureController.php:113 -#: application/front/controller/visitor/InstallController.php:136 +#: application/front/controller/visitor/InstallController.php:146 msgid "Error while writing config file after configuration update." msgstr "" "Une erreur s'est produite lors de la sauvegarde du fichier de configuration." @@ -378,70 +347,47 @@ msgstr "" "le serveur web peut accepter (%s). Merci de l'envoyer en parties plus " "légères." -#: application/front/controller/admin/ManageShaareController.php:29 -#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 -msgid "Shaare a new link" -msgstr "Partager un nouveau lien" - -#: application/front/controller/admin/ManageShaareController.php:78 -msgid "Note: " -msgstr "Note : " - -#: application/front/controller/admin/ManageShaareController.php:109 -#: application/front/controller/admin/ManageShaareController.php:206 -#: application/front/controller/admin/ManageShaareController.php:275 -#: application/front/controller/admin/ManageShaareController.php:315 -#, php-format -msgid "Bookmark with identifier %s could not be found." -msgstr "Le lien avec l'identifiant %s n'a pas pu être trouvé." - -#: application/front/controller/admin/ManageShaareController.php:194 -#: application/front/controller/admin/ManageShaareController.php:252 -msgid "Invalid bookmark ID provided." -msgstr "ID du lien non valide." +#: application/front/controller/admin/ManageTagController.php:30 +msgid "whitespace" +msgstr "espace" -#: application/front/controller/admin/ManageShaareController.php:260 -msgid "Invalid visibility provided." -msgstr "Visibilité du lien non valide." - -#: application/front/controller/admin/ManageShaareController.php:363 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171 -msgid "Edit" -msgstr "Modifier" - -#: application/front/controller/admin/ManageShaareController.php:366 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28 -msgid "Shaare" -msgstr "Shaare" - -#: application/front/controller/admin/ManageTagController.php:29 +#: application/front/controller/admin/ManageTagController.php:35 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 msgid "Manage tags" msgstr "Gérer les tags" -#: application/front/controller/admin/ManageTagController.php:48 +#: application/front/controller/admin/ManageTagController.php:54 msgid "Invalid tags provided." msgstr "Les tags fournis ne sont pas valides." -#: application/front/controller/admin/ManageTagController.php:72 +#: application/front/controller/admin/ManageTagController.php:78 #, php-format msgid "The tag was removed from %d bookmark." msgid_plural "The tag was removed from %d bookmarks." msgstr[0] "Le tag a été supprimé du %d lien." msgstr[1] "Le tag a été supprimé de %d liens." -#: application/front/controller/admin/ManageTagController.php:77 +#: application/front/controller/admin/ManageTagController.php:83 #, php-format msgid "The tag was renamed in %d bookmark." msgid_plural "The tag was renamed in %d bookmarks." msgstr[0] "Le tag a été renommé dans %d lien." msgstr[1] "Le tag a été renommé dans %d liens." +#: application/front/controller/admin/ManageTagController.php:105 +msgid "Tags separator must be a single character." +msgstr "Un séparateur de tags doit contenir un seul caractère." + +#: application/front/controller/admin/ManageTagController.php:111 +msgid "These characters are reserved and can't be used as tags separator: " +msgstr "" +"Ces caractères sont réservés et ne peuvent être utilisés comme des " +"séparateurs de tags : " + #: application/front/controller/admin/PasswordController.php:28 #: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 msgid "Change password" msgstr "Modifier le mot de passe" @@ -463,16 +409,71 @@ msgstr "Votre mot de passe a été modifié" msgid "Plugin Administration" msgstr "Administration des plugins" -#: application/front/controller/admin/PluginsController.php:75 +#: application/front/controller/admin/PluginsController.php:76 msgid "Setting successfully saved." msgstr "Les paramètres ont été sauvegardés avec succès." -#: application/front/controller/admin/PluginsController.php:78 +#: application/front/controller/admin/PluginsController.php:79 msgid "Error while saving plugin configuration: " msgstr "" "Une erreur s'est produite lors de la sauvegarde de la configuration des " "plugins : " +#: application/front/controller/admin/ServerController.php:35 +msgid "Check disabled" +msgstr "Vérification désactivée" + +#: application/front/controller/admin/ServerController.php:57 +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 +msgid "Server administration" +msgstr "Administration serveur" + +#: application/front/controller/admin/ServerController.php:74 +msgid "Thumbnails cache has been cleared." +msgstr "Le cache des miniatures a été vidé." + +#: application/front/controller/admin/ServerController.php:83 +msgid "Shaarli's cache folder has been cleared!" +msgstr "Le dossier de cache de Shaarli a été vidé !" + +#: application/front/controller/admin/ShaareAddController.php:26 +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 +msgid "Shaare a new link" +msgstr "Partagez un nouveau lien" + +#: application/front/controller/admin/ShaareManageController.php:35 +#: application/front/controller/admin/ShaareManageController.php:93 +msgid "Invalid bookmark ID provided." +msgstr "L'ID du marque-page fourni n'est pas valide." + +#: application/front/controller/admin/ShaareManageController.php:47 +#: application/front/controller/admin/ShaareManageController.php:116 +#: application/front/controller/admin/ShaareManageController.php:156 +#: application/front/controller/admin/ShaarePublishController.php:82 +#, php-format +msgid "Bookmark with identifier %s could not be found." +msgstr "Le lien avec l'identifiant %s n'a pas pu être trouvé." + +#: application/front/controller/admin/ShaareManageController.php:101 +msgid "Invalid visibility provided." +msgstr "Visibilité du lien non valide." + +#: application/front/controller/admin/ShaarePublishController.php:171 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171 +msgid "Edit" +msgstr "Modifier" + +#: application/front/controller/admin/ShaarePublishController.php:174 +#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28 +msgid "Shaare" +msgstr "Shaare" + +#: application/front/controller/admin/ShaarePublishController.php:205 +msgid "Note: " +msgstr "Note : " + #: application/front/controller/admin/ThumbnailsController.php:37 #: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 msgid "Thumbnails update" @@ -484,29 +485,62 @@ msgstr "Mise à jour des miniatures" msgid "Tools" msgstr "Outils" -#: application/front/controller/visitor/BookmarkListController.php:115 +#: application/front/controller/visitor/BookmarkListController.php:120 msgid "Search: " msgstr "Recherche : " -#: application/front/controller/visitor/DailyController.php:45 -msgid "Today" -msgstr "Aujourd'hui" - -#: application/front/controller/visitor/DailyController.php:47 -msgid "Yesterday" -msgstr "Hier" +#: application/front/controller/visitor/DailyController.php:200 +msgid "day" +msgstr "jour" -#: application/front/controller/visitor/DailyController.php:85 +#: application/front/controller/visitor/DailyController.php:200 +#: application/front/controller/visitor/DailyController.php:203 +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 #: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48 msgid "Daily" msgstr "Quotidien" -#: application/front/controller/visitor/ErrorController.php:36 +#: application/front/controller/visitor/DailyController.php:201 +msgid "week" +msgstr "semaine" + +#: application/front/controller/visitor/DailyController.php:201 +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 +msgid "Weekly" +msgstr "Hebdomadaire" + +#: application/front/controller/visitor/DailyController.php:202 +msgid "month" +msgstr "mois" + +#: application/front/controller/visitor/DailyController.php:202 +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 +msgid "Monthly" +msgstr "Mensuel" + +#: application/front/controller/visitor/ErrorController.php:30 +msgid "Error: " +msgstr "Erreur : " + +#: application/front/controller/visitor/ErrorController.php:34 +msgid "Please report it on Github." +msgstr "Merci de la rapporter sur Github." + +#: application/front/controller/visitor/ErrorController.php:39 msgid "An unexpected error occurred." msgstr "Une erreur inattendue s'est produite." -#: application/front/controller/visitor/InstallController.php:73 +#: application/front/controller/visitor/ErrorNotFoundController.php:25 +msgid "Requested page could not be found." +msgstr "La page demandée n'a pas pu être trouvée." + +#: application/front/controller/visitor/InstallController.php:64 +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22 +msgid "Install Shaarli" +msgstr "Installation de Shaarli" + +#: application/front/controller/visitor/InstallController.php:83 #, php-format msgid "" "
Sessions do not seem to work correctly on your server.
Make sure the " @@ -525,14 +559,14 @@ msgstr "" "des cookies. Nous vous recommandons d'accéder à votre serveur depuis son " "adresse IP ou un Fully Qualified Domain Name.
" -#: application/front/controller/visitor/InstallController.php:144 +#: application/front/controller/visitor/InstallController.php:154 msgid "" "Shaarli is now configured. Please login and start shaaring your bookmarks!" msgstr "" "Shaarli est maintenant configuré. Vous pouvez vous connecter et commencez à " "shaare vos liens !" -#: application/front/controller/visitor/InstallController.php:158 +#: application/front/controller/visitor/InstallController.php:168 msgid "Insufficient permissions:" msgstr "Permissions insuffisantes :" @@ -546,7 +580,7 @@ msgstr "Permissions insuffisantes :" msgid "Login" msgstr "Connexion" -#: application/front/controller/visitor/LoginController.php:78 +#: application/front/controller/visitor/LoginController.php:77 msgid "Wrong login/password." msgstr "Nom d'utilisateur ou mot de passe incorrect(s)." @@ -556,11 +590,9 @@ msgstr "Nom d'utilisateur ou mot de passe incorrect(s)." msgid "Picture wall" msgstr "Mur d'images" -#: application/front/controller/visitor/TagCloudController.php:80 -#, fuzzy -#| msgid "Tag list" +#: application/front/controller/visitor/TagCloudController.php:90 msgid "Tag " -msgstr "Liste des tags" +msgstr "Tag " #: application/front/exceptions/AlreadyInstalledException.php:11 msgid "Shaarli has already been installed. Login to edit the configuration." @@ -588,6 +620,86 @@ msgstr "" msgid "Wrong token." msgstr "Jeton invalide." +#: application/helper/ApplicationUtils.php:162 +#, php-format +msgid "" +"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus " +"cannot run. Your PHP version has known security vulnerabilities and should " +"be updated as soon as possible." +msgstr "" +"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne " +"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités " +"connues et devrait être mise à jour au plus tôt." + +#: application/helper/ApplicationUtils.php:195 +#: application/helper/ApplicationUtils.php:215 +msgid "directory is not readable" +msgstr "le répertoire n'est pas accessible en lecture" + +#: application/helper/ApplicationUtils.php:218 +msgid "directory is not writable" +msgstr "le répertoire n'est pas accessible en écriture" + +#: application/helper/ApplicationUtils.php:240 +msgid "file is not readable" +msgstr "le fichier n'est pas accessible en lecture" + +#: application/helper/ApplicationUtils.php:243 +msgid "file is not writable" +msgstr "le fichier n'est pas accessible en écriture" + +#: application/helper/ApplicationUtils.php:277 +msgid "Configuration parsing" +msgstr "Chargement de la configuration" + +#: application/helper/ApplicationUtils.php:278 +msgid "Slim Framework (routing, etc.)" +msgstr "Slim Framwork (routage, etc.)" + +#: application/helper/ApplicationUtils.php:279 +msgid "Multibyte (Unicode) string support" +msgstr "Support des chaînes de caractère multibytes (Unicode)" + +#: application/helper/ApplicationUtils.php:280 +msgid "Required to use thumbnails" +msgstr "Obligatoire pour utiliser les miniatures" + +#: application/helper/ApplicationUtils.php:281 +msgid "Localized text sorting (e.g. e->è->f)" +msgstr "Tri des textes traduits (ex : e->è->f)" + +#: application/helper/ApplicationUtils.php:282 +msgid "Better retrieval of bookmark metadata and thumbnail" +msgstr "Meilleure récupération des meta-données des marque-pages et minatures" + +#: application/helper/ApplicationUtils.php:283 +msgid "Use the translation system in gettext mode" +msgstr "Utiliser le système de traduction en mode gettext" + +#: application/helper/ApplicationUtils.php:284 +msgid "Login using LDAP server" +msgstr "Authentification via un serveur LDAP" + +#: application/helper/DailyPageHelper.php:172 +msgid "Week" +msgstr "Semaine" + +#: application/helper/DailyPageHelper.php:176 +msgid "Today" +msgstr "Aujourd'hui" + +#: application/helper/DailyPageHelper.php:178 +msgid "Yesterday" +msgstr "Hier" + +#: application/helper/FileUtils.php:100 +msgid "Provided path is not a directory." +msgstr "Le chemin fourni n'est pas un dossier." + +#: application/helper/FileUtils.php:104 +msgid "Trying to delete a folder outside of Shaarli path." +msgstr "Tentative de supprimer un dossier en dehors du chemin de Shaarli." + #: application/legacy/LegacyLinkDB.php:131 msgid "You are not authorized to add a link." msgstr "Vous n'êtes pas autorisé à ajouter un lien." @@ -664,7 +776,7 @@ msgstr "" "a été importé avec succès en %d secondes : %d liens importés, %d liens " "écrasés, %d liens ignorés." -#: application/plugin/PluginManager.php:122 +#: application/plugin/PluginManager.php:124 msgid " [plugin incompatibility]: " msgstr " [incompatibilité de l'extension] : " @@ -682,7 +794,7 @@ msgstr "Impossible de purger %s : le répertoire n'existe pas" msgid "An error occurred while running the update " msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour " -#: index.php:62 +#: index.php:80 msgid "Shared bookmarks on " msgstr "Liens partagés sur " @@ -699,11 +811,11 @@ msgstr "Shaare" msgid "Adds the addlink input on the linklist page." msgstr "Ajoute le formulaire d'ajout de liens sur la page principale." -#: plugins/archiveorg/archiveorg.php:26 +#: plugins/archiveorg/archiveorg.php:28 msgid "View on archive.org" msgstr "Voir sur archive.org" -#: plugins/archiveorg/archiveorg.php:39 +#: plugins/archiveorg/archiveorg.php:41 msgid "For each link, add an Archive.org icon." msgstr "Pour chaque lien, ajoute une icône pour Archive.org." @@ -823,7 +935,7 @@ msgstr "Mauvaise réponse du hub %s" msgid "Enable PubSubHubbub feed publishing." msgstr "Active la publication de flux vers PubSubHubbub." -#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70 +#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:71 msgid "For each link, add a QRCode icon." msgstr "Pour chaque lien, ajouter une icône de QRCode." @@ -835,15 +947,15 @@ msgstr "" "Erreur de l'extension Wallabag : Merci de définir le paramètre « " "WALLABAG_URL » dans la page d'administration des extensions." -#: plugins/wallabag/wallabag.php:47 +#: plugins/wallabag/wallabag.php:48 msgid "Save to wallabag" msgstr "Sauvegarder dans Wallabag" -#: plugins/wallabag/wallabag.php:71 +#: plugins/wallabag/wallabag.php:72 msgid "Wallabag API URL" msgstr "URL de l'API Wallabag" -#: plugins/wallabag/wallabag.php:72 +#: plugins/wallabag/wallabag.php:73 msgid "Wallabag API version (1 or 2)" msgstr "Version de l'API Wallabag (1 ou 2)" @@ -855,6 +967,48 @@ msgstr "Désolé, il y a rien à voir ici." msgid "URL or leave empty to post a note" msgstr "URL ou laisser vide pour créer une note" +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 +msgid "BULK CREATION" +msgstr "CRÉATION DE MASSE" + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40 +msgid "Metadata asynchronous retrieval is disabled." +msgstr "La récupération asynchrone des meta-données est désactivée." + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 +msgid "" +"We recommend that you enable the setting general > " +"enable_async_metadata in your configuration file to use bulk link " +"creation." +msgstr "" +"Nous recommandons d'activer le paramètre general > " +"enable_async_metadata dans votre fichier de configuration pour utiliser " +"la création de masse." + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56 +msgid "Shaare multiple new links" +msgstr "Partagez plusieurs nouveaux liens" + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59 +msgid "Add one URL per line to create multiple bookmarks." +msgstr "Ajouter une URL par ligne pour créer plusieurs marque-pages." + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67 +msgid "Tags" +msgstr "Tags" + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83 +#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169 +msgid "Private" +msgstr "Privé" + +#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78 +msgid "Add links" +msgstr "Ajouter des liens" + #: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 msgid "Current password" msgstr "Mot de passe actuel" @@ -881,26 +1035,48 @@ msgid "Case sensitive" msgstr "Sensible à la casse" #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 -msgid "Rename" -msgstr "Renommer" +#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68 +msgid "Rename tag" +msgstr "Renommer le tag" #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:93 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147 -#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67 -msgid "Delete" -msgstr "Supprimer" +msgid "Delete tag" +msgstr "Supprimer le tag" -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40 msgid "You can also edit tags in the" msgstr "Vous pouvez aussi modifier les tags dans la" -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40 msgid "tag list" msgstr "liste des tags" +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 +msgid "Change tags separator" +msgstr "Changer le séparateur de tags" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50 +msgid "Your current tag separator is" +msgstr "Votre séparateur actuel est" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53 +msgid "New separator" +msgstr "Nouveau séparateur" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58 +#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139 +#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199 +msgid "Save" +msgstr "Enregistrer" + +#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61 +msgid "Note that hashtags won't fully work with a non-whitespace separator." +msgstr "" +"Notez que les hashtags ne sont pas complètement fonctionnels avec un " +"séparateur qui n'est pas un espace." + #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 msgid "title" msgstr "titre" @@ -1024,71 +1200,72 @@ msgstr "" "miniatures." #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56 +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122 msgid "Synchronize thumbnails" msgstr "Synchroniser les miniatures" #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30 +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102 msgid "All" msgstr "Tous" #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343 +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106 msgid "Only common media hosts" msgstr "Seulement les hébergeurs de média connus" #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347 +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110 msgid "None" msgstr "Aucune" -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355 -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199 -msgid "Save" -msgstr "Enregistrer" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -msgid "The Daily Shaarli" -msgstr "Le Quotidien Shaarli" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17 -msgid "1 RSS entry per day" -msgstr "1 entrée RSS par jour" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37 -msgid "Previous day" -msgstr "Jour précédent" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 -msgid "All links of one day in a single page." -msgstr "Tous les liens d'un jour sur une page." - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51 -msgid "Next day" -msgstr "Jour suivant" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18 +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26 +msgid "1 RSS entry per :type" +msgid_plural "" +msgstr[0] "1 entrée RSS par :type" +msgstr[1] "" + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49 +msgid "Previous :type" +msgid_plural "" +msgstr[0] ":type précédent" +msgstr[1] "Jour précédent" + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56 +#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7 +msgid "All links of one :type in a single page." +msgid_plural "" +msgstr[0] "Tous les liens d'un :type sur une page." +msgstr[1] "Tous les liens d'un jour sur une page." + +#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63 +msgid "Next :type" +msgid_plural "" +msgstr[0] ":type suivant" +msgstr[1] "" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30 msgid "Edit Shaare" msgstr "Modifier le Shaare" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30 msgid "New Shaare" msgstr "Nouveau Shaare" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38 msgid "Created:" msgstr "Création :" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 msgid "URL" msgstr "URL" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 msgid "Title" msgstr "Titre" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99 @@ -1096,32 +1273,39 @@ msgstr "Titre" msgid "Description" msgstr "Description" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 -msgid "Tags" -msgstr "Tags" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60 -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169 -msgid "Private" -msgstr "Privé" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89 msgid "Description will be rendered with" msgstr "La description sera générée avec" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91 msgid "Markdown syntax documentation" msgstr "Documentation sur la syntaxe Markdown" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92 msgid "Markdown syntax" msgstr "la syntaxe Markdown" -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:115 +msgid "Cancel" +msgstr "Annuler" + +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121 msgid "Apply Changes" msgstr "Appliquer les changements" +#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:126 +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173 +#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147 +#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147 +#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67 +msgid "Delete" +msgstr "Supprimer" + +#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21 +#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32 +msgid "Save all" +msgstr "Tout enregistrer" + #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 msgid "Export Database" msgstr "Exporter les données" @@ -1179,10 +1363,6 @@ msgstr "Les doublons s'appuient sur les URL" msgid "Add default tags" msgstr "Ajouter des tags par défaut" -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22 -msgid "Install Shaarli" -msgstr "Installation de Shaarli" - #: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25 msgid "It looks like it's the first time you run Shaarli. Please configure it." msgstr "" @@ -1215,6 +1395,10 @@ msgstr "Mes liens" msgid "Install" msgstr "Installer" +#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190 +msgid "Server requirements" +msgstr "Pré-requis serveur" + #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79 msgid "shaare" @@ -1288,8 +1472,8 @@ msgid "without any tag" msgstr "sans tag" #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43 +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:41 msgid "Fold" msgstr "Replier" @@ -1313,6 +1497,10 @@ msgstr "Changer statut épinglé" msgid "Sticky" msgstr "Épinglé" +#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189 +msgid "Share a private link" +msgstr "Partager un lien privé" + #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5 msgid "Filters" @@ -1331,7 +1519,7 @@ msgstr "Afficher uniquement les liens publics" #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18 msgid "Filter untagged links" -msgstr "Filtrer par liens privés" +msgstr "Filtrer par liens sans tag" #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24 @@ -1342,8 +1530,8 @@ msgstr "Tout sélectionner" #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:29 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:89 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44 +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42 msgid "Fold all" msgstr "Replier tout" @@ -1359,9 +1547,9 @@ msgid "Remember me" msgstr "Rester connecté" #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49 +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:49 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48 msgid "by the Shaarli community" msgstr "par la communauté Shaarli" @@ -1370,21 +1558,26 @@ msgstr "par la communauté Shaarli" msgid "Documentation" msgstr "Documentation" -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45 +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43 msgid "Expand" msgstr "Déplier" -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46 +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44 msgid "Expand all" msgstr "Déplier tout" -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:47 +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45 msgid "Are you sure you want to delete this link?" msgstr "Êtes-vous sûr de vouloir supprimer ce lien ?" +#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46 +#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46 +msgid "Are you sure you want to delete this tag?" +msgstr "Êtes-vous sûr de vouloir supprimer ce tag ?" + #: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:11 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:11 msgid "Menu" @@ -1511,6 +1704,100 @@ msgstr "Configuration des extensions" msgid "No parameter available." msgstr "Aucun paramètre disponible." +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 +msgid "General" +msgstr "Général" + +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20 +msgid "Index URL" +msgstr "URL de l'index" + +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 +msgid "Base path" +msgstr "Chemin de base" + +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 +msgid "Client IP" +msgstr "IP du client" + +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 +msgid "Trusted reverse proxies" +msgstr "Reverse proxies de confiance" + +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58 +msgid "N/A" +msgstr "N/A" + +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84 +msgid "Visit releases page on Github" +msgstr "Visiter la page des releases sur Github" + +#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121 +msgid "Synchronize all link thumbnails" +msgstr "Synchroniser toutes les miniatures" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:2 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:2 +msgid "Permissions" +msgstr "Permissions" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:8 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:8 +msgid "There are permissions that need to be fixed." +msgstr "Il y a des permissions qui doivent être corrigées." + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:23 +msgid "All read/write permissions are properly set." +msgstr "Toutes les permissions de lecture/écriture sont définies correctement." + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:32 +msgid "Running PHP" +msgstr "Fonctionnant avec PHP" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:36 +msgid "End of life: " +msgstr "Fin de vie : " + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:48 +msgid "Extension" +msgstr "Extension" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:49 +msgid "Usage" +msgstr "Utilisation" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:50 +msgid "Status" +msgstr "Statut" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51 +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:51 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:66 +msgid "Loaded" +msgstr "Chargé" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60 +msgid "Required" +msgstr "Obligatoire" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60 +msgid "Optional" +msgstr "Optionnel" + +#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70 +#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:70 +msgid "Not loaded" +msgstr "Non chargé" + #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 msgid "tags" @@ -1525,10 +1812,6 @@ msgstr "Lister tous les liens avec ces tags" msgid "Tag list" msgstr "Liste des tags" -#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68 -msgid "Rename tag" -msgstr "Renommer le tag" - #: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3 #: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3 msgid "Sort by:" @@ -1565,15 +1848,19 @@ msgstr "Configurer Shaarli" msgid "Enable, disable and configure plugins" msgstr "Activer, désactiver et configurer les extensions" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27 +msgid "Check instance's server configuration" +msgstr "Vérifier la configuration serveur de l'instance" + +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 msgid "Change your password" msgstr "Modifier le mot de passe" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 msgid "Rename or delete a tag in all links" msgstr "Renommer ou supprimer un tag dans tous les liens" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 msgid "" "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, " "delicious...)" @@ -1581,11 +1868,11 @@ msgstr "" "Importer des marques pages au format Netscape HTML (comme exportés depuis " "Firefox, Chrome, Opera, delicious...)" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 msgid "Import links" msgstr "Importer des liens" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53 msgid "" "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, " "Opera, delicious...)" @@ -1593,15 +1880,11 @@ msgstr "" "Exporter les marques pages au format Netscape HTML (comme exportés depuis " "Firefox, Chrome, Opera, delicious...)" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54 msgid "Export database" msgstr "Exporter les données" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:55 -msgid "Synchronize all link thumbnails" -msgstr "Synchroniser toutes les miniatures" - -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77 msgid "" "Drag one of these button to your bookmarks toolbar or right-click it and " "\"Bookmark This Link\"" @@ -1609,13 +1892,13 @@ msgstr "" "Glisser un de ces boutons dans votre barre de favoris ou cliquer droit " "dessus et « Ajouter aux favoris »" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78 msgid "then click on the bookmarklet in any page you want to share." msgstr "" "puis cliquer sur le marque-page depuis un site que vous souhaitez partager." -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106 msgid "" "Drag this link to your bookmarks toolbar or right-click it and Bookmark This " "Link" @@ -1623,40 +1906,40 @@ msgstr "" "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « " "Ajouter aux favoris »" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83 msgid "then click ✚Shaare link button in any page you want to share" msgstr "puis cliquer sur ✚Shaare depuis un site que vous souhaitez partager" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114 msgid "The selected text is too long, it will be truncated." msgstr "Le texte sélectionné est trop long, il sera tronqué." -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102 msgid "Shaare link" msgstr "Shaare" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:107 msgid "" "Then click ✚Add Note button anytime to start composing a private Note (text " "post) to your Shaarli" msgstr "" "Puis cliquer sur ✚Add Note pour commencer à rédiger une Note sur Shaarli" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123 msgid "Add Note" msgstr "Ajouter une Note" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:132 msgid "3rd party" msgstr "Applications tierces" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:140 msgid "plugin" msgstr "extension" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169 +#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165 msgid "" "Drag this link to your bookmarks toolbar, or right-click it and choose " "Bookmark This Link" @@ -1664,6 +1947,12 @@ msgstr "" "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « " "Ajouter aux favoris »" +#~ msgid "Display:" +#~ msgstr "Afficher :" + +#~ msgid "The Daily Shaarli" +#~ msgstr "Le Quotidien Shaarli" + #, fuzzy #~| msgid "Selection" #~ msgid ".ui-selecting" diff --git a/inc/languages/jp/LC_MESSAGES/shaarli.po b/inc/languages/jp/LC_MESSAGES/shaarli.po index b420bb519..57f42fc2a 100644 --- a/inc/languages/jp/LC_MESSAGES/shaarli.po +++ b/inc/languages/jp/LC_MESSAGES/shaarli.po @@ -2,15 +2,15 @@ msgid "" msgstr "" "Project-Id-Version: Shaarli\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2020-02-11 09:31+0900\n" -"PO-Revision-Date: 2020-02-11 10:54+0900\n" +"POT-Creation-Date: 2020-10-19 10:19+0900\n" +"PO-Revision-Date: 2020-10-19 10:25+0900\n" "Last-Translator: yude \n" "Language-Team: Shaarli\n" "Language: ja\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 2.3\n" +"X-Generator: Poedit 2.2.3\n" "X-Poedit-Basepath: ../../../..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" @@ -19,7 +19,7 @@ msgstr "" "X-Poedit-SearchPathExcluded-0: node_modules\n" "X-Poedit-SearchPathExcluded-1: vendor\n" -#: application/ApplicationUtils.php:153 +#: application/ApplicationUtils.php:161 #, php-format msgid "" "Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus " @@ -30,200 +30,250 @@ msgstr "" "が必要です。 現在使用している PHP のバージョンには脆弱性があり、できるだけ速" "やかにアップデートするべきです。" -#: application/ApplicationUtils.php:183 application/ApplicationUtils.php:195 +#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204 msgid "directory is not readable" msgstr "ディレクトリを読み込めません" -#: application/ApplicationUtils.php:198 +#: application/ApplicationUtils.php:207 msgid "directory is not writable" msgstr "ディレクトリに書き込めません" -#: application/ApplicationUtils.php:216 +#: application/ApplicationUtils.php:225 msgid "file is not readable" msgstr "ファイルを読み取る権限がありません" -#: application/ApplicationUtils.php:219 +#: application/ApplicationUtils.php:228 msgid "file is not writable" msgstr "ファイルを書き込む権限がありません" -#: application/Cache.php:16 -#, php-format -msgid "Cannot purge %s: no directory" -msgstr "%s を削除できません: ディレクトリが存在しません" - -#: application/FeedBuilder.php:151 -msgid "Direct link" -msgstr "ダイレクトリンク" - -#: application/FeedBuilder.php:153 -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:178 -msgid "Permalink" -msgstr "パーマリンク" - -#: application/History.php:174 +#: application/History.php:179 msgid "History file isn't readable or writable" msgstr "履歴ファイルを読み込む、または書き込むための権限がありません" -#: application/History.php:185 +#: application/History.php:190 msgid "Could not parse history file" msgstr "履歴ファイルを正常に復元できませんでした" -#: application/Languages.php:177 +#: application/Languages.php:181 msgid "Automatic" msgstr "自動" -#: application/Languages.php:178 +#: application/Languages.php:182 +msgid "German" +msgstr "ドイツ語" + +#: application/Languages.php:183 msgid "English" msgstr "英語" -#: application/Languages.php:179 +#: application/Languages.php:184 msgid "French" msgstr "フランス語" -#: application/Languages.php:180 -msgid "German" -msgstr "ドイツ語" - -#: application/LinkDB.php:136 -msgid "You are not authorized to add a link." -msgstr "リンクを追加するには、ログインする必要があります。" - -#: application/LinkDB.php:139 -msgid "Internal Error: A link should always have an id and URL." -msgstr "エラー: リンクにはIDとURLを登録しなければなりません。" - -#: application/LinkDB.php:142 -msgid "You must specify an integer as a key." -msgstr "正常なキーの値ではありません。" - -#: application/LinkDB.php:145 -msgid "Array offset and link ID must be equal." -msgstr "Array オフセットとリンクのIDは同じでなければなりません。" - -#: application/LinkDB.php:251 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48 -msgid "" -"The personal, minimalist, super-fast, database free, bookmarking service" -msgstr "" -"個人向けの、ミニマムで高速でかつデータベースのいらないブックマークサービス" - -#: application/LinkDB.php:253 -msgid "" -"Welcome to Shaarli! This is your first public bookmark. To edit or delete " -"me, you must first login.\n" -"\n" -"To learn how to use Shaarli, consult the link \"Documentation\" at the " -"bottom of this page.\n" -"\n" -"You use the community supported version of the original Shaarli project, by " -"Sebastien Sauvage." -msgstr "" -"Shaarli へようこそ! これはあなたの最初の公開ブックマークです。これを編集した" -"り削除したりするには、ログインする必要があります。\n" -"\n" -"Shaarli の使い方を知るには、このページの下にある「ドキュメント」のリンクを開" -"いてください。\n" -"\n" -"あなたは Sebastien Sauvage による、コミュニティーサポートのあるバージョンのオ" -"リジナルのShaarli プロジェクトを使用しています。" - -#: application/LinkDB.php:267 -msgid "My secret stuff... - Pastebin.com" -msgstr "わたしのひ💗み💗つ💗 - Pastebin.com" - -#: application/LinkDB.php:269 -msgid "Shhhh! I'm a private link only YOU can see. You can delete me too." -msgstr "" -"シーッ! これはあなたしか見られないプライベートリンクです。消すこともできま" -"す。" - -#: application/LinkFilter.php:452 -msgid "The link you are trying to reach does not exist or has been deleted." -msgstr "開こうとしたリンクは存在しないか、削除されています。" - -#: application/NetscapeBookmarkUtils.php:35 -msgid "Invalid export selection:" -msgstr "不正なエクスポートの選択:" - -#: application/NetscapeBookmarkUtils.php:81 -#, php-format -msgid "File %s (%d bytes) " -msgstr "ファイル %s (%d バイト) " - -#: application/NetscapeBookmarkUtils.php:83 -msgid "has an unknown file format. Nothing was imported." -msgstr "は不明なファイル形式です。インポートは中止されました。" +#: application/Languages.php:185 +msgid "Japanese" +msgstr "日本語" -#: application/NetscapeBookmarkUtils.php:86 -#, php-format +#: application/Thumbnailer.php:62 msgid "" -"was successfully processed in %d seconds: %d links imported, %d links " -"overwritten, %d links skipped." +"php-gd extension must be loaded to use thumbnails. Thumbnails are now " +"disabled. Please reload the page." msgstr "" -"が %d 秒で処理され、%d 件のリンクがインポートされ、%d 件のリンクが上書きさ" -"れ、%d 件のリンクがスキップされました。" - -#: application/PageBuilder.php:168 -msgid "The page you are trying to reach does not exist or has been deleted." -msgstr "あなたが開こうとしたページは存在しないか、削除されています。" - -#: application/PageBuilder.php:170 -msgid "404 Not Found" -msgstr "404 ページが存在しません" - -#: application/PluginManager.php:243 -#, php-format -msgid "Plugin \"%s\" files not found." -msgstr "プラグイン「%s」のファイルが存在しません。" - -#: application/Updater.php:76 -msgid "Couldn't retrieve Updater class methods." -msgstr "アップデーターのクラスメゾットを受信できませんでした。" - -#: application/Updater.php:532 -msgid "An error occurred while running the update " -msgstr "更新中に問題が発生しました " - -#: application/Updater.php:572 -msgid "Updates file path is not set, can't write updates." -msgstr "更新するファイルのパスが指定されていないため、更新を書き込めません。" +"サムネイルを使用するには、php-gd エクステンションが読み込まれている必要があり" +"ます。サムネイルは無効化されました。ページを再読込してください。" -#: application/Updater.php:577 -msgid "Unable to write updates in " -msgstr "更新を次の項目に書き込めませんでした: " - -#: application/Utils.php:376 tests/UtilsTest.php:340 +#: application/Utils.php:383 tests/UtilsTest.php:343 msgid "Setting not set" msgstr "未設定" -#: application/Utils.php:383 tests/UtilsTest.php:338 tests/UtilsTest.php:339 +#: application/Utils.php:390 tests/UtilsTest.php:341 tests/UtilsTest.php:342 msgid "Unlimited" msgstr "無制限" -#: application/Utils.php:386 tests/UtilsTest.php:335 tests/UtilsTest.php:336 -#: tests/UtilsTest.php:350 +#: application/Utils.php:393 tests/UtilsTest.php:338 tests/UtilsTest.php:339 +#: tests/UtilsTest.php:353 msgid "B" msgstr "B" -#: application/Utils.php:386 tests/UtilsTest.php:329 tests/UtilsTest.php:330 -#: tests/UtilsTest.php:337 +#: application/Utils.php:393 tests/UtilsTest.php:332 tests/UtilsTest.php:333 +#: tests/UtilsTest.php:340 msgid "kiB" msgstr "kiB" -#: application/Utils.php:386 tests/UtilsTest.php:331 tests/UtilsTest.php:332 -#: tests/UtilsTest.php:348 tests/UtilsTest.php:349 +#: application/Utils.php:393 tests/UtilsTest.php:334 tests/UtilsTest.php:335 +#: tests/UtilsTest.php:351 tests/UtilsTest.php:352 msgid "MiB" msgstr "MiB" -#: application/Utils.php:386 tests/UtilsTest.php:333 tests/UtilsTest.php:334 +#: application/Utils.php:393 tests/UtilsTest.php:336 tests/UtilsTest.php:337 msgid "GiB" msgstr "GiB" -#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:121 +#: application/bookmark/BookmarkFileService.php:180 +#: application/bookmark/BookmarkFileService.php:202 +#: application/bookmark/BookmarkFileService.php:224 +#: application/bookmark/BookmarkFileService.php:238 +msgid "You're not authorized to alter the datastore" +msgstr "設定を変更する権限がありません" + +#: application/bookmark/BookmarkFileService.php:205 +msgid "This bookmarks already exists" +msgstr "このブックマークは既に存在します。" + +#: application/bookmark/BookmarkInitializer.php:39 +msgid "(private bookmark with thumbnail demo)" +msgstr "(サムネイルデモが付属しているプライベートブックマーク)" + +#: application/bookmark/BookmarkInitializer.php:42 +msgid "" +"Shaarli will automatically pick up the thumbnail for links to a variety of " +"websites.\n" +"\n" +"Explore your new Shaarli instance by trying out controls and menus.\n" +"Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the " +"documentation](https://shaarli.readthedocs.io/en/master/) to learn more " +"about Shaarli.\n" +"\n" +"Now you can edit or delete the default shaares.\n" +msgstr "" +"Shaarli は自動的に多様なウェブサイトのサムネイルを取得します。\n" +"\n" +"あなたの新しい Shaarli インスタンスをコントロールやメニューを試したりして、探" +"検してください。\n" +" [Github](https://github.com/shaarli/Shaarli) または [the documentation]" +"(https://shaarli.readthedocs.io/en/master/) でプロジェクトを訪問して、" +"Shaarli をもっとよく知ることができます。\n" +"\n" +"今から、既定の shaares を編集したり、削除したりすることができます。\n" + +#: application/bookmark/BookmarkInitializer.php:55 +msgid "Note: Shaare descriptions" +msgstr "説明: Shaare の概要" + +#: application/bookmark/BookmarkInitializer.php:57 +msgid "" +"Adding a shaare without entering a URL creates a text-only \"note\" post " +"such as this one.\n" +"This note is private, so you are the only one able to see it while logged " +"in.\n" +"\n" +"You can use this to keep notes, post articles, code snippets, and much " +"more.\n" +"\n" +"The Markdown formatting setting allows you to format your notes and bookmark " +"description:\n" +"\n" +"### Title headings\n" +"\n" +"#### Multiple headings levels\n" +" * bullet lists\n" +" * _italic_ text\n" +" * **bold** text\n" +" * ~~strike through~~ text\n" +" * `code` blocks\n" +" * images\n" +" * [links](https://en.wikipedia.org/wiki/Markdown)\n" +"\n" +"Markdown also supports tables:\n" +"\n" +"| Name | Type | Color | Qty |\n" +"| ------- | --------- | ------ | ----- |\n" +"| Orange | Fruit | Orange | 126 |\n" +"| Apple | Fruit | Any | 62 |\n" +"| Lemon | Fruit | Yellow | 30 |\n" +"| Carrot | Vegetable | Red | 14 |\n" +msgstr "" +"URL を追加せずに shaare を作成すると、テキストのみのこのような \"ノート\" が" +"作成されます。\n" +"このノートはプライベートなので、ログイン中のあなたしか見ることはできませ" +"ん。\n" +"\n" +"あなたはこれをメモ帳として使ったり、記事を投稿したり、コード スニペットとした" +"りするなどといったことに使えます。\n" +"\n" +"Markdown フォーマットの設定により、ノートやブックマークの概要を以下のように" +"フォーマットできます:\n" +"\n" +"### タイトル ヘッダー\n" +"\n" +"#### 複数の見出し\n" +" * 箇条書きリスト\n" +" * _イタリック_ 文字\n" +" * **ボールド** 文字\n" +" * ~~打ち消し~~ 文字\n" +" * `コード` ブロック\n" +" * 画像\n" +" * [リンク](https://en.wikipedia.org/wiki/Markdown)\n" +"\n" +"Markdown は表もサポートします:\n" +"\n" +"| 名前 | 種類 | 色 | 数量 |\n" +"| ------- | --------- | ------ | ----- |\n" +"| オレンジ | 果物 | 橙 | 126 |\n" +"| リンゴ | 果物 | 任意 | 62 |\n" +"| レモン | 果物 | 黄 | 30 |\n" +"| 人参 | 野菜 | 赤 | 14 |\n" + +#: application/bookmark/BookmarkInitializer.php:91 +#: application/legacy/LegacyLinkDB.php:246 +msgid "" +"The personal, minimalist, super-fast, database free, bookmarking service" +msgstr "" +"個人向けの、ミニマムで高速でかつデータベースのいらないブックマークサービス" + +#: application/bookmark/BookmarkInitializer.php:94 +msgid "" +"Welcome to Shaarli!\n" +"\n" +"Shaarli allows you to bookmark your favorite pages, and share them with " +"others or store them privately.\n" +"You can add a description to your bookmarks, such as this one, and tag " +"them.\n" +"\n" +"Create a new shaare by clicking the `+Shaare` button, or using any of the " +"recommended tools (browser extension, mobile app, bookmarklet, REST API, " +"etc.).\n" +"\n" +"You can easily retrieve your links, even with thousands of them, using the " +"internal search engine, or search through tags (e.g. this Shaare is tagged " +"with `shaarli` and `help`).\n" +"Hashtags such as #shaarli #help are also supported.\n" +"You can also filter the available [RSS feed](/feed/atom) and picture wall by " +"tag or plaintext search.\n" +"\n" +"We hope that you will enjoy using Shaarli, maintained with ❤️ by the " +"community!\n" +"Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if " +"you have a suggestion or encounter an issue.\n" +msgstr "" +"Shaarli へようこそ!\n" +"\n" +"Shaarli では、あなたのお気に入りのページをブックマークしたり、それを他の人と" +"共有するか、またはプライベートなものとして保管することができます。\n" +"加えて、あなたのブックマークにこの項目のように概要を追加したり、タグ付けした" +"りすることができます。\n" +"\n" +"`+Shaare` ボタンをクリックすることで新しい shaare を作成できます。また、推奨" +"されたツールを使うこともできます (ブラウザー 拡張機能、モバイル アプリ、ブッ" +"クマークレット、REST API など...)。\n" +"\n" +"また、簡単にあなたのリンクを取得できます。それが何千と登る数であっても、内部" +"の検索エンジンや、タグを使って検索できます (例えば、この Shaare は `shaarli` " +"と `help` というタグが付いています)。\n" +"#shaarli や #help といったハッシュタグもサポートされています。\n" +"タグやテキスト検索による [RSS フィード](/feed/atom) や ピクチャー ウォール で" +"項目を絞ることもできます。\n" +"\n" +"私たちはあなたが Shaarli を楽しんでくれることを願っています。Shaarli はコミュ" +"ニティーによって ♡ と共にメンテナンスされています!\n" +"何か問題に遭遇したり、提案があれば、気軽に [Issue](https://github.com/" +"shaarli/Shaarli/issues) を開いてください。\n" + +#: application/bookmark/exception/BookmarkNotFoundException.php:13 +msgid "The link you are trying to reach does not exist or has been deleted." +msgstr "開こうとしたリンクは存在しないか、削除されています。" + +#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:129 msgid "" "Shaarli could not create the config file. Please make sure Shaarli has the " "right to write in the folder is it installed in." @@ -232,7 +282,8 @@ msgstr "" "ていて、インストールされているディレクトリに書き込みできることを確認してくだ" "さい。" -#: application/config/ConfigManager.php:135 +#: application/config/ConfigManager.php:136 +#: application/config/ConfigManager.php:163 msgid "Invalid setting key parameter. String expected, got: " msgstr "" "不正なキーの値です。文字列が想定されていますが、次のように入力されました: " @@ -250,159 +301,185 @@ msgstr "プラグインの読込順を変更する際にエラーが発生しま msgid "You are not authorized to alter config." msgstr "設定を変更する権限がありません。" -#: application/exceptions/IOException.php:19 +#: application/exceptions/IOException.php:22 msgid "Error accessing" msgstr "読込中にエラーが発生しました" -#: index.php:142 -msgid "Shared links on " -msgstr "次において共有されたリンク:" +#: application/feed/FeedBuilder.php:179 +msgid "Direct link" +msgstr "ダイレクトリンク" -#: index.php:164 -msgid "Insufficient permissions:" -msgstr "権限がありません:" +#: application/feed/FeedBuilder.php:181 +msgid "Permalink" +msgstr "パーマリンク" -#: index.php:303 -msgid "I said: NO. You are banned for the moment. Go away." -msgstr "あなたはこのサーバーからBANされています。" +#: application/front/controller/admin/ConfigureController.php:54 +msgid "Configure" +msgstr "設定" -#: index.php:368 -msgid "Wrong login/password." -msgstr "不正なユーザー名、またはパスワードです。" +#: application/front/controller/admin/ConfigureController.php:102 +#: application/legacy/LegacyUpdater.php:537 +msgid "You have enabled or changed thumbnails mode." +msgstr "サムネイルのモードを有効化、または変更しました。" -#: index.php:576 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:42 -msgid "Daily" -msgstr "デイリー" +#: application/front/controller/admin/ConfigureController.php:103 +#: application/legacy/LegacyUpdater.php:538 +msgid "Please synchronize them." +msgstr "それらを同期してください。" -#: index.php:681 tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 -#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:95 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:71 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:95 -msgid "Login" -msgstr "ログイン" +#: application/front/controller/admin/ConfigureController.php:113 +#: application/front/controller/visitor/InstallController.php:136 +msgid "Error while writing config file after configuration update." +msgstr "設定ファイルを更新した後の書き込みに失敗しました。" -#: index.php:722 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:39 -msgid "Picture wall" -msgstr "ピクチャウォール" +#: application/front/controller/admin/ConfigureController.php:122 +msgid "Configuration was saved." +msgstr "設定は保存されました。" -#: index.php:770 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:36 -#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 -msgid "Tag cloud" -msgstr "タグクラウド" +#: application/front/controller/admin/ExportController.php:26 +msgid "Export" +msgstr "エクスポート" -#: index.php:803 tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 -msgid "Tag list" -msgstr "タグ一覧" +#: application/front/controller/admin/ExportController.php:42 +msgid "Please select an export mode." +msgstr "エクスポート モードを指定してください。" -#: index.php:1028 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:31 -msgid "Tools" -msgstr "ツール" +#: application/front/controller/admin/ImportController.php:41 +msgid "Import" +msgstr "インポート" -#: index.php:1037 -msgid "You are not supposed to change a password on an Open Shaarli." +#: application/front/controller/admin/ImportController.php:55 +msgid "No import file provided." +msgstr "何のインポート元ファイルも指定されませんでした。" + +#: application/front/controller/admin/ImportController.php:66 +#, php-format +msgid "" +"The file you are trying to upload is probably bigger than what this " +"webserver can accept (%s). Please upload in smaller chunks." msgstr "" -"公開されている Shaarli において、パスワードを変更することは想定されていませ" -"ん。" +"あなたがアップロードしようとしているファイルは、サーバーが許可しているファイ" +"ルサイズ (%s) よりも大きいです。もう少し小さいものをアップロードしてくださ" +"い。" -#: index.php:1042 index.php:1084 index.php:1160 index.php:1191 index.php:1291 -msgid "Wrong token." -msgstr "不正なトークンです。" +#: application/front/controller/admin/ManageShaareController.php:29 +msgid "Shaare a new link" +msgstr "新しいリンクを追加" -#: index.php:1047 -msgid "The old password is not correct." -msgstr "元のパスワードが正しくありません。" +#: application/front/controller/admin/ManageShaareController.php:78 +msgid "Note: " +msgstr "注: " -#: index.php:1067 -msgid "Your password has been changed" -msgstr "あなたのパスワードは変更されました" +#: application/front/controller/admin/ManageShaareController.php:109 +#: application/front/controller/admin/ManageShaareController.php:206 +#: application/front/controller/admin/ManageShaareController.php:275 +#: application/front/controller/admin/ManageShaareController.php:315 +#, php-format +msgid "Bookmark with identifier %s could not be found." +msgstr "%s という識別子を持ったブックマークは見つかりませんでした。" -#: index.php:1072 -#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 -msgid "Change password" -msgstr "パスワードを変更" +#: application/front/controller/admin/ManageShaareController.php:194 +#: application/front/controller/admin/ManageShaareController.php:252 +msgid "Invalid bookmark ID provided." +msgstr "不正なブックマーク ID が入力されました。" -#: index.php:1120 -msgid "Configuration was saved." -msgstr "設定は保存されました。" +#: application/front/controller/admin/ManageShaareController.php:260 +msgid "Invalid visibility provided." +msgstr "不正な公開設定が入力されました。" -#: index.php:1143 tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 -msgid "Configure" -msgstr "設定" +#: application/front/controller/admin/ManageShaareController.php:363 +msgid "Edit" +msgstr "共有" + +#: application/front/controller/admin/ManageShaareController.php:366 +msgid "Shaare" +msgstr "Shaare" -#: index.php:1154 tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 +#: application/front/controller/admin/ManageTagController.php:29 msgid "Manage tags" msgstr "タグを設定" -#: index.php:1172 +#: application/front/controller/admin/ManageTagController.php:48 +msgid "Invalid tags provided." +msgstr "不正なタグが入力されました。" + +#: application/front/controller/admin/ManageTagController.php:72 #, php-format -msgid "The tag was removed from %d link." -msgid_plural "The tag was removed from %d links." +msgid "The tag was removed from %d bookmark." +msgid_plural "The tag was removed from %d bookmarks." msgstr[0] "%d 件のリンクからタグが削除されました。" -msgstr[1] "The tag was removed from %d links." +msgstr[1] "%d 件のリンクからタグが削除されました。" -#: index.php:1173 +#: application/front/controller/admin/ManageTagController.php:77 #, php-format -msgid "The tag was renamed in %d link." -msgid_plural "The tag was renamed in %d links." -msgstr[0] "タグが %d 件のリンクにおいて、名前が変更されました。" -msgstr[1] "タグが %d 件のリンクにおいて、名前が変更されました。" +msgid "The tag was renamed in %d bookmark." +msgid_plural "The tag was renamed in %d bookmarks." +msgstr[0] "このタグを持つ %d 件のリンクにおいて、名前が変更されました。" +msgstr[1] "このタグを持つ %d 件のリンクにおいて、名前が変更されました。" -#: index.php:1181 tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 -msgid "Shaare a new link" -msgstr "新しいリンクを追加" +#: application/front/controller/admin/PasswordController.php:28 +msgid "Change password" +msgstr "パスワードを変更" -#: index.php:1351 tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170 -msgid "Edit" -msgstr "共有" +#: application/front/controller/admin/PasswordController.php:55 +msgid "You must provide the current and new password to change it." +msgstr "" +"パスワードを変更するには、現在のパスワードと、新しいパスワードを入力する必要" +"があります。" -#: index.php:1351 index.php:1421 -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:26 -msgid "Shaare" -msgstr "Shaare" +#: application/front/controller/admin/PasswordController.php:71 +msgid "The old password is not correct." +msgstr "元のパスワードが正しくありません。" -#: index.php:1390 -msgid "Note: " -msgstr "注: " +#: application/front/controller/admin/PasswordController.php:97 +msgid "Your password has been changed" +msgstr "あなたのパスワードは変更されました" -#: index.php:1430 tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65 -msgid "Export" -msgstr "エクスポート" +#: application/front/controller/admin/PluginsController.php:45 +msgid "Plugin Administration" +msgstr "プラグイン管理" -#: index.php:1492 tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83 -msgid "Import" -msgstr "インポート" +#: application/front/controller/admin/PluginsController.php:76 +msgid "Setting successfully saved." +msgstr "設定が正常に保存されました。" -#: index.php:1502 -#, php-format -msgid "" -"The file you are trying to upload is probably bigger than what this " -"webserver can accept (%s). Please upload in smaller chunks." -msgstr "" -"あなたがアップロードしようとしているファイルは、サーバーが許可しているファイ" -"ルサイズ (%s) よりも大きいです。もう少し小さいものをアップロードしてくださ" -"い。" +#: application/front/controller/admin/PluginsController.php:79 +msgid "Error while saving plugin configuration: " +msgstr "プラグインの設定ファイルを保存するときにエラーが発生しました: " -#: index.php:1541 tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22 -msgid "Plugin administration" -msgstr "プラグイン管理" +#: application/front/controller/admin/ThumbnailsController.php:37 +msgid "Thumbnails update" +msgstr "サムネイルの更新" + +#: application/front/controller/admin/ToolsController.php:31 +msgid "Tools" +msgstr "ツール" -#: index.php:1706 +#: application/front/controller/visitor/BookmarkListController.php:116 msgid "Search: " msgstr "検索: " -#: index.php:1933 +#: application/front/controller/visitor/DailyController.php:45 +msgid "Today" +msgstr "今日" + +#: application/front/controller/visitor/DailyController.php:47 +msgid "Yesterday" +msgstr "昨日" + +#: application/front/controller/visitor/DailyController.php:85 +msgid "Daily" +msgstr "デイリー" + +#: application/front/controller/visitor/ErrorController.php:36 +msgid "An unexpected error occurred." +msgstr "予期しないエラーが発生しました。" + +#: application/front/controller/visitor/ErrorNotFoundController.php:25 +msgid "Requested page could not be found." +msgstr "リクエストされたページは存在しません。" + +#: application/front/controller/visitor/InstallController.php:73 #, php-format msgid "" "
Sessions do not seem to work correctly on your server.
Make sure the " @@ -420,32 +497,205 @@ msgstr "" "ります。IP アドレスや完全なドメイン名でサーバーにアクセスすることをおすすめし" "ます。
" -#: index.php:1943 -msgid "Click to try again." -msgstr "クリックして再度試します。" +#: application/front/controller/visitor/InstallController.php:144 +msgid "" +"Shaarli is now configured. Please login and start shaaring your bookmarks!" +msgstr "" +"Shaarli の設定が完了しました。ログインして、あなたのブックマークを登録しま" +"しょう!" + +#: application/front/controller/visitor/InstallController.php:158 +msgid "Insufficient permissions:" +msgstr "権限がありません:" + +#: application/front/controller/visitor/LoginController.php:46 +msgid "Login" +msgstr "ログイン" + +#: application/front/controller/visitor/LoginController.php:78 +msgid "Wrong login/password." +msgstr "不正なユーザー名、またはパスワードです。" + +#: application/front/controller/visitor/PictureWallController.php:29 +msgid "Picture wall" +msgstr "ピクチャウォール" + +#: application/front/controller/visitor/TagCloudController.php:88 +msgid "Tag " +msgstr "タグ " + +#: application/front/exceptions/AlreadyInstalledException.php:11 +msgid "Shaarli has already been installed. Login to edit the configuration." +msgstr "Shaarli がインストールされました。ログインして設定を変更できます。" + +#: application/front/exceptions/LoginBannedException.php:11 +msgid "" +"You have been banned after too many failed login attempts. Try again later." +msgstr "複数回に渡るログインへの失敗を検出しました。後でまた試してください。" + +#: application/front/exceptions/OpenShaarliPasswordException.php:16 +msgid "You are not supposed to change a password on an Open Shaarli." +msgstr "" +"公開されている Shaarli において、パスワードを変更することは想定されていませ" +"ん。" + +#: application/front/exceptions/ThumbnailsDisabledException.php:11 +msgid "Picture wall unavailable (thumbnails are disabled)." +msgstr "ピクチャ ウォールは利用できません (サムネイルが無効化されています)。" + +#: application/front/exceptions/WrongTokenException.php:16 +msgid "Wrong token." +msgstr "不正なトークンです。" + +#: application/legacy/LegacyLinkDB.php:131 +msgid "You are not authorized to add a link." +msgstr "リンクを追加するには、ログインする必要があります。" + +#: application/legacy/LegacyLinkDB.php:134 +msgid "Internal Error: A link should always have an id and URL." +msgstr "エラー: リンクにはIDとURLを登録しなければなりません。" + +#: application/legacy/LegacyLinkDB.php:137 +msgid "You must specify an integer as a key." +msgstr "正常なキーの値ではありません。" + +#: application/legacy/LegacyLinkDB.php:140 +msgid "Array offset and link ID must be equal." +msgstr "Array オフセットとリンクのIDは同じでなければなりません。" + +#: application/legacy/LegacyLinkDB.php:249 +msgid "" +"Welcome to Shaarli! This is your first public bookmark. To edit or delete " +"me, you must first login.\n" +"\n" +"To learn how to use Shaarli, consult the link \"Documentation\" at the " +"bottom of this page.\n" +"\n" +"You use the community supported version of the original Shaarli project, by " +"Sebastien Sauvage." +msgstr "" +"Shaarli へようこそ! これはあなたの最初の公開ブックマークです。これを編集した" +"り削除したりするには、ログインする必要があります。\n" +"\n" +"Shaarli の使い方を知るには、このページの下にある「ドキュメント」のリンクを開" +"いてください。\n" +"\n" +"あなたは Sebastien Sauvage による、コミュニティーサポートのあるバージョンのオ" +"リジナルのShaarli プロジェクトを使用しています。" + +#: application/legacy/LegacyLinkDB.php:266 +msgid "My secret stuff... - Pastebin.com" +msgstr "わたしのひ💗み💗つ💗 - Pastebin.com" + +#: application/legacy/LegacyLinkDB.php:268 +msgid "Shhhh! I'm a private link only YOU can see. You can delete me too." +msgstr "" +"シーッ! これはあなたしか見られないプライベートリンクです。消すこともできま" +"す。" + +#: application/legacy/LegacyUpdater.php:104 +#, fuzzy +#| msgid "Couldn't retrieve Updater class methods." +msgid "Couldn't retrieve updater class methods." +msgstr "アップデーターのクラスメゾットを受信できませんでした。" + +#: application/legacy/LegacyUpdater.php:538 +msgid "" +msgstr "" + +#: application/netscape/NetscapeBookmarkUtils.php:63 +msgid "Invalid export selection:" +msgstr "不正なエクスポートの選択:" + +#: application/netscape/NetscapeBookmarkUtils.php:215 +#, php-format +msgid "File %s (%d bytes) " +msgstr "ファイル %s (%d バイト) " + +#: application/netscape/NetscapeBookmarkUtils.php:217 +msgid "has an unknown file format. Nothing was imported." +msgstr "は不明なファイル形式です。インポートは中止されました。" + +#: application/netscape/NetscapeBookmarkUtils.php:221 +#, fuzzy, php-format +#| msgid "" +#| "was successfully processed in %d seconds: %d links imported, %d links " +#| "overwritten, %d links skipped." +msgid "" +"was successfully processed in %d seconds: %d bookmarks imported, %d " +"bookmarks overwritten, %d bookmarks skipped." +msgstr "" +"が %d 秒で処理され、%d 件のリンクがインポートされ、%d 件のリンクが上書きさ" +"れ、%d 件のリンクがスキップされました。" + +#: application/plugin/PluginManager.php:124 +msgid " [plugin incompatibility]: " +msgstr "[非対応のプラグイン]: " + +#: application/plugin/exception/PluginFileNotFoundException.php:21 +#, php-format +msgid "Plugin \"%s\" files not found." +msgstr "プラグイン「%s」のファイルが存在しません。" + +#: application/render/PageCacheManager.php:32 +#, php-format +msgid "Cannot purge %s: no directory" +msgstr "%s を削除できません: ディレクトリが存在しません" + +#: application/updater/exception/UpdaterException.php:51 +msgid "An error occurred while running the update " +msgstr "更新中に問題が発生しました " + +#: index.php:65 +msgid "Shared bookmarks on " +msgstr "次において共有されたリンク " -#: plugins/addlink_toolbar/addlink_toolbar.php:29 +#: plugins/addlink_toolbar/addlink_toolbar.php:31 msgid "URI" msgstr "URI" -#: plugins/addlink_toolbar/addlink_toolbar.php:33 -#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 +#: plugins/addlink_toolbar/addlink_toolbar.php:35 msgid "Add link" msgstr "リンクを追加" -#: plugins/addlink_toolbar/addlink_toolbar.php:50 +#: plugins/addlink_toolbar/addlink_toolbar.php:52 msgid "Adds the addlink input on the linklist page." msgstr "リンク一覧のページに、リンクを追加するためのフォームを表示する。" -#: plugins/archiveorg/archiveorg.php:23 +#: plugins/archiveorg/archiveorg.php:28 msgid "View on archive.org" msgstr "archive.org 上で表示する" -#: plugins/archiveorg/archiveorg.php:36 +#: plugins/archiveorg/archiveorg.php:41 msgid "For each link, add an Archive.org icon." msgstr "それぞれのリンクに、Archive.org のアイコンを追加する。" -#: plugins/demo_plugin/demo_plugin.php:465 +#: plugins/default_colors/default_colors.php:38 +msgid "" +"Default colors plugin error: This plugin is active and no custom color is " +"configured." +msgstr "" +"既定の色のプラグインにおけるエラー: このプラグインは有効なので、カスタム カ" +"ラーは適用されません。" + +#: plugins/default_colors/default_colors.php:113 +msgid "Override default theme colors. Use any CSS valid color." +msgstr "" +"既定のテーマの色を上書きします。どのような CSS カラーコードでも使えます。" + +#: plugins/default_colors/default_colors.php:114 +msgid "Main color (navbar green)" +msgstr "メイン カラー (ナビバーの緑)" + +#: plugins/default_colors/default_colors.php:115 +msgid "Background color (light grey)" +msgstr "背景色 (灰色)" + +#: plugins/default_colors/default_colors.php:116 +msgid "Dark main color (e.g. visited links)" +msgstr "暗い方の メイン カラー (例: 閲覧済みリンク)" + +#: plugins/demo_plugin/demo_plugin.php:477 msgid "" "A demo plugin covering all use cases for template designers and plugin " "developers." @@ -453,7 +703,15 @@ msgstr "" "テンプレートのデザイナーや、プラグインの開発者のためのすべての状況に対応でき" "るデモプラグインです。" -#: plugins/isso/isso.php:20 +#: plugins/demo_plugin/demo_plugin.php:478 +msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed." +msgstr "これはデモプラグイン専用のパラメーターです。末尾に追加されます。" + +#: plugins/demo_plugin/demo_plugin.php:479 +msgid "Other demo parameter" +msgstr "他のデモ パラメーター" + +#: plugins/isso/isso.php:22 msgid "" "Isso plugin error: Please define the \"ISSO_SERVER\" setting in the plugin " "administration page." @@ -461,45 +719,17 @@ msgstr "" "Isso プラグインエラー: \"ISSO_SERVER\" の値をプラグイン管理ページにて指定して" "ください。" -#: plugins/isso/isso.php:63 +#: plugins/isso/isso.php:92 msgid "Let visitor comment your shaares on permalinks with Isso." msgstr "" "Isso を使って、あなたのパーマリンク上のリンクに第三者がコメントを残すことがで" "きます。" -#: plugins/isso/isso.php:64 +#: plugins/isso/isso.php:93 msgid "Isso server URL (without 'http://')" msgstr "Isso server URL ('http://' 抜き)" -#: plugins/markdown/markdown.php:158 -msgid "Description will be rendered with" -msgstr "説明は次の方法で描画されます:" - -#: plugins/markdown/markdown.php:159 -msgid "Markdown syntax documentation" -msgstr "マークダウン形式のドキュメント" - -#: plugins/markdown/markdown.php:160 -msgid "Markdown syntax" -msgstr "マークダウン形式" - -#: plugins/markdown/markdown.php:339 -msgid "" -"Render shaare description with Markdown syntax.
Warning:\n" -"If your shaared descriptions contained HTML tags before enabling the " -"markdown plugin,\n" -"enabling it might break your page.\n" -"See the
README." -msgstr "" -"リンクの説明をマークダウン形式で表示します。
警告:\n" -"リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n" -"正常にページを表示できなくなるかもしれません。\n" -"詳しくは README をご覧ください。" - -#: plugins/piwik/piwik.php:21 +#: plugins/piwik/piwik.php:23 msgid "" "Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin " "administration page." @@ -507,27 +737,27 @@ msgstr "" "Piwik プラグインエラー: PIWIK_URL と PIWIK_SITEID の値をプラグイン管理ページ" "で指定してください。" -#: plugins/piwik/piwik.php:70 +#: plugins/piwik/piwik.php:72 msgid "A plugin that adds Piwik tracking code to Shaarli pages." msgstr "Piwik のトラッキングコードをShaarliに追加するプラグインです。" -#: plugins/piwik/piwik.php:71 +#: plugins/piwik/piwik.php:73 msgid "Piwik URL" msgstr "Piwik URL" -#: plugins/piwik/piwik.php:72 +#: plugins/piwik/piwik.php:74 msgid "Piwik site ID" msgstr "Piwik サイトID" -#: plugins/playvideos/playvideos.php:22 +#: plugins/playvideos/playvideos.php:25 msgid "Video player" msgstr "動画プレイヤー" -#: plugins/playvideos/playvideos.php:25 +#: plugins/playvideos/playvideos.php:28 msgid "Play Videos" msgstr "動画を再生" -#: plugins/playvideos/playvideos.php:56 +#: plugins/playvideos/playvideos.php:59 msgid "Add a button in the toolbar allowing to watch all videos." msgstr "すべての動画を閲覧するボタンをツールバーに追加します。" @@ -535,26 +765,26 @@ msgstr "すべての動画を閲覧するボタンをツールバーに追加し msgid "plugins/playvideos/jquery-1.11.2.min.js" msgstr "plugins/playvideos/jquery-1.11.2.min.js" -#: plugins/pubsubhubbub/pubsubhubbub.php:69 +#: plugins/pubsubhubbub/pubsubhubbub.php:72 #, php-format msgid "Could not publish to PubSubHubbub: %s" msgstr "PubSubHubbub に登録できませんでした: %s" -#: plugins/pubsubhubbub/pubsubhubbub.php:95 +#: plugins/pubsubhubbub/pubsubhubbub.php:99 #, php-format msgid "Could not post to %s" msgstr "%s に登録できませんでした" -#: plugins/pubsubhubbub/pubsubhubbub.php:99 +#: plugins/pubsubhubbub/pubsubhubbub.php:103 #, php-format msgid "Bad response from the hub %s" msgstr "ハブ %s からの不正なレスポンス" -#: plugins/pubsubhubbub/pubsubhubbub.php:110 +#: plugins/pubsubhubbub/pubsubhubbub.php:114 msgid "Enable PubSubHubbub feed publishing." msgstr "PubSubHubbub へのフィードを公開する。" -#: plugins/qrcode/qrcode.php:69 plugins/wallabag/wallabag.php:68 +#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70 msgid "For each link, add a QRCode icon." msgstr "それぞれのリンクについて、QRコードのアイコンを追加する。" @@ -570,724 +800,534 @@ msgstr "" msgid "Save to wallabag" msgstr "Wallabag に保存" -#: plugins/wallabag/wallabag.php:69 +#: plugins/wallabag/wallabag.php:71 msgid "Wallabag API URL" msgstr "Wallabag のAPIのURL" -#: plugins/wallabag/wallabag.php:70 +#: plugins/wallabag/wallabag.php:72 msgid "Wallabag API version (1 or 2)" msgstr "Wallabag のAPIのバージョン (1 または 2)" #: tests/LanguagesTest.php:214 tests/LanguagesTest.php:227 -#: tests/languages/fr/LanguagesFrTest.php:160 -#: tests/languages/fr/LanguagesFrTest.php:173 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:81 +#: tests/languages/fr/LanguagesFrTest.php:159 +#: tests/languages/fr/LanguagesFrTest.php:172 msgid "Search" msgid_plural "Search" msgstr[0] "検索" msgstr[1] "検索" -#: tmp/404.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12 -msgid "Sorry, nothing to see here." -msgstr "すみませんが、ここには何もありません。" - -#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -msgid "URL or leave empty to post a note" -msgstr "URL を入力するか、空欄にするとノートを投稿します" - -#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -msgid "Current password" -msgstr "現在のパスワード" - -#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 -msgid "New password" -msgstr "新しいパスワード" - -#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23 -msgid "Change" -msgstr "変更" - -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77 -msgid "Tag" -msgstr "タグ" - -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 -msgid "New name" -msgstr "変更先の名前" - -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31 -msgid "Case sensitive" -msgstr "大文字と小文字を区別" - -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 -msgid "Rename" -msgstr "名前を変更" - -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:172 -msgid "Delete" -msgstr "削除" - -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 -msgid "You can also edit tags in the" -msgstr "次に含まれるタグを編集することもできます:" - -#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 -msgid "tag list" -msgstr "タグ一覧" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 -msgid "title" -msgstr "タイトル" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 -msgid "Home link" -msgstr "ホームのリンク先" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 -msgid "Default value" -msgstr "既定の値" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58 -msgid "Theme" -msgstr "テーマ" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78 -msgid "Language" -msgstr "言語" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:116 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102 -msgid "Timezone" -msgstr "タイムゾーン" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103 -msgid "Continent" -msgstr "大陸" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103 -msgid "City" -msgstr "町" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:164 -msgid "Disable session cookie hijacking protection" -msgstr "不正ログイン防止のためのセッションクッキーを無効化" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:166 -msgid "Check this if you get disconnected or if your IP address changes often" -msgstr "" -"あなたが切断されたり、IPアドレスが頻繁に変わる環境下であるならチェックを入れ" -"てください" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183 -msgid "Private links by default" -msgstr "既定でプライベートリンク" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:184 -msgid "All new links are private by default" -msgstr "すべての新規リンクをプライベートで作成" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199 -msgid "RSS direct links" -msgstr "RSS 直リンク" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:200 -msgid "Check this to use direct URL instead of permalink in feeds" -msgstr "フィードでパーマリンクの代わりに直リンクを使う" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:215 -msgid "Hide public links" -msgstr "公開リンクを隠す" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:216 -msgid "Do not show any links if the user is not logged in" -msgstr "ログインしていないユーザーには何のリンクも表示しない" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:231 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150 -msgid "Check updates" -msgstr "更新を確認" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:232 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152 -msgid "Notify me when a new release is ready" -msgstr "新しいバージョンがリリースされたときに通知" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:247 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169 -msgid "Enable REST API" -msgstr "REST API を有効化" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:248 -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170 -msgid "Allow third party software to use Shaarli such as mobile application" -msgstr "" -"モバイルアプリといったサードパーティーのソフトウェアにShaarliを使用することを" -"許可" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:263 -msgid "API secret" -msgstr "API シークレット" - -#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274 -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199 -msgid "Save" -msgstr "保存" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -msgid "The Daily Shaarli" -msgstr "デイリーSharli" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17 -msgid "1 RSS entry per day" -msgstr "各日1つずつのRSS項目" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37 -msgid "Previous day" -msgstr "前日" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 -msgid "All links of one day in a single page." -msgstr "1日に作成されたすべてのリンクです。" - -#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51 -msgid "Next day" -msgstr "翌日" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25 -msgid "Created:" -msgstr "作成:" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 -msgid "URL" -msgstr "URL" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 -msgid "Title" -msgstr "タイトル" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124 -msgid "Description" -msgstr "説明" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46 -msgid "Tags" -msgstr "タグ" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59 -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168 -msgid "Private" -msgstr "プライベート" - -#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74 -msgid "Apply Changes" -msgstr "変更を適用" - -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -msgid "Export Database" -msgstr "データベースをエクスポート" - -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 -msgid "Selection" -msgstr "選択済み" - -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31 -msgid "All" -msgstr "すべて" - -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 -msgid "Public" -msgstr "公開" - -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52 -msgid "Prepend note permalinks with this Shaarli instance's URL" -msgstr "この Shaarli のインスタンスのURL にノートへのパーマリンクを付け加える" - -#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53 -msgid "Useful to import bookmarks in a web browser" -msgstr "ウェブブラウザーのリンクをインポートするのに有効です" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -msgid "Import Database" -msgstr "データベースをインポート" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23 -msgid "Maximum size allowed:" -msgstr "最大サイズ:" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 -msgid "Visibility" -msgstr "可視性" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 -msgid "Use values from the imported file, default to public" -msgstr "インポート元のファイルの値を使用 (既定は公開リンクとなります)" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 -msgid "Import all bookmarks as private" -msgstr "すべてのブックマーク項目をプライベートリンクとしてインポート" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46 -msgid "Import all bookmarks as public" -msgstr "すべてのブックマーク項目を公開リンクとしてインポート" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57 -msgid "Overwrite existing bookmarks" -msgstr "既に存在しているブックマークを上書き" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58 -msgid "Duplicates based on URL" -msgstr "URL による重複" - -#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72 -msgid "Add default tags" -msgstr "既定のタグを追加" - -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22 -msgid "Install Shaarli" -msgstr "Shaarli をインストール" - -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25 -msgid "It looks like it's the first time you run Shaarli. Please configure it." -msgstr "どうやら Shaarli を初めて起動しているようです。設定してください。" - -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33 -#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147 -msgid "Username" -msgstr "ユーザー名" - -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 -#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:148 -msgid "Password" -msgstr "パスワード" - -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63 -msgid "Shaarli title" -msgstr "Shaarli のタイトル" - -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69 -msgid "My links" -msgstr "自分のリンク" - -#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182 -msgid "Install" -msgstr "インストール" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:80 -msgid "shaare" -msgid_plural "shaares" -msgstr[0] "共有" -msgstr[1] "共有" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18 -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84 -msgid "private link" -msgid_plural "private links" -msgstr[0] "プライベートリンク" -msgstr[1] "プライベートリンク" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:117 -msgid "Search text" -msgstr "文字列で検索" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:124 -#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 -#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64 -#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 -#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74 -msgid "Filter by tag" -msgstr "タグによって分類" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111 -msgid "Nothing found." -msgstr "何も見つかりませんでした。" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:119 -#, php-format -msgid "%s result" -msgid_plural "%s results" -msgstr[0] "%s 件の結果" -msgstr[1] "%s 件の結果" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123 -msgid "for" -msgstr "for" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130 -msgid "tagged" -msgstr "タグ付けされた" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134 -msgid "Remove tag" -msgstr "タグを削除" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143 -msgid "with status" -msgstr "with status" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154 -msgid "without any tag" -msgstr "タグなし" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:174 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42 -msgid "Fold" -msgstr "畳む" - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176 -msgid "Edited: " -msgstr "編集済み: " - -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:180 -msgid "permalink" -msgstr "パーマリンク" +#~ msgid "The page you are trying to reach does not exist or has been deleted." +#~ msgstr "あなたが開こうとしたページは存在しないか、削除されています。" -#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182 -msgid "Add tag" -msgstr "タグを追加" - -#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7 -#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:7 -msgid "Filters" -msgstr "分類" - -#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12 -#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:12 -msgid "Only display private links" -msgstr "プライベートリンクのみを表示" - -#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:15 -msgid "Only display public links" -msgstr "公開リンクのみを表示" - -#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20 -#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:20 -msgid "Filter untagged links" -msgstr "タグ付けされていないリンクで分類" - -#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 -#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76 -#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24 -#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:76 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43 -msgid "Fold all" -msgstr "すべて畳む" - -#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69 -#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:69 -msgid "Links per page" -msgstr "各ページをリンク" - -#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -msgid "" -"You have been banned after too many failed login attempts. Try again later." -msgstr "複数回に渡るログインへの失敗を検出しました。後でまた試してください。" +#~ msgid "404 Not Found" +#~ msgstr "404 ページが存在しません" -#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:151 -msgid "Remember me" -msgstr "パスワードを保存" - -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48 -msgid "by the Shaarli community" -msgstr "by Shaarli コミュニティ" - -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15 -msgid "Documentation" -msgstr "ドキュメント" - -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44 -msgid "Expand" -msgstr "展開する" - -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45 -msgid "Expand all" -msgstr "すべて展開する" - -#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46 -#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46 -msgid "Are you sure you want to delete this link?" -msgstr "本当にこのリンクを削除しますか?" - -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:61 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:86 -msgid "RSS Feed" -msgstr "RSS フィード" - -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66 -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:66 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:102 -msgid "Logout" -msgstr "ログアウト" - -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:169 -msgid "is available" -msgstr "が利用可能" - -#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176 -#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:176 -msgid "Error" -msgstr "エラー" - -#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -msgid "Picture Wall" -msgstr "ピクチャーウォール" - -#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -msgid "pics" -msgstr "画像" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 -msgid "You need to enable Javascript to change plugin loading order." -msgstr "" -"プラグインを読み込む順番を変更するには、Javascriptを有効にする必要がありま" -"す。" +#~ msgid "Updates file path is not set, can't write updates." +#~ msgstr "" +#~ "更新するファイルのパスが指定されていないため、更新を書き込めません。" -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 -msgid "Enabled Plugins" -msgstr "有効なプラグイン" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155 -msgid "No plugin enabled." -msgstr "有効なプラグインはありません。" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73 -msgid "Disable" -msgstr "無効化" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:98 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123 -msgid "Name" -msgstr "名前" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76 -msgid "Order" -msgstr "順序" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86 -msgid "Disabled Plugins" -msgstr "無効なプラグイン" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91 -msgid "No plugin disabled." -msgstr "無効なプラグインはありません。" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:97 -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122 -msgid "Enable" -msgstr "有効化" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134 -msgid "More plugins available" -msgstr "さらに利用できるプラグインがあります" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136 -msgid "in the documentation" -msgstr "ドキュメント内" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150 -msgid "Plugin configuration" -msgstr "プラグイン設定" - -#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:195 -msgid "No parameter available." -msgstr "利用可能な設定項目はありません。" - -#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 -#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 -msgid "tags" -msgstr "タグ" - -#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 -#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 -msgid "List all links with those tags" -msgstr "このタグが付いているリンクをリスト化する" - -#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3 -#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3 -msgid "Sort by:" -msgstr "分類:" - -#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5 -#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:5 -msgid "Cloud" -msgstr "クラウド" - -#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:6 -#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:6 -msgid "Most used" -msgstr "もっとも使われた" - -#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7 -#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:7 -msgid "Alphabetical" -msgstr "アルファベット順" - -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 -msgid "Settings" -msgstr "設定" +#~ msgid "Unable to write updates in " +#~ msgstr "更新を次の項目に書き込めませんでした: " -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 -msgid "Change Shaarli settings: title, timezone, etc." -msgstr "Shaarli の設定を変更: タイトル、タイムゾーンなど。" +#~ msgid "I said: NO. You are banned for the moment. Go away." +#~ msgstr "あなたはこのサーバーからBANされています。" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17 -msgid "Configure your Shaarli" -msgstr "あなたの Shaarli を設定" +#~ msgid "Tag cloud" +#~ msgstr "タグクラウド" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21 -msgid "Enable, disable and configure plugins" -msgstr "プラグインを有効化、無効化、設定する" +#~ msgid "Click to try again." +#~ msgstr "クリックして再度試します。" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 -msgid "Change your password" -msgstr "パスワードを変更" +#~ msgid "Description will be rendered with" +#~ msgstr "説明は次の方法で描画されます:" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 -msgid "Rename or delete a tag in all links" -msgstr "すべてのリンクのタグの名前を変更する、または削除する" +#~ msgid "Markdown syntax documentation" +#~ msgstr "マークダウン形式のドキュメント" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 -msgid "" -"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, " -"delicious...)" -msgstr "" -"Netscape HTML 形式のブックマークをインポートする (Firefox、Chrome、Operaと" -"いったブラウザーが含まれます)" +#~ msgid "Markdown syntax" +#~ msgstr "マークダウン形式" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 -msgid "Import links" -msgstr "リンクをインポート" +#~ msgid "" +#~ "Render shaare description with Markdown syntax.
Warning:\n" +#~ "If your shaared descriptions contained HTML tags before enabling the " +#~ "markdown plugin,\n" +#~ "enabling it might break your page.\n" +#~ "See the README." +#~ msgstr "" +#~ "リンクの説明をマークダウン形式で表示します。
警告:\n" +#~ "リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n" +#~ "正常にページを表示できなくなるかもしれません。\n" +#~ "詳しくは README をご覧ください。" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 -msgid "" -"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, " -"Opera, delicious...)" -msgstr "" -"Netscape HTML 形式のブックマークをエクスポートする (Firefox、Chrome、Operaと" -"いったブラウザーが含まれます)" +#~ msgid "Sorry, nothing to see here." +#~ msgstr "すみませんが、ここには何もありません。" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 -msgid "Export database" -msgstr "リンクをエクスポート" +#~ msgid "URL or leave empty to post a note" +#~ msgstr "URL を入力するか、空欄にするとノートを投稿します" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71 -msgid "" -"Drag one of these button to your bookmarks toolbar or right-click it and " -"\"Bookmark This Link\"" -msgstr "" -"これらのボタンのうち1つををブックマークバーにドラッグするか、右クリックして" -"「このリンクをブックマークに追加」してください" +#~ msgid "Current password" +#~ msgstr "現在のパスワード" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72 -msgid "then click on the bookmarklet in any page you want to share." -msgstr "共有したいページでブックマークレットをクリックしてください。" +#~ msgid "New password" +#~ msgstr "新しいパスワード" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:100 -msgid "" -"Drag this link to your bookmarks toolbar or right-click it and Bookmark This " -"Link" -msgstr "" -"このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを" -"ブックマークに追加」してください" +#~ msgid "Change" +#~ msgstr "変更" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77 -msgid "then click ✚Shaare link button in any page you want to share" -msgstr "✚リンクを共有 ボタンをクリックすることで、どこでもリンクを共有できます" +#~ msgid "Tag" +#~ msgstr "タグ" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108 -msgid "The selected text is too long, it will be truncated." -msgstr "選択された文字列は長すぎるので、一部が切り捨てられます。" +#~ msgid "New name" +#~ msgstr "変更先の名前" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96 -msgid "Shaare link" -msgstr "共有リンク" +#~ msgid "Case sensitive" +#~ msgstr "大文字と小文字を区別" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101 -msgid "" -"Then click ✚Add Note button anytime to start composing a private Note (text " -"post) to your Shaarli" -msgstr "" -"✚ノートを追加 ボタンをクリックすることで、いつでもプライベートノート(テキスト" -"形式)をShaarli上に作成できます" +#~ msgid "Rename" +#~ msgstr "名前を変更" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117 -msgid "Add Note" -msgstr "ノートを追加" +#~ msgid "Delete" +#~ msgstr "削除" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129 -msgid "" -"You need to browse your Shaarli over HTTPS to use this " -"functionality." -msgstr "" -"この機能を使用するには、HTTPS 経由でShaarliに接続してくださ" -"い。" +#~ msgid "You can also edit tags in the" +#~ msgstr "次に含まれるタグを編集することもできます:" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134 -msgid "Add to" -msgstr "次に追加:" +#~ msgid "tag list" +#~ msgstr "タグ一覧" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:145 -msgid "3rd party" -msgstr "サードパーティー" +#~ msgid "title" +#~ msgstr "タイトル" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:153 -msgid "Plugin" -msgstr "プラグイン" +#~ msgid "Home link" +#~ msgstr "ホームのリンク先" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148 -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154 -msgid "plugin" -msgstr "プラグイン" +#~ msgid "Default value" +#~ msgstr "既定の値" -#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175 -msgid "" -"Drag this link to your bookmarks toolbar, or right-click it and choose " -"Bookmark This Link" -msgstr "" -"このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを" -"ブックマークに追加」してください" +#~ msgid "Theme" +#~ msgstr "テーマ" + +#~ msgid "Language" +#~ msgstr "言語" + +#~ msgid "Timezone" +#~ msgstr "タイムゾーン" + +#~ msgid "Continent" +#~ msgstr "大陸" + +#~ msgid "City" +#~ msgstr "町" + +#~ msgid "Disable session cookie hijacking protection" +#~ msgstr "不正ログイン防止のためのセッションクッキーを無効化" + +#~ msgid "" +#~ "Check this if you get disconnected or if your IP address changes often" +#~ msgstr "" +#~ "あなたが切断されたり、IPアドレスが頻繁に変わる環境下であるならチェックを入" +#~ "れてください" + +#~ msgid "Private links by default" +#~ msgstr "既定でプライベートリンク" + +#~ msgid "All new links are private by default" +#~ msgstr "すべての新規リンクをプライベートで作成" + +#~ msgid "RSS direct links" +#~ msgstr "RSS 直リンク" + +#~ msgid "Check this to use direct URL instead of permalink in feeds" +#~ msgstr "フィードでパーマリンクの代わりに直リンクを使う" + +#~ msgid "Hide public links" +#~ msgstr "公開リンクを隠す" + +#~ msgid "Do not show any links if the user is not logged in" +#~ msgstr "ログインしていないユーザーには何のリンクも表示しない" + +#~ msgid "Check updates" +#~ msgstr "更新を確認" + +#~ msgid "Notify me when a new release is ready" +#~ msgstr "新しいバージョンがリリースされたときに通知" + +#~ msgid "Enable REST API" +#~ msgstr "REST API を有効化" + +#~ msgid "Allow third party software to use Shaarli such as mobile application" +#~ msgstr "" +#~ "モバイルアプリといったサードパーティーのソフトウェアにShaarliを使用するこ" +#~ "とを許可" + +#~ msgid "API secret" +#~ msgstr "API シークレット" + +#~ msgid "Save" +#~ msgstr "保存" + +#~ msgid "The Daily Shaarli" +#~ msgstr "デイリーSharli" + +#~ msgid "1 RSS entry per day" +#~ msgstr "各日1つずつのRSS項目" + +#~ msgid "Previous day" +#~ msgstr "前日" + +#~ msgid "All links of one day in a single page." +#~ msgstr "1日に作成されたすべてのリンクです。" + +#~ msgid "Next day" +#~ msgstr "翌日" + +#~ msgid "Created:" +#~ msgstr "作成:" + +#~ msgid "URL" +#~ msgstr "URL" + +#~ msgid "Title" +#~ msgstr "タイトル" + +#~ msgid "Description" +#~ msgstr "説明" + +#~ msgid "Tags" +#~ msgstr "タグ" + +#~ msgid "Private" +#~ msgstr "プライベート" + +#~ msgid "Apply Changes" +#~ msgstr "変更を適用" + +#~ msgid "Export Database" +#~ msgstr "データベースをエクスポート" + +#~ msgid "Selection" +#~ msgstr "選択済み" + +#~ msgid "All" +#~ msgstr "すべて" + +#~ msgid "Public" +#~ msgstr "公開" + +#~ msgid "Prepend note permalinks with this Shaarli instance's URL" +#~ msgstr "" +#~ "この Shaarli のインスタンスのURL にノートへのパーマリンクを付け加える" + +#~ msgid "Useful to import bookmarks in a web browser" +#~ msgstr "ウェブブラウザーのリンクをインポートするのに有効です" + +#~ msgid "Import Database" +#~ msgstr "データベースをインポート" + +#~ msgid "Maximum size allowed:" +#~ msgstr "最大サイズ:" + +#~ msgid "Visibility" +#~ msgstr "可視性" + +#~ msgid "Use values from the imported file, default to public" +#~ msgstr "インポート元のファイルの値を使用 (既定は公開リンクとなります)" + +#~ msgid "Import all bookmarks as public" +#~ msgstr "すべてのブックマーク項目を公開リンクとしてインポート" + +#~ msgid "Overwrite existing bookmarks" +#~ msgstr "既に存在しているブックマークを上書き" + +#~ msgid "Duplicates based on URL" +#~ msgstr "URL による重複" + +#~ msgid "Add default tags" +#~ msgstr "既定のタグを追加" + +#~ msgid "Install Shaarli" +#~ msgstr "Shaarli をインストール" + +#~ msgid "" +#~ "It looks like it's the first time you run Shaarli. Please configure it." +#~ msgstr "どうやら Shaarli を初めて起動しているようです。設定してください。" + +#~ msgid "Username" +#~ msgstr "ユーザー名" + +#~ msgid "Password" +#~ msgstr "パスワード" + +#~ msgid "Shaarli title" +#~ msgstr "Shaarli のタイトル" + +#~ msgid "My links" +#~ msgstr "自分のリンク" + +#~ msgid "Install" +#~ msgstr "インストール" + +#~ msgid "shaare" +#~ msgid_plural "shaares" +#~ msgstr[0] "共有" +#~ msgstr[1] "共有" + +#~ msgid "private link" +#~ msgid_plural "private links" +#~ msgstr[0] "プライベートリンク" +#~ msgstr[1] "プライベートリンク" + +#~ msgid "Search text" +#~ msgstr "文字列で検索" + +#~ msgid "Filter by tag" +#~ msgstr "タグによって分類" + +#~ msgid "Nothing found." +#~ msgstr "何も見つかりませんでした。" + +#~ msgid "%s result" +#~ msgid_plural "%s results" +#~ msgstr[0] "%s 件の結果" +#~ msgstr[1] "%s 件の結果" + +#~ msgid "for" +#~ msgstr "for" + +#~ msgid "tagged" +#~ msgstr "タグ付けされた" + +#~ msgid "Remove tag" +#~ msgstr "タグを削除" + +#~ msgid "with status" +#~ msgstr "with status" + +#~ msgid "without any tag" +#~ msgstr "タグなし" + +#~ msgid "Fold" +#~ msgstr "畳む" + +#~ msgid "Edited: " +#~ msgstr "編集済み: " + +#~ msgid "permalink" +#~ msgstr "パーマリンク" + +#~ msgid "Add tag" +#~ msgstr "タグを追加" + +#~ msgid "Filters" +#~ msgstr "分類" + +#~ msgid "Only display private links" +#~ msgstr "プライベートリンクのみを表示" + +#~ msgid "Only display public links" +#~ msgstr "公開リンクのみを表示" + +#~ msgid "Filter untagged links" +#~ msgstr "タグ付けされていないリンクで分類" + +#~ msgid "Fold all" +#~ msgstr "すべて畳む" + +#~ msgid "Links per page" +#~ msgstr "各ページをリンク" + +#~ msgid "Remember me" +#~ msgstr "パスワードを保存" + +#~ msgid "by the Shaarli community" +#~ msgstr "by Shaarli コミュニティ" + +#~ msgid "Documentation" +#~ msgstr "ドキュメント" + +#~ msgid "Expand" +#~ msgstr "展開する" + +#~ msgid "Expand all" +#~ msgstr "すべて展開する" + +#~ msgid "Are you sure you want to delete this link?" +#~ msgstr "本当にこのリンクを削除しますか?" + +#~ msgid "RSS Feed" +#~ msgstr "RSS フィード" + +#~ msgid "Logout" +#~ msgstr "ログアウト" + +#~ msgid "is available" +#~ msgstr "が利用可能" + +#~ msgid "Error" +#~ msgstr "エラー" + +#~ msgid "Picture Wall" +#~ msgstr "ピクチャーウォール" + +#~ msgid "pics" +#~ msgstr "画像" + +#~ msgid "You need to enable Javascript to change plugin loading order." +#~ msgstr "" +#~ "プラグインを読み込む順番を変更するには、Javascriptを有効にする必要がありま" +#~ "す。" + +#~ msgid "Enabled Plugins" +#~ msgstr "有効なプラグイン" + +#~ msgid "No plugin enabled." +#~ msgstr "有効なプラグインはありません。" + +#~ msgid "Disable" +#~ msgstr "無効化" + +#~ msgid "Name" +#~ msgstr "名前" + +#~ msgid "Order" +#~ msgstr "順序" + +#~ msgid "Disabled Plugins" +#~ msgstr "無効なプラグイン" + +#~ msgid "No plugin disabled." +#~ msgstr "無効なプラグインはありません。" + +#~ msgid "Enable" +#~ msgstr "有効化" + +#~ msgid "More plugins available" +#~ msgstr "さらに利用できるプラグインがあります" + +#~ msgid "in the documentation" +#~ msgstr "ドキュメント内" + +#~ msgid "No parameter available." +#~ msgstr "利用可能な設定項目はありません。" + +#~ msgid "tags" +#~ msgstr "タグ" + +#~ msgid "List all links with those tags" +#~ msgstr "このタグが付いているリンクをリスト化する" + +#~ msgid "Sort by:" +#~ msgstr "分類:" + +#~ msgid "Cloud" +#~ msgstr "クラウド" + +#~ msgid "Most used" +#~ msgstr "もっとも使われた" + +#~ msgid "Alphabetical" +#~ msgstr "アルファベット順" + +#~ msgid "Settings" +#~ msgstr "設定" + +#~ msgid "Change Shaarli settings: title, timezone, etc." +#~ msgstr "Shaarli の設定を変更: タイトル、タイムゾーンなど。" + +#~ msgid "Configure your Shaarli" +#~ msgstr "あなたの Shaarli を設定" + +#~ msgid "Enable, disable and configure plugins" +#~ msgstr "プラグインを有効化、無効化、設定する" + +#~ msgid "Change your password" +#~ msgstr "パスワードを変更" + +#~ msgid "Rename or delete a tag in all links" +#~ msgstr "すべてのリンクのタグの名前を変更する、または削除する" + +#~ msgid "" +#~ "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, " +#~ "delicious...)" +#~ msgstr "" +#~ "Netscape HTML 形式のブックマークをインポートする (Firefox、Chrome、Operaと" +#~ "いったブラウザーが含まれます)" + +#~ msgid "Import links" +#~ msgstr "リンクをインポート" + +#~ msgid "" +#~ "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, " +#~ "Opera, delicious...)" +#~ msgstr "" +#~ "Netscape HTML 形式のブックマークをエクスポートする (Firefox、Chrome、Opera" +#~ "といったブラウザーが含まれます)" + +#~ msgid "Export database" +#~ msgstr "リンクをエクスポート" + +#~ msgid "" +#~ "Drag one of these button to your bookmarks toolbar or right-click it and " +#~ "\"Bookmark This Link\"" +#~ msgstr "" +#~ "これらのボタンのうち1つををブックマークバーにドラッグするか、右クリックし" +#~ "て「このリンクをブックマークに追加」してください" + +#~ msgid "then click on the bookmarklet in any page you want to share." +#~ msgstr "共有したいページでブックマークレットをクリックしてください。" + +#~ msgid "" +#~ "Drag this link to your bookmarks toolbar or right-click it and Bookmark " +#~ "This Link" +#~ msgstr "" +#~ "このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを" +#~ "ブックマークに追加」してください" + +#~ msgid "then click ✚Shaare link button in any page you want to share" +#~ msgstr "" +#~ "✚リンクを共有 ボタンをクリックすることで、どこでもリンクを共有できます" + +#~ msgid "The selected text is too long, it will be truncated." +#~ msgstr "選択された文字列は長すぎるので、一部が切り捨てられます。" + +#~ msgid "Shaare link" +#~ msgstr "共有リンク" + +#~ msgid "" +#~ "Then click ✚Add Note button anytime to start composing a private Note " +#~ "(text post) to your Shaarli" +#~ msgstr "" +#~ "✚ノートを追加 ボタンをクリックすることで、いつでもプライベートノート(テキ" +#~ "スト形式)をShaarli上に作成できます" + +#~ msgid "Add Note" +#~ msgstr "ノートを追加" + +#~ msgid "" +#~ "You need to browse your Shaarli over HTTPS to use this " +#~ "functionality." +#~ msgstr "" +#~ "この機能を使用するには、HTTPS 経由でShaarliに接続してくだ" +#~ "さい。" + +#~ msgid "Add to" +#~ msgstr "次に追加:" + +#~ msgid "3rd party" +#~ msgstr "サードパーティー" + +#~ msgid "Plugin" +#~ msgstr "プラグイン" + +#~ msgid "plugin" +#~ msgstr "プラグイン" + +#~ msgid "" +#~ "Drag this link to your bookmarks toolbar, or right-click it and choose " +#~ "Bookmark This Link" +#~ msgstr "" +#~ "このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを" +#~ "ブックマークに追加」してください" diff --git a/index.php b/index.php index b10397dda..1eb7659af 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,5 @@ get('resource.log')), + !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG, + ['filename' => basename($conf->get('resource.log'))] +); $sessionManager = new SessionManager($_SESSION, $conf, session_save_path()); $sessionManager->initialize(); $cookieManager = new CookieManager($_COOKIE); -$loginManager = new LoginManager($conf, $sessionManager, $cookieManager); +$banManager = new BanManager( + $conf->get('security.trusted_proxies', []), + $conf->get('security.ban_after'), + $conf->get('security.ban_duration'), + $conf->get('resource.ban_file', 'data/ipbans.php'), + $logger +); +$loginManager = new LoginManager($conf, $sessionManager, $cookieManager, $banManager, $logger); $loginManager->generateStaySignedInToken($_SERVER['REMOTE_ADDR']); // Sniff browser language and set date format accordingly. @@ -62,16 +78,16 @@ new Languages(setlocale(LC_MESSAGES, 0), $conf); $conf->setEmpty('general.timezone', date_default_timezone_get()); -$conf->setEmpty('general.title', t('Shared bookmarks on '). escape(index_url($_SERVER))); +$conf->setEmpty('general.title', t('Shared bookmarks on ') . escape(index_url($_SERVER))); -RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory +RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl') . '/' . $conf->get('resource.theme') . '/'; // template directory RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory date_default_timezone_set($conf->get('general.timezone', 'UTC')); $loginManager->checkLoginState(client_ip_id($_SERVER)); -$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager); +$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager, $logger); $container = $containerBuilder->build(); $app = new App($container); @@ -110,13 +126,16 @@ $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save'); $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index'); $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save'); - $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:addShaare'); - $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayCreateForm'); - $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayEditForm'); - $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:save'); - $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ManageShaareController:deleteBookmark'); - $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ManageShaareController:changeVisibility'); - $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark'); + $this->post('/tags/change-separator', '\Shaarli\Front\Controller\Admin\ManageTagController:changeSeparator'); + $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare'); + $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm'); + $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm'); + $this->get('/shaare/private/{hash}', '\Shaarli\Front\Controller\Admin\ShaareManageController:sharePrivate'); + $this->post('/shaare-batch', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateBatchForms'); + $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:save'); + $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ShaareManageController:deleteBookmark'); + $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ShaareManageController:changeVisibility'); + $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ShaareManageController:pinBookmark'); $this->patch( '/shaare/{id:[0-9]+}/update-thumbnail', '\Shaarli\Front\Controller\Admin\ThumbnailsController:ajaxUpdate' @@ -128,8 +147,10 @@ $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index'); $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save'); $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken'); + $this->get('/server', '\Shaarli\Front\Controller\Admin\ServerController:index'); + $this->get('/clear-cache', '\Shaarli\Front\Controller\Admin\ServerController:clearCache'); $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'); @@ -151,6 +172,12 @@ $this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory'); })->add('\Shaarli\Api\ApiMiddleware'); -$response = $app->run(true); - -$app->respond($response); +try { + $response = $app->run(true); + $app->respond($response); +} catch (Throwable $e) { + die(nl2br( + 'An unexpected error happened, and the error template could not be displayed.' . PHP_EOL . PHP_EOL . + exception2text($e) + )); +} diff --git a/init.php b/init.php index f0b843680..d84627129 100644 --- a/init.php +++ b/init.php @@ -2,7 +2,7 @@ require_once __DIR__ . '/vendor/autoload.php'; -use Shaarli\ApplicationUtils; +use Shaarli\Helper\ApplicationUtils; use Shaarli\Security\SessionManager; // Set 'UTC' as the default timezone if it is not defined in php.ini @@ -60,6 +60,7 @@ ini_set('session.use_trans_sid', false); define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE)); +define('SHAARLI_MUTEX_FILE', __FILE__); session_name('shaarli'); // Start session if needed (Some server auto-start sessions). diff --git a/package.json b/package.json index 8a24512a4..b879b2235 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/phpcs.xml b/phpcs.xml index 29b95d56d..c559e35da 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -5,13 +5,18 @@ index.php application plugins - tests + */*.css */*.js - - + + + + + + index.php + diff --git a/plugins/addlink_toolbar/addlink_toolbar.php b/plugins/addlink_toolbar/addlink_toolbar.php index ab6ed6de0..80b1dd95b 100644 --- a/plugins/addlink_toolbar/addlink_toolbar.php +++ b/plugins/addlink_toolbar/addlink_toolbar.php @@ -17,26 +17,26 @@ function hook_addlink_toolbar_render_header($data) { if ($data['_PAGE_'] == TemplatePage::LINKLIST && $data['_LOGGEDIN_'] === true) { - $form = array( - 'attr' => array( + $form = [ + 'attr' => [ 'method' => 'GET', 'action' => $data['_BASE_PATH_'] . '/admin/shaare', 'name' => 'addform', 'class' => 'addform', - ), - 'inputs' => array( - array( + ], + 'inputs' => [ + [ 'type' => 'text', 'name' => 'post', 'placeholder' => t('URI'), - ), - array( + ], + [ 'type' => 'submit', 'value' => t('Add link'), 'class' => 'bigbutton', - ), - ), - ); + ], + ], + ]; $data['fields_toolbar'][] = $form; } diff --git a/plugins/archiveorg/archiveorg.php b/plugins/archiveorg/archiveorg.php index 922b5966f..88f2b6533 100644 --- a/plugins/archiveorg/archiveorg.php +++ b/plugins/archiveorg/archiveorg.php @@ -1,4 +1,5 @@ get('plugins.'. $placeholder, '')); + $value = trim($conf->get('plugins.' . $placeholder, '')); if (strlen($value) > 0) { $params[$placeholder] = $value; } } if (empty($params)) { - $error = t('Default colors plugin error: '. + $error = t('Default colors plugin error: ' . 'This plugin is active and no custom color is configured.'); return [$error]; } @@ -56,7 +56,7 @@ function default_colors_init($conf) function hook_default_colors_render_includes($data) { $file = PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css'; - if (file_exists($file )) { + if (file_exists($file)) { $data['css_files'][] = $file ; } @@ -75,7 +75,7 @@ function default_colors_generate_css_file($params): void $content = ''; foreach (DEFAULT_COLORS_PLACEHOLDERS as $rule) { $content .= !empty($params[$rule]) - ? default_colors_format_css_rule($params, $rule) .';'. PHP_EOL + ? default_colors_format_css_rule($params, $rule) . ';' . PHP_EOL : ''; } @@ -99,8 +99,8 @@ function default_colors_format_css_rule($data, $parameter) } $key = str_replace('DEFAULT_COLORS_', '', $parameter); - $key = str_replace('_', '-', strtolower($key)) .'-color'; - return ' --'. $key .': '. $data[$parameter]; + $key = str_replace('_', '-', strtolower($key)) . '-color'; + return ' --' . $key . ': ' . $data[$parameter]; } diff --git a/plugins/demo_plugin/demo_plugin.php b/plugins/demo_plugin/demo_plugin.php index defb01f7e..22d27b682 100644 --- a/plugins/demo_plugin/demo_plugin.php +++ b/plugins/demo_plugin/demo_plugin.php @@ -1,4 +1,5 @@ array ( + $button = [ + 'attr' => [ 'href' => '#', 'class' => 'mybutton', 'title' => 'hover me', - ), + ], 'html' => 'DEMO buttons toolbar', - ); + ]; $data['buttons_toolbar'][] = $button; } @@ -115,29 +116,29 @@ function hook_demo_plugin_render_header($data) * * */ - $form = array( - 'attr' => array( + $form = [ + 'attr' => [ 'method' => 'GET', 'action' => $data['_BASE_PATH_'] . '/', 'class' => 'addform', - ), - 'inputs' => array( - array( + ], + 'inputs' => [ + [ 'type' => 'text', 'name' => 'demo', 'placeholder' => 'demo', - ) - ) - ); + ] + ] + ]; $data['fields_toolbar'][] = $form; } // Another button always displayed - $button = array( - 'attr' => array( + $button = [ + 'attr' => [ 'href' => '#', - ), + ], 'html' => 'Demo', - ); + ]; $data['buttons_toolbar'][] = $button; return $data; @@ -187,7 +188,7 @@ function hook_demo_plugin_render_includes($data) function hook_demo_plugin_render_footer($data) { // Footer text - $data['text'][] = '
'. demo_plugin_t('Shaarli is now enhanced by the awesome demo_plugin.'); + $data['text'][] = '
' . demo_plugin_t('Shaarli is now enhanced by the awesome demo_plugin.'); // Free elements at the end of the page. $data['endofpage'][] = '' . @@ -229,13 +230,13 @@ function hook_demo_plugin_render_linklist($data) * and a mandatory `html` key, which contains its value. * It's also recommended to add key 'on' or 'off' for theme rendering. */ - $action = array( - 'attr' => array( + $action = [ + 'attr' => [ 'href' => '?up', 'title' => 'Uppercase!', - ), + ], 'html' => '←', - ); + ]; if (isset($_GET['up'])) { // Manipulate link data @@ -275,7 +276,7 @@ function hook_demo_plugin_render_linklist($data) function hook_demo_plugin_render_editlink($data) { // Load HTML into a string - $html = file_get_contents(PluginManager::$PLUGINS_PATH .'/demo_plugin/field.html'); + $html = file_get_contents(PluginManager::$PLUGINS_PATH . '/demo_plugin/field.html'); // Replace value in HTML if it exists in $data if (!empty($data['link']['stuff'])) { diff --git a/plugins/isso/isso.php b/plugins/isso/isso.php index 79e7380b6..a54509892 100644 --- a/plugins/isso/isso.php +++ b/plugins/isso/isso.php @@ -19,9 +19,9 @@ function isso_init($conf) { $issoUrl = $conf->get('plugins.ISSO_SERVER'); if (empty($issoUrl)) { - $error = t('Isso plugin error: '. + $error = t('Isso plugin error: ' . 'Please define the "ISSO_SERVER" setting in the plugin administration page.'); - return array($error); + return [$error]; } } @@ -49,12 +49,12 @@ function hook_isso_render_linklist($data, $conf) $isso = sprintf($issoHtml, $issoUrl, $issoUrl, $link['id'], $link['id']); $data['plugin_end_zone'][] = $isso; } else { - $button = ''; + $button = ''; // For the default theme we use a FontAwesome icon which is better than an image if ($conf->get('resource.theme') === 'default') { $button .= ''; } else { - $button .= ' array( + $playvideo = [ + 'attr' => [ 'href' => '#', 'title' => t('Video player'), 'id' => 'playvideos', - ), - 'html' => '► '. t('Play Videos') - ); + ], + 'html' => '► ' . t('Play Videos') + ]; $data['buttons_toolbar'][] = $playvideo; } diff --git a/plugins/pubsubhubbub/pubsubhubbub.php b/plugins/pubsubhubbub/pubsubhubbub.php index 8fe6799ce..299b84fb1 100644 --- a/plugins/pubsubhubbub/pubsubhubbub.php +++ b/plugins/pubsubhubbub/pubsubhubbub.php @@ -42,7 +42,7 @@ function pubsubhubbub_init($conf) function hook_pubsubhubbub_render_feed($data, $conf) { $feedType = $data['_PAGE_'] == TemplatePage::FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM; - $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml'); + $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.' . $feedType . '.xml'); $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL')); return $data; @@ -59,10 +59,10 @@ function hook_pubsubhubbub_render_feed($data, $conf) */ function hook_pubsubhubbub_save_link($data, $conf) { - $feeds = array( - index_url($_SERVER) .'feed/atom', - index_url($_SERVER) .'feed/rss', - ); + $feeds = [ + index_url($_SERVER) . 'feed/atom', + index_url($_SERVER) . 'feed/rss', + ]; $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post'; try { @@ -87,11 +87,11 @@ function hook_pubsubhubbub_save_link($data, $conf) */ function nocurl_http_post($url, $postString) { - $params = array('http' => array( + $params = ['http' => [ 'method' => 'POST', 'content' => $postString, 'user_agent' => 'PubSubHubbub-Publisher-PHP/1.0', - )); + ]]; $context = stream_context_create($params); $fp = @fopen($url, 'rb', false, $context); diff --git a/plugins/qrcode/qrcode.php b/plugins/qrcode/qrcode.php index 95499e39f..2ae10476f 100644 --- a/plugins/qrcode/qrcode.php +++ b/plugins/qrcode/qrcode.php @@ -1,4 +1,5 @@ '1.x', 2 => '2.x', - ); + ]; /** * @var array Static reference to WB endpoint according to the API version. * - key: version name. * - value: endpoint. */ - private static $wallabagEndpoints = array( + private static $wallabagEndpoints = [ '1.x' => '?plainurl=', '2.x' => 'bookmarklet?url=', - ); + ]; /** * @var string Wallabag user instance URL. diff --git a/plugins/wallabag/wallabag.php b/plugins/wallabag/wallabag.php index 805c1ad98..f2003cb99 100644 --- a/plugins/wallabag/wallabag.php +++ b/plugins/wallabag/wallabag.php @@ -1,4 +1,5 @@ get('plugins.WALLABAG_URL'); if (empty($wallabagUrl)) { - $error = t('Wallabag plugin error: '. + $error = t('Wallabag plugin error: ' . 'Please define the "WALLABAG_URL" setting in the plugin administration page.'); - return array($error); + return [$error]; } + $conf->setEmpty('plugins.WALLABAG_URL', '2'); } /** @@ -35,7 +37,7 @@ function wallabag_init($conf) function hook_wallabag_render_linklist($data, $conf) { $wallabagUrl = $conf->get('plugins.WALLABAG_URL'); - if (empty($wallabagUrl)) { + if (empty($wallabagUrl) || !$data['_LOGGEDIN_']) { return $data; } @@ -45,13 +47,13 @@ function hook_wallabag_render_linklist($data, $conf) $wallabagHtml = file_get_contents(PluginManager::$PLUGINS_PATH . '/wallabag/wallabag.html'); $linkTitle = t('Save to wallabag'); - $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH; + $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH; foreach ($data['links'] as &$value) { $wallabag = sprintf( $wallabagHtml, $wallabagInstance->getWallabagUrl(), - urlencode($value['url']), + urlencode(unescape($value['url'])), $path, $linkTitle ); diff --git a/tests/HistoryTest.php b/tests/HistoryTest.php index 6dc0e5b7a..e810104ea 100644 --- a/tests/HistoryTest.php +++ b/tests/HistoryTest.php @@ -89,14 +89,6 @@ public function testAddLink() $this->assertEquals(History::CREATED, $actual['event']); $this->assertTrue(new DateTime('-2 seconds') < $actual['datetime']); $this->assertEquals(1, $actual['id']); - - $history = new History(self::$historyFilePath); - $bookmark = (new Bookmark())->setId('str'); - $history->addLink($bookmark); - $actual = $history->getHistory()[0]; - $this->assertEquals(History::CREATED, $actual['event']); - $this->assertTrue(new DateTime('-2 seconds') < $actual['datetime']); - $this->assertEquals('str', $actual['id']); } // /** diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php index 6e787d7f1..59dca75f5 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -63,41 +63,25 @@ protected function getLastLogEntry() } /** - * Log a message to a file - IPv4 client address + * Format a log a message - IPv4 client address */ - public function testLogmIp4() + public function testFormatLogIp4() { - $logMessage = 'IPv4 client connected'; - logm(self::$testLogFile, '127.0.0.1', $logMessage); - list($date, $ip, $message) = $this->getLastLogEntry(); + $message = 'IPv4 client connected'; + $log = format_log($message, '127.0.0.1'); - $this->assertInstanceOf( - 'DateTime', - DateTime::createFromFormat(self::$dateFormat, $date) - ); - $this->assertTrue( - filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false - ); - $this->assertEquals($logMessage, $message); + static::assertSame('- 127.0.0.1 - IPv4 client connected', $log); } /** - * Log a message to a file - IPv6 client address + * Format a log a message - IPv6 client address */ - public function testLogmIp6() + public function testFormatLogIp6() { - $logMessage = 'IPv6 client connected'; - logm(self::$testLogFile, '2001:db8::ff00:42:8329', $logMessage); - list($date, $ip, $message) = $this->getLastLogEntry(); + $message = 'IPv6 client connected'; + $log = format_log($message, '2001:db8::ff00:42:8329'); - $this->assertInstanceOf( - 'DateTime', - DateTime::createFromFormat(self::$dateFormat, $date) - ); - $this->assertTrue( - filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false - ); - $this->assertEquals($logMessage, $message); + static::assertSame('- 2001:db8::ff00:42:8329 - IPv6 client connected', $log); } /** diff --git a/tests/api/controllers/info/InfoTest.php b/tests/api/controllers/info/InfoTest.php index 1598e1e8a..10b29ab25 100644 --- a/tests/api/controllers/info/InfoTest.php +++ b/tests/api/controllers/info/InfoTest.php @@ -1,6 +1,7 @@ conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -58,7 +60,7 @@ protected function setUp(): void $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new BookmarkFileService($this->conf, $history, true); + $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true); $this->container['history'] = null; $this->controller = new Info($this->container); diff --git a/tests/api/controllers/links/DeleteLinkTest.php b/tests/api/controllers/links/DeleteLinkTest.php index cf9464f07..805c9be33 100644 --- a/tests/api/controllers/links/DeleteLinkTest.php +++ b/tests/api/controllers/links/DeleteLinkTest.php @@ -3,6 +3,7 @@ namespace Shaarli\Api\Controllers; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; use Shaarli\History; @@ -53,11 +54,15 @@ class DeleteLinkTest extends \Shaarli\TestCase */ protected $controller; + /** @var NoMutex */ + protected $mutex; + /** * Before each test, instantiate a new Api with its config, plugins and bookmarks. */ protected function setUp(): void { + $this->mutex = new NoMutex(); $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -65,7 +70,7 @@ protected function setUp(): void $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); $this->history = new History(self::$testHistory); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->container = new Container(); $this->container['conf'] = $this->conf; @@ -100,7 +105,7 @@ public function testDeleteLinkValid() $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->assertFalse($this->bookmarkService->exists($id)); $historyEntry = $this->history->getHistory()[0]; diff --git a/tests/api/controllers/links/GetLinkIdTest.php b/tests/api/controllers/links/GetLinkIdTest.php index 99dc606fb..1ec56ef3c 100644 --- a/tests/api/controllers/links/GetLinkIdTest.php +++ b/tests/api/controllers/links/GetLinkIdTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Api\Controllers; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; @@ -57,6 +58,7 @@ class GetLinkIdTest extends \Shaarli\TestCase */ protected function setUp(): void { + $mutex = new NoMutex(); $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -65,7 +67,7 @@ protected function setUp(): void $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new BookmarkFileService($this->conf, $history, true); + $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true); $this->container['history'] = null; $this->controller = new Links($this->container); diff --git a/tests/api/controllers/links/GetLinksTest.php b/tests/api/controllers/links/GetLinksTest.php index ca1bfc636..b1c46ee28 100644 --- a/tests/api/controllers/links/GetLinksTest.php +++ b/tests/api/controllers/links/GetLinksTest.php @@ -1,6 +1,7 @@ conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -65,7 +67,7 @@ protected function setUp(): void $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new BookmarkFileService($this->conf, $history, true); + $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true); $this->container['history'] = null; $this->controller = new Links($this->container); @@ -396,7 +398,7 @@ public function testGetLinksSearchTags() $response = $this->controller->getLinks($request, new Response()); $this->assertEquals(200, $response->getStatusCode()); $data = json_decode((string) $response->getBody(), true); - $this->assertEquals(4, count($data)); + $this->assertEquals(5, count($data)); $this->assertEquals(6, $data[0]['id']); // wildcard: placeholder at the middle diff --git a/tests/api/controllers/links/PostLinkTest.php b/tests/api/controllers/links/PostLinkTest.php index fe3de66ff..e12f803be 100644 --- a/tests/api/controllers/links/PostLinkTest.php +++ b/tests/api/controllers/links/PostLinkTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Api\Controllers; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; @@ -72,6 +73,7 @@ class PostLinkTest extends TestCase */ protected function setUp(): void { + $mutex = new NoMutex(); $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -79,7 +81,7 @@ protected function setUp(): void $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); $this->history = new History(self::$testHistory); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true); $this->container = new Container(); $this->container['conf'] = $this->conf; @@ -90,8 +92,8 @@ protected function setUp(): void $mock = $this->createMock(Router::class); $mock->expects($this->any()) - ->method('relativePathFor') - ->willReturn('api/v1/bookmarks/1'); + ->method('pathFor') + ->willReturn('/api/v1/bookmarks/1'); // affect @property-read... seems to work $this->controller->getCi()->router = $mock; @@ -126,7 +128,7 @@ public function testPostLinkMinimal() $response = $this->controller->postLink($request, new Response()); $this->assertEquals(201, $response->getStatusCode()); - $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]); + $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]); $data = json_decode((string) $response->getBody(), true); $this->assertEquals(self::NB_FIELDS_LINK, count($data)); $this->assertEquals(43, $data['id']); @@ -160,6 +162,8 @@ public function testPostLinkFull() 'description' => 'shaare description', 'tags' => ['one', 'two'], 'private' => true, + 'created' => '2015-05-05T12:30:00+03:00', + 'updated' => '2016-06-05T14:32:10+03:00', ]; $env = Environment::mock([ 'REQUEST_METHOD' => 'POST', @@ -171,7 +175,7 @@ public function testPostLinkFull() $response = $this->controller->postLink($request, new Response()); $this->assertEquals(201, $response->getStatusCode()); - $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]); + $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]); $data = json_decode((string) $response->getBody(), true); $this->assertEquals(self::NB_FIELDS_LINK, count($data)); $this->assertEquals(43, $data['id']); @@ -181,10 +185,8 @@ public function testPostLinkFull() $this->assertEquals($link['description'], $data['description']); $this->assertEquals($link['tags'], $data['tags']); $this->assertEquals(true, $data['private']); - $this->assertTrue( - new \DateTime('2 seconds ago') < \DateTime::createFromFormat(\DateTime::ATOM, $data['created']) - ); - $this->assertEquals('', $data['updated']); + $this->assertSame($link['created'], $data['created']); + $this->assertSame($link['updated'], $data['updated']); } /** diff --git a/tests/api/controllers/links/PutLinkTest.php b/tests/api/controllers/links/PutLinkTest.php index a2e87c598..240ee323a 100644 --- a/tests/api/controllers/links/PutLinkTest.php +++ b/tests/api/controllers/links/PutLinkTest.php @@ -3,6 +3,7 @@ namespace Shaarli\Api\Controllers; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; @@ -64,6 +65,7 @@ class PutLinkTest extends \Shaarli\TestCase */ protected function setUp(): void { + $mutex = new NoMutex(); $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -71,7 +73,7 @@ protected function setUp(): void $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); $this->history = new History(self::$testHistory); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true); $this->container = new Container(); $this->container['conf'] = $this->conf; diff --git a/tests/api/controllers/tags/DeleteTagTest.php b/tests/api/controllers/tags/DeleteTagTest.php index 1326eb47a..37f072297 100644 --- a/tests/api/controllers/tags/DeleteTagTest.php +++ b/tests/api/controllers/tags/DeleteTagTest.php @@ -3,6 +3,7 @@ namespace Shaarli\Api\Controllers; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; @@ -54,11 +55,15 @@ class DeleteTagTest extends \Shaarli\TestCase */ protected $controller; + /** @var NoMutex */ + protected $mutex; + /** * Before each test, instantiate a new Api with its config, plugins and bookmarks. */ protected function setUp(): void { + $this->mutex = new NoMutex(); $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -66,7 +71,7 @@ protected function setUp(): void $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); $this->history = new History(self::$testHistory); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->container = new Container(); $this->container['conf'] = $this->conf; @@ -102,7 +107,7 @@ public function testDeleteTagValid() $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $tags = $this->bookmarkService->bookmarksCountPerTag(); $this->assertFalse(isset($tags[$tagName])); @@ -136,7 +141,7 @@ public function testDeleteTagCaseSensitivity() $this->assertEquals(204, $response->getStatusCode()); $this->assertEmpty((string) $response->getBody()); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $tags = $this->bookmarkService->bookmarksCountPerTag(); $this->assertFalse(isset($tags[$tagName])); $this->assertTrue($tags[strtolower($tagName)] > 0); diff --git a/tests/api/controllers/tags/GetTagNameTest.php b/tests/api/controllers/tags/GetTagNameTest.php index 9c05954b4..878de5a42 100644 --- a/tests/api/controllers/tags/GetTagNameTest.php +++ b/tests/api/controllers/tags/GetTagNameTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Api\Controllers; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; @@ -55,6 +56,7 @@ class GetTagNameTest extends \Shaarli\TestCase */ protected function setUp(): void { + $mutex = new NoMutex(); $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -63,7 +65,7 @@ protected function setUp(): void $this->container = new Container(); $this->container['conf'] = $this->conf; - $this->container['db'] = new BookmarkFileService($this->conf, $history, true); + $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true); $this->container['history'] = null; $this->controller = new Tags($this->container); diff --git a/tests/api/controllers/tags/GetTagsTest.php b/tests/api/controllers/tags/GetTagsTest.php index 3459fdfae..b565a8c4d 100644 --- a/tests/api/controllers/tags/GetTagsTest.php +++ b/tests/api/controllers/tags/GetTagsTest.php @@ -1,6 +1,7 @@ conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); $this->refDB->write(self::$testDatastore); $history = new History('sandbox/history.php'); - $this->bookmarkService = new BookmarkFileService($this->conf, $history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $history, $mutex, true); $this->container = new Container(); $this->container['conf'] = $this->conf; diff --git a/tests/api/controllers/tags/PutTagTest.php b/tests/api/controllers/tags/PutTagTest.php index 74edde787..c73f6d3be 100644 --- a/tests/api/controllers/tags/PutTagTest.php +++ b/tests/api/controllers/tags/PutTagTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Api\Controllers; +use malkusch\lock\mutex\NoMutex; use Shaarli\Api\Exceptions\ApiBadParametersException; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\LinkDB; @@ -64,6 +65,7 @@ class PutTagTest extends \Shaarli\TestCase */ protected function setUp(): void { + $mutex = new NoMutex(); $this->conf = new ConfigManager('tests/utils/config/configJson'); $this->conf->set('resource.datastore', self::$testDatastore); $this->refDB = new \ReferenceLinkDB(); @@ -71,7 +73,7 @@ protected function setUp(): void $refHistory = new \ReferenceHistory(); $refHistory->write(self::$testHistory); $this->history = new History(self::$testHistory); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true); $this->container = new Container(); $this->container['conf'] = $this->conf; diff --git a/tests/bookmark/BookmarkArrayTest.php b/tests/bookmark/BookmarkArrayTest.php index ebed9bfca..1953078cd 100644 --- a/tests/bookmark/BookmarkArrayTest.php +++ b/tests/bookmark/BookmarkArrayTest.php @@ -90,19 +90,6 @@ public function testArrayAccessAddBadEntryOffset() $array['nope'] = $bookmark; } - /** - * Test adding a bad entry: invalid ID type - */ - public function testArrayAccessAddBadEntryIdType() - { - $this->expectException(\Shaarli\Bookmark\Exception\InvalidBookmarkException::class); - - $array = new BookmarkArray(); - $bookmark = (new Bookmark())->setId('nope'); - $bookmark->validate(); - $array[] = $bookmark; - } - /** * Test adding a bad entry: ID/offset not consistent */ diff --git a/tests/bookmark/BookmarkFileServiceTest.php b/tests/bookmark/BookmarkFileServiceTest.php index c399822b5..f619aff3f 100644 --- a/tests/bookmark/BookmarkFileServiceTest.php +++ b/tests/bookmark/BookmarkFileServiceTest.php @@ -6,6 +6,7 @@ namespace Shaarli\Bookmark; use DateTime; +use malkusch\lock\mutex\NoMutex; use ReferenceLinkDB; use ReflectionClass; use Shaarli; @@ -52,6 +53,9 @@ class BookmarkFileServiceTest extends TestCase */ protected $privateLinkDB = null; + /** @var NoMutex */ + protected $mutex; + /** * Instantiates public and private LinkDBs with test data * @@ -68,6 +72,8 @@ class BookmarkFileServiceTest extends TestCase */ protected function setUp(): void { + $this->mutex = new NoMutex(); + if (file_exists(self::$testDatastore)) { unlink(self::$testDatastore); } @@ -87,8 +93,8 @@ protected function setUp(): void $this->refDB = new \ReferenceLinkDB(); $this->refDB->write(self::$testDatastore); $this->history = new History('sandbox/history.php'); - $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, false); - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); } /** @@ -105,7 +111,7 @@ public function testDatabaseMigration() $db = self::getMethod('migrate'); $db->invokeArgs($this->privateLinkDB, []); - $db = new \FakeBookmarkService($this->conf, $this->history, true); + $db = new \FakeBookmarkService($this->conf, $this->history, $this->mutex, true); $this->assertInstanceOf(BookmarkArray::class, $db->getBookmarks()); $this->assertEquals($this->refDB->countLinks(), $db->count()); } @@ -174,7 +180,7 @@ public function testAddFull() $this->assertEquals($updated, $bookmark->getUpdated()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(43); $this->assertEquals(43, $bookmark->getId()); @@ -212,7 +218,7 @@ public function testAddMinimal() $this->assertNull($bookmark->getUpdated()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(43); $this->assertEquals(43, $bookmark->getId()); @@ -242,7 +248,7 @@ public function testAddMinimalNoWrite() $this->assertEquals(43, $bookmark->getId()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->privateLinkDB->get(43); } @@ -258,17 +264,6 @@ public function testAddLoggedOut() $this->publicLinkDB->add(new Bookmark()); } - /** - * Test add() method with an entry which is not a bookmark instance - */ - public function testAddNotABookmark() - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Provided data is invalid'); - - $this->privateLinkDB->add(['title' => 'hi!']); - } - /** * Test add() method with a Bookmark already containing an ID */ @@ -314,7 +309,7 @@ public function testSetFull() $this->assertTrue(new \DateTime('5 seconds ago') < $bookmark->getUpdated()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(42); $this->assertEquals(42, $bookmark->getId()); @@ -355,7 +350,7 @@ public function testSetMinimal() $this->assertTrue(new \DateTime('5 seconds ago') < $bookmark->getUpdated()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(42); $this->assertEquals(42, $bookmark->getId()); @@ -388,7 +383,7 @@ public function testSetMinimalNoWrite() $this->assertEquals($title, $bookmark->getTitle()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(42); $this->assertEquals(42, $bookmark->getId()); @@ -406,17 +401,6 @@ public function testSetLoggedOut() $this->publicLinkDB->set(new Bookmark()); } - /** - * Test set() method with an entry which is not a bookmark instance - */ - public function testSetNotABookmark() - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Provided data is invalid'); - - $this->privateLinkDB->set(['title' => 'hi!']); - } - /** * Test set() method with a Bookmark without an ID defined. */ @@ -452,7 +436,7 @@ public function testAddOrSetNew() $this->assertEquals(43, $bookmark->getId()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(43); $this->assertEquals(43, $bookmark->getId()); @@ -472,7 +456,7 @@ public function testAddOrSetExisting() $this->assertEquals($title, $bookmark->getTitle()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(42); $this->assertEquals(42, $bookmark->getId()); @@ -490,17 +474,6 @@ public function testAddOrSetLoggedOut() $this->publicLinkDB->addOrSet(new Bookmark()); } - /** - * Test addOrSet() method with an entry which is not a bookmark instance - */ - public function testAddOrSetNotABookmark() - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Provided data is invalid'); - - $this->privateLinkDB->addOrSet(['title' => 'hi!']); - } - /** * Test addOrSet() method for a bookmark without any field set and without writing the data store */ @@ -515,7 +488,7 @@ public function testAddOrSetMinimalNoWrite() $this->assertEquals($title, $bookmark->getTitle()); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $bookmark = $this->privateLinkDB->get(42); $this->assertEquals(42, $bookmark->getId()); @@ -541,7 +514,7 @@ public function testRemoveExisting() $this->assertInstanceOf(BookmarkNotFoundException::class, $exception); // reload from file - $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true); + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->privateLinkDB->get(42); } @@ -558,17 +531,6 @@ public function testRemoveLoggedOut() $this->publicLinkDB->remove($bookmark); } - /** - * Test remove() method with an entry which is not a bookmark instance - */ - public function testRemoveNotABookmark() - { - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Provided data is invalid'); - - $this->privateLinkDB->remove(['title' => 'hi!']); - } - /** * Test remove() method with a Bookmark with an unknown ID */ @@ -645,7 +607,7 @@ public function testConstructDatastoreNotWriteable() $conf = new ConfigManager('tests/utils/config/configJson'); $conf->set('resource.datastore', 'null/store.db'); - new BookmarkFileService($conf, $this->history, true); + new BookmarkFileService($conf, $this->history, $this->mutex, true); } /** @@ -655,7 +617,7 @@ public function testCheckDBNewLoggedIn() { unlink(self::$testDatastore); $this->assertFileNotExists(self::$testDatastore); - new BookmarkFileService($this->conf, $this->history, true); + new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->assertFileExists(self::$testDatastore); // ensure the correct data has been written @@ -669,7 +631,7 @@ public function testCheckDBNewLoggedOut() { unlink(self::$testDatastore); $this->assertFileNotExists(self::$testDatastore); - $db = new \FakeBookmarkService($this->conf, $this->history, false); + $db = new \FakeBookmarkService($this->conf, $this->history, $this->mutex, false); $this->assertFileNotExists(self::$testDatastore); $this->assertInstanceOf(BookmarkArray::class, $db->getBookmarks()); $this->assertCount(0, $db->getBookmarks()); @@ -702,13 +664,13 @@ public function testReadPrivateDB() */ public function testSave() { - $testDB = new BookmarkFileService($this->conf, $this->history, true); + $testDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $dbSize = $testDB->count(); $bookmark = new Bookmark(); $testDB->add($bookmark); - $testDB = new BookmarkFileService($this->conf, $this->history, true); + $testDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->assertEquals($dbSize + 1, $testDB->count()); } @@ -718,27 +680,11 @@ public function testSave() public function testCountHiddenPublic() { $this->conf->set('privacy.hide_public_links', true); - $linkDB = new BookmarkFileService($this->conf, $this->history, false); + $linkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false); $this->assertEquals(0, $linkDB->count()); } - /** - * List the days for which bookmarks have been posted - */ - public function testDays() - { - $this->assertEquals( - ['20100309', '20100310', '20121206', '20121207', '20130614', '20150310'], - $this->publicLinkDB->days() - ); - - $this->assertEquals( - ['20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'], - $this->privateLinkDB->days() - ); - } - /** * The URL corresponds to an existing entry in the DB */ @@ -786,6 +732,10 @@ public function testAllTags() // They need to be grouped with the first case found - order by date DESC: `sTuff`. 'sTuff' => 2, 'ut' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ], $this->publicLinkDB->bookmarksCountPerTag() ); @@ -814,6 +764,10 @@ public function testAllTags() 'tag3' => 1, 'tag4' => 1, 'ut' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ], $this->privateLinkDB->bookmarksCountPerTag() ); @@ -927,6 +881,37 @@ public function testFilterHashInValid() $this->publicLinkDB->findByHash(''); } + /** + * Test filterHash() on a private bookmark while logged out. + */ + public function testFilterHashPrivateWhileLoggedOut() + { + $this->expectException(BookmarkNotFoundException::class); + $this->expectExceptionMessage('The link you are trying to reach does not exist or has been deleted'); + + $hash = smallHash('20141125_084734' . 6); + + $this->publicLinkDB->findByHash($hash); + } + + /** + * Test filterHash() with private key. + */ + public function testFilterHashWithPrivateKey() + { + $hash = smallHash('20141125_084734' . 6); + $privateKey = 'this is usually auto generated'; + + $bookmark = $this->privateLinkDB->findByHash($hash); + $bookmark->addAdditionalContentEntry('private_key', $privateKey); + $this->privateLinkDB->save(); + + $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false); + $bookmark = $this->privateLinkDB->findByHash($hash, $privateKey); + + static::assertSame(6, $bookmark->getId()); + } + /** * Test linksCountPerTag all tags without filter. * Equal occurrences should be sorted alphabetically. @@ -956,6 +941,10 @@ public function testCountLinkPerTagAllNoFilter() 'tag4' => 1, 'ut' => 1, 'w3c' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ]; $tags = $this->privateLinkDB->bookmarksCountPerTag(); @@ -1054,6 +1043,10 @@ public function testCountTagsNoMarkdown() 'stallman' => 1, 'ut' => 1, 'w3c' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ]; $bookmark = new Bookmark(); $bookmark->setTags(['newTagToCount', BookmarkMarkdownFormatter::NO_MD_TAG]); @@ -1065,33 +1058,105 @@ public function testCountTagsNoMarkdown() } /** - * Test filterDay while logged in + * Test find by dates in the middle of the datastore (sorted by dates) with a single bookmark as a result. */ - public function testFilterDayLoggedIn(): void + public function testFilterByDateMidTimePeriodSingleBookmark(): void { - $bookmarks = $this->privateLinkDB->filterDay('20121206'); - $expectedIds = [4, 9, 1, 0]; + $bookmarks = $this->privateLinkDB->findByDate( + DateTime::createFromFormat('Ymd_His', '20121206_150000'), + DateTime::createFromFormat('Ymd_His', '20121206_160000'), + $before, + $after + ); - static::assertCount(4, $bookmarks); - foreach ($bookmarks as $bookmark) { - $i = ($i ?? -1) + 1; - static::assertSame($expectedIds[$i], $bookmark->getId()); - } + static::assertCount(1, $bookmarks); + + static::assertSame(9, $bookmarks[0]->getId()); + static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before); + static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_172539'), $after); } /** - * Test filterDay while logged out + * Test find by dates in the middle of the datastore (sorted by dates) with a multiple bookmarks as a result. */ - public function testFilterDayLoggedOut(): void + public function testFilterByDateMidTimePeriodMultipleBookmarks(): void { - $bookmarks = $this->publicLinkDB->filterDay('20121206'); - $expectedIds = [4, 9, 1]; + $bookmarks = $this->privateLinkDB->findByDate( + DateTime::createFromFormat('Ymd_His', '20121206_150000'), + DateTime::createFromFormat('Ymd_His', '20121206_180000'), + $before, + $after + ); - static::assertCount(3, $bookmarks); - foreach ($bookmarks as $bookmark) { - $i = ($i ?? -1) + 1; - static::assertSame($expectedIds[$i], $bookmark->getId()); - } + static::assertCount(2, $bookmarks); + + static::assertSame(1, $bookmarks[0]->getId()); + static::assertSame(9, $bookmarks[1]->getId()); + static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before); + static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_182539'), $after); + } + + /** + * Test find by dates at the end of the datastore (sorted by dates). + */ + public function testFilterByDateLastTimePeriod(): void + { + $after = new DateTime(); + $bookmarks = $this->privateLinkDB->findByDate( + DateTime::createFromFormat('Ymd_His', '20150310_114640'), + DateTime::createFromFormat('Ymd_His', '20450101_010101'), + $before, + $after + ); + + static::assertCount(1, $bookmarks); + + static::assertSame(41, $bookmarks[0]->getId()); + static::assertEquals(DateTime::createFromFormat('Ymd_His', '20150310_114633'), $before); + static::assertNull($after); + } + + /** + * Test find by dates at the beginning of the datastore (sorted by dates). + */ + public function testFilterByDateFirstTimePeriod(): void + { + $before = new DateTime(); + $bookmarks = $this->privateLinkDB->findByDate( + DateTime::createFromFormat('Ymd_His', '20000101_101010'), + DateTime::createFromFormat('Ymd_His', '20100309_110000'), + $before, + $after + ); + + static::assertCount(1, $bookmarks); + + static::assertSame(11, $bookmarks[0]->getId()); + static::assertNull($before); + static::assertEquals(DateTime::createFromFormat('Ymd_His', '20100310_101010'), $after); + } + + /** + * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead. + */ + public function testGetLatestWithSticky(): void + { + $bookmark = $this->publicLinkDB->getLatest(); + + static::assertSame(41, $bookmark->getId()); + } + + /** + * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead. + */ + public function testGetLatestEmptyDatastore(): void + { + unlink($this->conf->get('resource.datastore')); + $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false); + + $bookmark = $this->publicLinkDB->getLatest(); + + static::assertNull($bookmark); } /** diff --git a/tests/bookmark/BookmarkFilterTest.php b/tests/bookmark/BookmarkFilterTest.php index 48c7f8247..835674f2d 100644 --- a/tests/bookmark/BookmarkFilterTest.php +++ b/tests/bookmark/BookmarkFilterTest.php @@ -2,7 +2,7 @@ namespace Shaarli\Bookmark; -use Exception; +use malkusch\lock\mutex\NoMutex; use ReferenceLinkDB; use Shaarli\Config\ConfigManager; use Shaarli\History; @@ -37,13 +37,14 @@ class BookmarkFilterTest extends TestCase */ public static function setUpBeforeClass(): void { + $mutex = new NoMutex(); $conf = new ConfigManager('tests/utils/config/configJson'); $conf->set('resource.datastore', self::$testDatastore); self::$refDB = new \ReferenceLinkDB(); self::$refDB->write(self::$testDatastore); $history = new History('sandbox/history.php'); - self::$bookmarkService = new \FakeBookmarkService($conf, $history, true); - self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks()); + self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true); + self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks(), $conf); } /** @@ -523,4 +524,43 @@ public function testFilterByHashtag() )) ); } + + /** + * Test search result highlights in every field of bookmark reference #9. + */ + public function testFullTextSearchHighlight(): void + { + $bookmarks = self::$linkFilter->filter( + BookmarkFilter::$FILTER_TEXT, + '"psr-2" coding guide http fig "psr-2/" "This guide" basic standard. coding-style quality assurance' + ); + + static::assertCount(1, $bookmarks); + static::assertArrayHasKey(9, $bookmarks); + + $bookmark = $bookmarks[9]; + $expectedHighlights = [ + 'title' => [ + ['start' => 0, 'end' => 5], // "psr-2" + ['start' => 7, 'end' => 13], // coding + ['start' => 20, 'end' => 25], // guide + ], + 'description' => [ + ['start' => 0, 'end' => 10], // "This guide" + ['start' => 45, 'end' => 50], // basic + ['start' => 58, 'end' => 67], // standard. + ], + 'url' => [ + ['start' => 0, 'end' => 4], // http + ['start' => 15, 'end' => 18], // fig + ['start' => 27, 'end' => 33], // "psr-2/" + ], + 'tags' => [ + ['start' => 0, 'end' => 12], // coding-style + ['start' => 23, 'end' => 30], // quality + ['start' => 31, 'end' => 40], // assurance + ], + ]; + static::assertSame($expectedHighlights, $bookmark->getAdditionalContentEntry('search_highlight')); + } } diff --git a/tests/bookmark/BookmarkInitializerTest.php b/tests/bookmark/BookmarkInitializerTest.php index 25704004e..0c8420ce5 100644 --- a/tests/bookmark/BookmarkInitializerTest.php +++ b/tests/bookmark/BookmarkInitializerTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Bookmark; +use malkusch\lock\mutex\NoMutex; use Shaarli\Config\ConfigManager; use Shaarli\History; use Shaarli\TestCase; @@ -34,11 +35,15 @@ class BookmarkInitializerTest extends TestCase /** @var BookmarkInitializer instance */ protected $initializer; + /** @var NoMutex */ + protected $mutex; + /** * Initialize an empty BookmarkFileService */ public function setUp(): void { + $this->mutex = new NoMutex(); if (file_exists(self::$testDatastore)) { unlink(self::$testDatastore); } @@ -47,7 +52,7 @@ public function setUp(): void $this->conf = new ConfigManager(self::$testConf); $this->conf->set('resource.datastore', self::$testDatastore); $this->history = new History('sandbox/history.php'); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->initializer = new BookmarkInitializer($this->bookmarkService); } @@ -59,7 +64,7 @@ public function testInitializeNotEmptyDataStore(): void { $refDB = new \ReferenceLinkDB(); $refDB->write(self::$testDatastore); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->initializer = new BookmarkInitializer($this->bookmarkService); $this->initializer->initialize(); @@ -90,7 +95,7 @@ public function testInitializeNotEmptyDataStore(): void $this->bookmarkService->save(); // Reload from file - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->assertEquals($refDB->countLinks() + 3, $this->bookmarkService->count()); $bookmark = $this->bookmarkService->get(43); @@ -121,7 +126,7 @@ public function testInitializeNotEmptyDataStore(): void public function testInitializeNonExistentDataStore(): void { $this->conf->set('resource.datastore', static::$testDatastore . '_empty'); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true); $this->initializer->initialize(); diff --git a/tests/bookmark/BookmarkTest.php b/tests/bookmark/BookmarkTest.php index afec24403..cb91b26ba 100644 --- a/tests/bookmark/BookmarkTest.php +++ b/tests/bookmark/BookmarkTest.php @@ -78,6 +78,23 @@ public function testFromArrayMinimal() $this->assertTrue($bookmark->isNote()); } + /** + * Test fromArray() with a link with a custom tags separator + */ + public function testFromArrayCustomTagsSeparator() + { + $data = [ + 'id' => 1, + 'tags' => ['tag1', 'tag2', 'chair'], + ]; + + $bookmark = (new Bookmark())->fromArray($data, '@'); + $this->assertEquals($data['id'], $bookmark->getId()); + $this->assertEquals($data['tags'], $bookmark->getTags()); + $this->assertEquals('tag1@tag2@chair', $bookmark->getTagsString('@')); + } + + /** * Test validate() with a valid minimal bookmark */ @@ -153,25 +170,6 @@ public function testValidateNotValidNoId() $this->assertContainsPolyfill('- ID: '. PHP_EOL, $exception->getMessage()); } - /** - * Test validate() with a a bookmark with a non integer ID. - */ - public function testValidateNotValidStringId() - { - $bookmark = new Bookmark(); - $bookmark->setId('str'); - $bookmark->setShortUrl('abc'); - $bookmark->setCreated(\DateTime::createFromFormat('Ymd_His', '20190514_200102')); - $exception = null; - try { - $bookmark->validate(); - } catch (InvalidBookmarkException $e) { - $exception = $e; - } - $this->assertNotNull($exception); - $this->assertContainsPolyfill('- ID: str'. PHP_EOL, $exception->getMessage()); - } - /** * Test validate() with a a bookmark without short url. */ @@ -210,25 +208,6 @@ public function testValidateNotValidNoCreated() $this->assertContainsPolyfill('- Created: '. PHP_EOL, $exception->getMessage()); } - /** - * Test validate() with a a bookmark with a bad created datetime. - */ - public function testValidateNotValidBadCreated() - { - $bookmark = new Bookmark(); - $bookmark->setId(1); - $bookmark->setShortUrl('abc'); - $bookmark->setCreated('hi!'); - $exception = null; - try { - $bookmark->validate(); - } catch (InvalidBookmarkException $e) { - $exception = $e; - } - $this->assertNotNull($exception); - $this->assertContainsPolyfill('- Created: Not a DateTime object'. PHP_EOL, $exception->getMessage()); - } - /** * Test setId() and make sure that default fields are generated. */ @@ -290,7 +269,7 @@ public function testSetTagsString() { $bookmark = new Bookmark(); - $str = 'tag1 tag2 tag3.tag3-2, tag4 , -tag5 '; + $str = 'tag1 tag2 tag3.tag3-2 tag4 -tag5 '; $bookmark->setTagsString($str); $this->assertEquals( [ @@ -314,9 +293,9 @@ public function testSetTags() $array = [ 'tag1 ', ' tag2', - 'tag3.tag3-2,', - ', tag4', - ', ', + 'tag3.tag3-2', + ' tag4', + ' ', '-tag5 ', ]; $bookmark->setTags($array); @@ -385,4 +364,48 @@ public function testDeleteTagNotExists() $bookmark->deleteTag('nope'); $this->assertEquals(['tag1', 'tag2', 'chair'], $bookmark->getTags()); } + + /** + * Test shouldUpdateThumbnail() with bookmarks needing an update. + */ + public function testShouldUpdateThumbnail(): void + { + $bookmark = (new Bookmark())->setUrl('http://domain.tld/with-image'); + + static::assertTrue($bookmark->shouldUpdateThumbnail()); + + $bookmark = (new Bookmark()) + ->setUrl('http://domain.tld/with-image') + ->setThumbnail('unknown file') + ; + + static::assertTrue($bookmark->shouldUpdateThumbnail()); + } + + /** + * Test shouldUpdateThumbnail() with bookmarks that should not update. + */ + public function testShouldNotUpdateThumbnail(): void + { + $bookmark = (new Bookmark()); + + static::assertFalse($bookmark->shouldUpdateThumbnail()); + + $bookmark = (new Bookmark()) + ->setUrl('ftp://domain.tld/other-protocol', ['ftp']) + ; + + static::assertFalse($bookmark->shouldUpdateThumbnail()); + + $bookmark = (new Bookmark()) + ->setUrl('http://domain.tld/with-image') + ->setThumbnail(__FILE__) + ; + + static::assertFalse($bookmark->shouldUpdateThumbnail()); + + $bookmark = (new Bookmark())->setUrl('/shaare/abcdef'); + + static::assertFalse($bookmark->shouldUpdateThumbnail()); + } } diff --git a/tests/bookmark/LinkUtilsTest.php b/tests/bookmark/LinkUtilsTest.php index ef00b92f8..ddab4e3ca 100644 --- a/tests/bookmark/LinkUtilsTest.php +++ b/tests/bookmark/LinkUtilsTest.php @@ -94,8 +94,108 @@ public function testHtmlExtractNonExistentCharset() public function testHtmlExtractExistentNameTag() { $description = 'Bob and Alice share cookies.'; + + // Simple one line $html = 'stuff2'; $this->assertEquals($description, html_extract_tag('description', $html)); + + // Simple OpenGraph + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // Simple reversed OpenGraph + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // ItemProp OpenGraph + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph without quotes + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph reversed without quotes + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph with noise + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph reversed with noise + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph multiple properties start + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph multiple properties end + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph multiple properties both end + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph multiple properties both end with noise + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph reversed multiple properties start + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph reversed multiple properties end + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph reversed multiple properties both end + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // OpenGraph reversed multiple properties both end with noise + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + // Suggestion from #1375 + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + } + + /** + * Test html_extract_tag() with double quoted content containing single quote, and the opposite. + */ + public function testHtmlExtractExistentNameTagWithMixedQuotes(): void + { + $description = 'Bob and Alice share M&M\'s.'; + + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + $description = 'Bob and Alice share "cookies".'; + + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); + + $html = ''; + $this->assertEquals($description, html_extract_tag('description', $html)); } /** @@ -105,6 +205,25 @@ public function testHtmlExtractNonExistentNameTag() { $html = 'stuff2'; $this->assertFalse(html_extract_tag('description', $html)); + + // Partial meta tag + $html = ''; + $this->assertFalse(html_extract_tag('description', $html)); + + $html = ''; + $this->assertFalse(html_extract_tag('description', $html)); + + $html = ''; + $this->assertFalse(html_extract_tag('description', $html)); + + $html = ''; + $this->assertFalse(html_extract_tag('description', $html)); + + $html = ''; + $this->assertFalse(html_extract_tag('description', $html)); + + $html = ''; + $this->assertFalse(html_extract_tag('description', $html)); } /** @@ -126,61 +245,94 @@ public function testHtmlExtractNonExistentOgTag() $this->assertFalse(html_extract_tag('description', $html)); } + /** + * Test the header callback with valid value + */ + public function testCurlHeaderCallbackOk(): void + { + $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_ok'); + $data = [ + 'HTTP/1.1 200 OK', + 'Server: GitHub.com', + 'Date: Sat, 28 Oct 2017 12:01:33 GMT', + 'Content-Type: text/html; charset=utf-8', + 'Status: 200 OK', + ]; + + foreach ($data as $chunk) { + static::assertIsInt($callback(null, $chunk)); + } + + static::assertSame('utf-8', $charset); + } + /** * Test the download callback with valid value */ - public function testCurlDownloadCallbackOk() + public function testCurlDownloadCallbackOk(): void { + $charset = 'utf-8'; $callback = get_curl_download_callback( $charset, $title, $desc, $keywords, false, - 'ut_curl_getinfo_ok' + ' ' ); + $data = [ - 'HTTP/1.1 200 OK', - 'Server: GitHub.com', - 'Date: Sat, 28 Oct 2017 12:01:33 GMT', - 'Content-Type: text/html; charset=utf-8', - 'Status: 200 OK', - 'end' => 'th=device-width">' + 'th=device-width">' . 'Refactoring · GitHub' . '' . '', ]; - foreach ($data as $key => $line) { - $ignore = null; - $expected = $key !== 'end' ? strlen($line) : false; - $this->assertEquals($expected, $callback($ignore, $line)); - if ($expected === false) { - break; - } + + foreach ($data as $chunk) { + static::assertSame(strlen($chunk), $callback(null, $chunk)); } - $this->assertEquals('utf-8', $charset); - $this->assertEquals('Refactoring · GitHub', $title); - $this->assertEmpty($desc); - $this->assertEmpty($keywords); + + static::assertSame('utf-8', $charset); + static::assertSame('Refactoring · GitHub', $title); + static::assertEmpty($desc); + static::assertEmpty($keywords); + } + + /** + * Test the header callback with valid value + */ + public function testCurlHeaderCallbackNoCharset(): void + { + $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_no_charset'); + $data = [ + 'HTTP/1.1 200 OK', + ]; + + foreach ($data as $chunk) { + static::assertSame(strlen($chunk), $callback(null, $chunk)); + } + + static::assertFalse($charset); } /** * Test the download callback with valid values and no charset */ - public function testCurlDownloadCallbackOkNoCharset() + public function testCurlDownloadCallbackOkNoCharset(): void { + $charset = null; $callback = get_curl_download_callback( $charset, $title, $desc, $keywords, false, - 'ut_curl_getinfo_no_charset' + ' ' ); + $data = [ - 'HTTP/1.1 200 OK', 'end' => 'th=device-width">' . 'Refactoring · GitHub' . '' . '', ]; - foreach ($data as $key => $line) { - $ignore = null; - $this->assertEquals(strlen($line), $callback($ignore, $line)); + + foreach ($data as $chunk) { + static::assertSame(strlen($chunk), $callback(null, $chunk)); } + $this->assertEmpty($charset); $this->assertEquals('Refactoring · GitHub', $title); $this->assertEmpty($desc); @@ -201,18 +354,19 @@ public function testCurlDownloadCallbackOkNoCharset() /** * Test the download callback with valid values and no charset */ - public function testCurlDownloadCallbackOkHtmlCharset() + public function testCurlDownloadCallbackOkHtmlCharset(): void { + $charset = null; $callback = get_curl_download_callback( $charset, $title, $desc, $keywords, false, - 'ut_curl_getinfo_no_charset' + ' ' ); + $data = [ - 'HTTP/1.1 200 OK', '', 'end' => 'th=device-width">' . 'Refactoring · GitHub' @@ -221,14 +375,10 @@ public function testCurlDownloadCallbackOkHtmlCharset() . '' . '', ]; - foreach ($data as $key => $line) { - $ignore = null; - $expected = $key !== 'end' ? strlen($line) : false; - $this->assertEquals($expected, $callback($ignore, $line)); - if ($expected === false) { - break; - } + foreach ($data as $chunk) { + static::assertSame(strlen($chunk), $callback(null, $chunk)); } + $this->assertEquals('utf-8', $charset); $this->assertEquals('Refactoring · GitHub', $title); $this->assertEmpty($desc); @@ -238,25 +388,27 @@ public function testCurlDownloadCallbackOkHtmlCharset() /** * Test the download callback with valid values and no title */ - public function testCurlDownloadCallbackOkNoTitle() + public function testCurlDownloadCallbackOkNoTitle(): void { + $charset = 'utf-8'; $callback = get_curl_download_callback( $charset, $title, $desc, $keywords, false, - 'ut_curl_getinfo_ok' + ' ' ); + $data = [ - 'HTTP/1.1 200 OK', 'end' => 'th=device-width">Refactoring · GitHub' . 'Refactoring · GitHub' . '' . '', ]; - foreach ($data as $key => $line) { - $ignore = null; - $expected = $key !== 'end' ? strlen($line) : false; - $this->assertEquals($expected, $callback($ignore, $line)); - if ($expected === false) { - break; - } + + foreach ($data as $chunk) { + static::assertSame(strlen($chunk), $callback(null, $chunk)); } + $this->assertEquals('utf-8', $charset); $this->assertEquals('Refactoring · GitHub', $title); $this->assertEquals('link desc', $desc); @@ -364,8 +488,9 @@ public function testCurlDownloadCallbackOkWithDesc() * Test the download callback with valid value, and retrieve_description option enabled, * but no desc or keyword defined in the page. */ - public function testCurlDownloadCallbackOkWithDescNotFound() + public function testCurlDownloadCallbackOkWithDescNotFound(): void { + $charset = 'utf-8'; $callback = get_curl_download_callback( $charset, $title, @@ -375,24 +500,16 @@ public function testCurlDownloadCallbackOkWithDescNotFound() 'ut_curl_getinfo_ok' ); $data = [ - 'HTTP/1.1 200 OK', - 'Server: GitHub.com', - 'Date: Sat, 28 Oct 2017 12:01:33 GMT', - 'Content-Type: text/html; charset=utf-8', - 'Status: 200 OK', 'th=device-width">' . 'Refactoring · GitHub' . ' 7, 'end' => 13], // coding + ['start' => 20, 'end' => 25], // guide + ]] + ); + + $link = $this->formatter->format($bookmark); + + $this->assertSame( + 'PSR-2: ' . + 'Coding Style ' . + 'Guide', + $link['title_html'] + ); + } + + /** + * Test formatDescription with search result highlight. + */ + public function testFormatDescriptionWithSearchHighlight(): void + { + $this->formatter = new BookmarkDefaultFormatter($this->conf, false); + + $bookmark = new Bookmark(); + $bookmark->setDescription('This guide extends and expands on PSR-1, the basic coding standard.'); + $bookmark->addAdditionalContentEntry( + 'search_highlight', + ['description' => [ + ['start' => 0, 'end' => 10], // "This guide" + ['start' => 45, 'end' => 50], // basic + ['start' => 58, 'end' => 67], // standard. + ]] + ); + + $link = $this->formatter->format($bookmark); + + $this->assertSame( + 'This guide extends and expands on PSR-1, the ' . + 'basic coding ' . + 'standard.', + $link['description'] + ); + } + + /** + * Test formatUrlHtml with search result highlight. + */ + public function testFormatUrlHtmlWithSearchHighlight(): void + { + $this->formatter = new BookmarkDefaultFormatter($this->conf, false); + + $bookmark = new Bookmark(); + $bookmark->setUrl('http://www.php-fig.org/psr/psr-2/'); + $bookmark->addAdditionalContentEntry( + 'search_highlight', + ['url' => [ + ['start' => 0, 'end' => 4], // http + ['start' => 15, 'end' => 18], // fig + ['start' => 27, 'end' => 33], // "psr-2/" + ]] + ); + + $link = $this->formatter->format($bookmark); + + $this->assertSame( + 'http://www.php-' . + 'fig.org/psr/' . + 'psr-2/', + $link['url_html'] + ); + } + + /** + * Test formatTagListHtml with search result highlight. + */ + public function testFormatTagListHtmlWithSearchHighlight(): void + { + $this->formatter = new BookmarkDefaultFormatter($this->conf, false); + + $bookmark = new Bookmark(); + $bookmark->setTagsString('coding-style standards quality assurance'); + $bookmark->addAdditionalContentEntry( + 'search_highlight', + ['tags' => [ + ['start' => 0, 'end' => 12], // coding-style + ['start' => 23, 'end' => 30], // quality + ['start' => 31, 'end' => 40], // assurance + ],] + ); + + $link = $this->formatter->format($bookmark); + + $this->assertSame( + [ + 'coding-style', + 'standards', + 'quality', + 'assurance', + ], + $link['taglist_html'] + ); + } + + /** + * Test default formatting with formatter_settings.autolink set to false: + * URLs and hashtags should not be transformed + */ + public function testFormatDescriptionWithoutLinkification(): void + { + $this->conf->set('formatter_settings.autolink', false); + $this->formatter = new BookmarkDefaultFormatter($this->conf, false); + + $bookmark = new Bookmark(); + $bookmark->setDescription('Hi!' . PHP_EOL . 'https://thisisaurl.tld #hashtag'); + + $link = $this->formatter->format($bookmark); + + static::assertSame( + 'Hi!
' . PHP_EOL . 'https://thisisaurl.tld  #hashtag', + $link['description'] + ); + } } diff --git a/tests/formatter/BookmarkMarkdownExtraFormatterTest.php b/tests/formatter/BookmarkMarkdownExtraFormatterTest.php new file mode 100644 index 000000000..d4941ef31 --- /dev/null +++ b/tests/formatter/BookmarkMarkdownExtraFormatterTest.php @@ -0,0 +1,162 @@ +conf = new ConfigManager(self::$testConf); + $this->formatter = new BookmarkMarkdownExtraFormatter($this->conf, true); + } + + /** + * Test formatting a bookmark with all its attribute filled. + */ + public function testFormatExtra(): void + { + $bookmark = new Bookmark(); + $bookmark->setId($id = 11); + $bookmark->setShortUrl($short = 'abcdef'); + $bookmark->setUrl('https://sub.domain.tld?query=here&for=real#hash'); + $bookmark->setTitle($title = 'This is a bookmark'); + $bookmark->setDescription('

Content

`Here is some content

'); + $bookmark->setTags($tags = ['tag1', 'bookmark', 'other', '']); + $bookmark->setThumbnail('http://domain2.tdl2/?type=img&name=file.png'); + $bookmark->setSticky(true); + $bookmark->setCreated($created = DateTime::createFromFormat('Ymd_His', '20190521_190412')); + $bookmark->setUpdated($updated = DateTime::createFromFormat('Ymd_His', '20190521_191213')); + $bookmark->setPrivate(true); + + $link = $this->formatter->format($bookmark); + $this->assertEquals($id, $link['id']); + $this->assertEquals($short, $link['shorturl']); + $this->assertEquals('https://sub.domain.tld?query=here&for=real#hash', $link['url']); + $this->assertEquals( + 'https://sub.domain.tld?query=here&for=real#hash', + $link['real_url'] + ); + $this->assertEquals('This is a <strong>bookmark</strong>', $link['title']); + $this->assertEquals( + '

'. + '<h2>Content</h2><p>`Here is some content</p>'. + '

', + $link['description'] + ); + $tags[3] = '<script>alert("xss");</script>'; + $this->assertEquals($tags, $link['taglist']); + $this->assertEquals(implode(' ', $tags), $link['tags']); + $this->assertEquals( + 'http://domain2.tdl2/?type=img&name=file.png', + $link['thumbnail'] + ); + $this->assertEquals($created, $link['created']); + $this->assertEquals($created->getTimestamp(), $link['timestamp']); + $this->assertEquals($updated, $link['updated']); + $this->assertEquals($updated->getTimestamp(), $link['updated_timestamp']); + $this->assertTrue($link['private']); + $this->assertTrue($link['sticky']); + $this->assertEquals('private', $link['class']); + } + + /** + * Test formatting a bookmark with all its attribute filled. + */ + public function testFormatExtraMinimal(): void + { + $bookmark = new Bookmark(); + + $link = $this->formatter->format($bookmark); + $this->assertEmpty($link['id']); + $this->assertEmpty($link['shorturl']); + $this->assertEmpty($link['url']); + $this->assertEmpty($link['real_url']); + $this->assertEmpty($link['title']); + $this->assertEmpty($link['description']); + $this->assertEmpty($link['taglist']); + $this->assertEmpty($link['tags']); + $this->assertEmpty($link['thumbnail']); + $this->assertEmpty($link['created']); + $this->assertEmpty($link['timestamp']); + $this->assertEmpty($link['updated']); + $this->assertEmpty($link['updated_timestamp']); + $this->assertFalse($link['private']); + $this->assertFalse($link['sticky']); + $this->assertEmpty($link['class']); + } + + /** + * Make sure that the description is properly formatted by the default formatter. + */ + public function testFormatExtrraDescription(): void + { + $description = 'This a description'. PHP_EOL; + $description .= 'text https://sub.domain.tld?query=here&for=real#hash more text'. PHP_EOL; + $description .= 'Also, there is an #hashtag added'. PHP_EOL; + $description .= ' A N D KEEP SPACES ! '. PHP_EOL; + $description .= '# Header {.class}'. PHP_EOL; + + $bookmark = new Bookmark(); + $bookmark->setDescription($description); + $link = $this->formatter->format($bookmark); + + $description = '

'; + $description .= 'This a <strong>description</strong>
'. PHP_EOL; + $url = 'https://sub.domain.tld?query=here&for=real#hash'; + $description .= 'text
'. $url .' more text
'. PHP_EOL; + $description .= 'Also, there is an #hashtag added
'. PHP_EOL; + $description .= 'A N D KEEP SPACES !

' . PHP_EOL; + $description .= '

Header

'; + $description .= '
'; + + $this->assertEquals($description, $link['description']); + } + + /** + * Test formatting URL with an index_url set + * It should prepend relative links. + */ + public function testFormatExtraNoteWithIndexUrl(): void + { + $bookmark = new Bookmark(); + $bookmark->setUrl($short = '?abcdef'); + $description = 'Text #hashtag more text'; + $bookmark->setDescription($description); + + $this->formatter->addContextData('index_url', $root = 'https://domain.tld/hithere/'); + + $description = '

'; + $description .= 'Text #hashtag more text'; + $description .= '

'; + + $link = $this->formatter->format($bookmark); + $this->assertEquals($root . $short, $link['url']); + $this->assertEquals($root . $short, $link['real_url']); + $this->assertEquals( + $description, + $link['description'] + ); + } +} diff --git a/tests/front/controller/admin/ConfigureControllerTest.php b/tests/front/controller/admin/ConfigureControllerTest.php index aca6cff31..d82db0a7c 100644 --- a/tests/front/controller/admin/ConfigureControllerTest.php +++ b/tests/front/controller/admin/ConfigureControllerTest.php @@ -51,7 +51,7 @@ public function testIndex(): void static::assertSame('general.title', $assignedVariables['title']); static::assertSame('resource.theme', $assignedVariables['theme']); static::assertEmpty($assignedVariables['theme_available']); - static::assertSame(['default', 'markdown'], $assignedVariables['formatter_available']); + static::assertSame(['default', 'markdown', 'markdownExtra'], $assignedVariables['formatter_available']); static::assertNotEmpty($assignedVariables['continents']); static::assertNotEmpty($assignedVariables['cities']); static::assertSame('general.retrieve_description', $assignedVariables['retrieve_description']); diff --git a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php b/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php deleted file mode 100644 index 0f27ec2fd..000000000 --- a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php +++ /dev/null @@ -1,47 +0,0 @@ -createContainer(); - - $this->container->httpAccess = $this->createMock(HttpAccess::class); - $this->controller = new ManageShaareController($this->container); - } - - /** - * Test displaying add link page - */ - public function testAddShaare(): void - { - $assignedVariables = []; - $this->assignTemplateVars($assignedVariables); - - $request = $this->createMock(Request::class); - $response = new Response(); - - $result = $this->controller->addShaare($request, $response); - - static::assertSame(200, $result->getStatusCode()); - static::assertSame('addlink', (string) $result->getBody()); - - static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']); - } -} diff --git a/tests/front/controller/admin/ManageTagControllerTest.php b/tests/front/controller/admin/ManageTagControllerTest.php index 8a0ff7a96..af6f273f8 100644 --- a/tests/front/controller/admin/ManageTagControllerTest.php +++ b/tests/front/controller/admin/ManageTagControllerTest.php @@ -6,6 +6,7 @@ use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkFilter; +use Shaarli\Config\ConfigManager; use Shaarli\Front\Exception\WrongTokenException; use Shaarli\Security\SessionManager; use Shaarli\TestCase; @@ -44,9 +45,32 @@ public function testIndex(): void static::assertSame('changetag', (string) $result->getBody()); static::assertSame('fromtag', $assignedVariables['fromtag']); + static::assertSame('@', $assignedVariables['tags_separator']); static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']); } + /** + * Test displaying manage tag page + */ + public function testIndexWhitespaceSeparator(): void + { + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $this->container->conf = $this->createMock(ConfigManager::class); + $this->container->conf->method('get')->willReturnCallback(function (string $key) { + return $key === 'general.tags_separator' ? ' ' : $key; + }); + + $request = $this->createMock(Request::class); + $response = new Response(); + + $this->controller->index($request, $response); + + static::assertSame(' ', $assignedVariables['tags_separator']); + static::assertSame('whitespace', $assignedVariables['tags_separator_desc']); + } + /** * Test posting a tag update - rename tag - valid info provided. */ @@ -269,4 +293,116 @@ public function testSaveRenameTagMissingTo(): void static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session); static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]); } + + /** + * Test changeSeparator to '#': redirection + success message. + */ + public function testChangeSeparatorValid(): void + { + $toSeparator = '#'; + + $session = []; + $this->assignSessionVars($session); + + $request = $this->createMock(Request::class); + $request + ->expects(static::atLeastOnce()) + ->method('getParam') + ->willReturnCallback(function (string $key) use ($toSeparator): ?string { + return $key === 'separator' ? $toSeparator : $key; + }) + ; + $response = new Response(); + + $this->container->conf + ->expects(static::once()) + ->method('set') + ->with('general.tags_separator', $toSeparator, true, true) + ; + + $result = $this->controller->changeSeparator($request, $response); + + static::assertSame(302, $result->getStatusCode()); + static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location')); + + static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session); + static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session); + static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session); + static::assertSame( + ['Your tags separator setting has been updated!'], + $session[SessionManager::KEY_SUCCESS_MESSAGES] + ); + } + + /** + * Test changeSeparator to '#@' (too long): redirection + error message. + */ + public function testChangeSeparatorInvalidTooLong(): void + { + $toSeparator = '#@'; + + $session = []; + $this->assignSessionVars($session); + + $request = $this->createMock(Request::class); + $request + ->expects(static::atLeastOnce()) + ->method('getParam') + ->willReturnCallback(function (string $key) use ($toSeparator): ?string { + return $key === 'separator' ? $toSeparator : $key; + }) + ; + $response = new Response(); + + $this->container->conf->expects(static::never())->method('set'); + + $result = $this->controller->changeSeparator($request, $response); + + static::assertSame(302, $result->getStatusCode()); + static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location')); + + static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session); + static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session); + static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session); + static::assertSame( + ['Tags separator must be a single character.'], + $session[SessionManager::KEY_ERROR_MESSAGES] + ); + } + + /** + * Test changeSeparator to '#@' (too long): redirection + error message. + */ + public function testChangeSeparatorInvalidReservedCharacter(): void + { + $toSeparator = '*'; + + $session = []; + $this->assignSessionVars($session); + + $request = $this->createMock(Request::class); + $request + ->expects(static::atLeastOnce()) + ->method('getParam') + ->willReturnCallback(function (string $key) use ($toSeparator): ?string { + return $key === 'separator' ? $toSeparator : $key; + }) + ; + $response = new Response(); + + $this->container->conf->expects(static::never())->method('set'); + + $result = $this->controller->changeSeparator($request, $response); + + static::assertSame(302, $result->getStatusCode()); + static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location')); + + static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session); + static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session); + static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session); + static::assertStringStartsWith( + 'These characters are reserved and can\'t be used as tags separator', + $session[SessionManager::KEY_ERROR_MESSAGES][0] + ); + } } diff --git a/tests/front/controller/admin/ServerControllerTest.php b/tests/front/controller/admin/ServerControllerTest.php new file mode 100644 index 000000000..355cce7d3 --- /dev/null +++ b/tests/front/controller/admin/ServerControllerTest.php @@ -0,0 +1,184 @@ +createContainer(); + + $this->controller = new ServerController($this->container); + + // initialize dummy cache + @mkdir('sandbox/'); + foreach (['pagecache', 'tmp', 'cache'] as $folder) { + @mkdir('sandbox/' . $folder); + @touch('sandbox/' . $folder . '/.htaccess'); + @touch('sandbox/' . $folder . '/1'); + @touch('sandbox/' . $folder . '/2'); + } + } + + public function tearDown(): void + { + foreach (['pagecache', 'tmp', 'cache'] as $folder) { + @unlink('sandbox/' . $folder . '/.htaccess'); + @unlink('sandbox/' . $folder . '/1'); + @unlink('sandbox/' . $folder . '/2'); + @rmdir('sandbox/' . $folder); + } + } + + /** + * Test default display of server administration page. + */ + public function testIndex(): void + { + $request = $this->createMock(Request::class); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $result = $this->controller->index($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('server', (string) $result->getBody()); + + static::assertSame(PHP_VERSION, $assignedVariables['php_version']); + static::assertArrayHasKey('php_has_reached_eol', $assignedVariables); + static::assertArrayHasKey('php_eol', $assignedVariables); + static::assertArrayHasKey('php_extensions', $assignedVariables); + static::assertArrayHasKey('permissions', $assignedVariables); + static::assertEmpty($assignedVariables['permissions']); + + static::assertRegExp( + '#https://github\.com/shaarli/Shaarli/releases/tag/v\d+\.\d+\.\d+#', + $assignedVariables['release_url'] + ); + static::assertRegExp('#v\d+\.\d+\.\d+#', $assignedVariables['latest_version']); + static::assertRegExp('#(v\d+\.\d+\.\d+|dev)#', $assignedVariables['current_version']); + static::assertArrayHasKey('index_url', $assignedVariables); + static::assertArrayHasKey('client_ip', $assignedVariables); + static::assertArrayHasKey('trusted_proxies', $assignedVariables); + + static::assertSame('Server administration - Shaarli', $assignedVariables['pagetitle']); + } + + /** + * Test clearing the main cache + */ + public function testClearMainCache(): void + { + $this->container->conf = $this->createMock(ConfigManager::class); + $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) { + if ($key === 'resource.page_cache') { + return 'sandbox/pagecache'; + } elseif ($key === 'resource.raintpl_tmp') { + return 'sandbox/tmp'; + } elseif ($key === 'resource.thumbnails_cache') { + return 'sandbox/cache'; + } else { + return $default; + } + }); + + $this->container->sessionManager + ->expects(static::once()) + ->method('setSessionParameter') + ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['Shaarli\'s cache folder has been cleared!']) + ; + + $request = $this->createMock(Request::class); + $request->method('getQueryParam')->with('type')->willReturn('main'); + $response = new Response(); + + $result = $this->controller->clearCache($request, $response); + + static::assertSame(302, $result->getStatusCode()); + static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location')); + + static::assertFileNotExists('sandbox/pagecache/1'); + static::assertFileNotExists('sandbox/pagecache/2'); + static::assertFileNotExists('sandbox/tmp/1'); + static::assertFileNotExists('sandbox/tmp/2'); + + static::assertFileExists('sandbox/pagecache/.htaccess'); + static::assertFileExists('sandbox/tmp/.htaccess'); + static::assertFileExists('sandbox/cache'); + static::assertFileExists('sandbox/cache/.htaccess'); + static::assertFileExists('sandbox/cache/1'); + static::assertFileExists('sandbox/cache/2'); + } + + /** + * Test clearing thumbnails cache + */ + public function testClearThumbnailsCache(): void + { + $this->container->conf = $this->createMock(ConfigManager::class); + $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) { + if ($key === 'resource.page_cache') { + return 'sandbox/pagecache'; + } elseif ($key === 'resource.raintpl_tmp') { + return 'sandbox/tmp'; + } elseif ($key === 'resource.thumbnails_cache') { + return 'sandbox/cache'; + } else { + return $default; + } + }); + + $this->container->sessionManager + ->expects(static::once()) + ->method('setSessionParameter') + ->willReturnCallback(function (string $key, array $value): SessionManager { + static::assertSame(SessionManager::KEY_WARNING_MESSAGES, $key); + static::assertCount(1, $value); + static::assertStringStartsWith('Thumbnails cache has been cleared.', $value[0]); + + return $this->container->sessionManager; + }); + ; + + $request = $this->createMock(Request::class); + $request->method('getQueryParam')->with('type')->willReturn('thumbnails'); + $response = new Response(); + + $result = $this->controller->clearCache($request, $response); + + static::assertSame(302, $result->getStatusCode()); + static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location')); + + static::assertFileNotExists('sandbox/cache/1'); + static::assertFileNotExists('sandbox/cache/2'); + + static::assertFileExists('sandbox/cache/.htaccess'); + static::assertFileExists('sandbox/pagecache'); + static::assertFileExists('sandbox/pagecache/.htaccess'); + static::assertFileExists('sandbox/pagecache/1'); + static::assertFileExists('sandbox/pagecache/2'); + static::assertFileExists('sandbox/tmp'); + static::assertFileExists('sandbox/tmp/.htaccess'); + static::assertFileExists('sandbox/tmp/1'); + static::assertFileExists('sandbox/tmp/2'); + } +} diff --git a/tests/front/controller/admin/ShaareAddControllerTest.php b/tests/front/controller/admin/ShaareAddControllerTest.php new file mode 100644 index 000000000..a27ebe649 --- /dev/null +++ b/tests/front/controller/admin/ShaareAddControllerTest.php @@ -0,0 +1,97 @@ +createContainer(); + + $this->container->httpAccess = $this->createMock(HttpAccess::class); + $this->controller = new ShaareAddController($this->container); + } + + /** + * Test displaying add link page + */ + public function testAddShaare(): void + { + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $request = $this->createMock(Request::class); + $response = new Response(); + + $expectedTags = [ + 'tag1' => 32, + 'tag2' => 24, + 'tag3' => 1, + ]; + $this->container->bookmarkService + ->expects(static::once()) + ->method('bookmarksCountPerTag') + ->willReturn($expectedTags) + ; + $expectedTags = array_merge($expectedTags, [BookmarkMarkdownFormatter::NO_MD_TAG => 1]); + + $this->container->conf = $this->createMock(ConfigManager::class); + $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) { + return $key === 'formatter' ? 'markdown' : $default; + }); + + $result = $this->controller->addShaare($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('addlink', (string) $result->getBody()); + + static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']); + static::assertFalse($assignedVariables['default_private_links']); + static::assertTrue($assignedVariables['async_metadata']); + static::assertSame($expectedTags, $assignedVariables['tags']); + } + + /** + * Test displaying add link page + */ + public function testAddShaareWithoutMd(): void + { + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $request = $this->createMock(Request::class); + $response = new Response(); + + $expectedTags = [ + 'tag1' => 32, + 'tag2' => 24, + 'tag3' => 1, + ]; + $this->container->bookmarkService + ->expects(static::once()) + ->method('bookmarksCountPerTag') + ->willReturn($expectedTags) + ; + + $result = $this->controller->addShaare($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('addlink', (string) $result->getBody()); + + static::assertSame($expectedTags, $assignedVariables['tags']); + } +} diff --git a/tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php similarity index 98% rename from tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php rename to tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php index 096d07743..28b1c0231 100644 --- a/tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php +++ b/tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; +namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; @@ -10,7 +10,7 @@ use Shaarli\Formatter\BookmarkRawFormatter; use Shaarli\Formatter\FormatterFactory; use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; -use Shaarli\Front\Controller\Admin\ManageShaareController; +use Shaarli\Front\Controller\Admin\ShaareManageController; use Shaarli\Http\HttpAccess; use Shaarli\Security\SessionManager; use Shaarli\TestCase; @@ -21,7 +21,7 @@ class ChangeVisibilityBookmarkTest extends TestCase { use FrontAdminControllerMockHelper; - /** @var ManageShaareController */ + /** @var ShaareManageController */ protected $controller; public function setUp(): void @@ -29,7 +29,7 @@ public function setUp(): void $this->createContainer(); $this->container->httpAccess = $this->createMock(HttpAccess::class); - $this->controller = new ManageShaareController($this->container); + $this->controller = new ShaareManageController($this->container); } /** diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php similarity index 94% rename from tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php rename to tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php index ba774e211..a276d988f 100644 --- a/tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php +++ b/tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; +namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Formatter\BookmarkFormatter; use Shaarli\Formatter\FormatterFactory; use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; -use Shaarli\Front\Controller\Admin\ManageShaareController; +use Shaarli\Front\Controller\Admin\ShaareManageController; use Shaarli\Http\HttpAccess; use Shaarli\Security\SessionManager; use Shaarli\TestCase; @@ -20,7 +20,7 @@ class DeleteBookmarkTest extends TestCase { use FrontAdminControllerMockHelper; - /** @var ManageShaareController */ + /** @var ShaareManageController */ protected $controller; public function setUp(): void @@ -28,7 +28,7 @@ public function setUp(): void $this->createContainer(); $this->container->httpAccess = $this->createMock(HttpAccess::class); - $this->controller = new ManageShaareController($this->container); + $this->controller = new ShaareManageController($this->container); } /** @@ -38,6 +38,8 @@ public function testDeleteSingleBookmark(): void { $parameters = ['id' => '123']; + $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/shaare/abcdef'; + $request = $this->createMock(Request::class); $request ->method('getParam') @@ -90,6 +92,8 @@ public function testDeleteMultipleBookmarks(): void { $parameters = ['id' => '123 456 789']; + $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/?searchtags=abcdef'; + $request = $this->createMock(Request::class); $request ->method('getParam') @@ -152,7 +156,7 @@ public function testDeleteMultipleBookmarks(): void $result = $this->controller->deleteBookmark($request, $response); static::assertSame(302, $result->getStatusCode()); - static::assertSame(['/subfolder/'], $result->getHeader('location')); + static::assertSame(['/subfolder/?searchtags=abcdef'], $result->getHeader('location')); } /** @@ -356,6 +360,10 @@ public function testDeleteBookmarkFromBookmarklet(): void ; $response = new Response(); + $this->container->bookmarkService->method('get')->with('123')->willReturn( + (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123') + ); + $this->container->formatterFactory = $this->createMock(FormatterFactory::class); $this->container->formatterFactory ->expects(static::once()) diff --git a/tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php similarity index 95% rename from tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php rename to tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php index 50ce7df14..b89206ce1 100644 --- a/tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php +++ b/tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; +namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; -use Shaarli\Front\Controller\Admin\ManageShaareController; +use Shaarli\Front\Controller\Admin\ShaareManageController; use Shaarli\Http\HttpAccess; use Shaarli\Security\SessionManager; use Shaarli\TestCase; @@ -18,7 +18,7 @@ class PinBookmarkTest extends TestCase { use FrontAdminControllerMockHelper; - /** @var ManageShaareController */ + /** @var ShaareManageController */ protected $controller; public function setUp(): void @@ -26,7 +26,7 @@ public function setUp(): void $this->createContainer(); $this->container->httpAccess = $this->createMock(HttpAccess::class); - $this->controller = new ManageShaareController($this->container); + $this->controller = new ShaareManageController($this->container); } /** diff --git a/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php b/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php new file mode 100644 index 000000000..ae61dfb7e --- /dev/null +++ b/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php @@ -0,0 +1,139 @@ +createContainer(); + + $this->container->httpAccess = $this->createMock(HttpAccess::class); + $this->controller = new ShaareManageController($this->container); + } + + /** + * Test shaare private with a private bookmark which does not have a key yet. + */ + public function testSharePrivateWithNewPrivateBookmark(): void + { + $hash = 'abcdcef'; + $request = $this->createMock(Request::class); + $response = new Response(); + + $bookmark = (new Bookmark()) + ->setId(123) + ->setUrl('http://domain.tld') + ->setTitle('Title 123') + ->setPrivate(true) + ; + + $this->container->bookmarkService + ->expects(static::once()) + ->method('findByHash') + ->with($hash) + ->willReturn($bookmark) + ; + $this->container->bookmarkService + ->expects(static::once()) + ->method('set') + ->with($bookmark, true) + ->willReturnCallback(function (Bookmark $bookmark): Bookmark { + static::assertSame(32, strlen($bookmark->getAdditionalContentEntry('private_key'))); + + return $bookmark; + }) + ; + + $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]); + + static::assertSame(302, $result->getStatusCode()); + static::assertRegExp('#/subfolder/shaare/' . $hash . '\?key=\w{32}#', $result->getHeaderLine('Location')); + } + + /** + * Test shaare private with a private bookmark which does already have a key. + */ + public function testSharePrivateWithExistingPrivateBookmark(): void + { + $hash = 'abcdcef'; + $existingKey = 'this is a private key'; + $request = $this->createMock(Request::class); + $response = new Response(); + + $bookmark = (new Bookmark()) + ->setId(123) + ->setUrl('http://domain.tld') + ->setTitle('Title 123') + ->setPrivate(true) + ->addAdditionalContentEntry('private_key', $existingKey) + ; + + $this->container->bookmarkService + ->expects(static::once()) + ->method('findByHash') + ->with($hash) + ->willReturn($bookmark) + ; + $this->container->bookmarkService + ->expects(static::never()) + ->method('set') + ; + + $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]); + + static::assertSame(302, $result->getStatusCode()); + static::assertSame('/subfolder/shaare/' . $hash . '?key=' . $existingKey, $result->getHeaderLine('Location')); + } + + /** + * Test shaare private with a public bookmark. + */ + public function testSharePrivateWithPublicBookmark(): void + { + $hash = 'abcdcef'; + $request = $this->createMock(Request::class); + $response = new Response(); + + $bookmark = (new Bookmark()) + ->setId(123) + ->setUrl('http://domain.tld') + ->setTitle('Title 123') + ->setPrivate(false) + ; + + $this->container->bookmarkService + ->expects(static::once()) + ->method('findByHash') + ->with($hash) + ->willReturn($bookmark) + ; + $this->container->bookmarkService + ->expects(static::never()) + ->method('set') + ; + + $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]); + + static::assertSame(302, $result->getStatusCode()); + static::assertSame('/subfolder/shaare/' . $hash, $result->getHeaderLine('Location')); + } +} diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php new file mode 100644 index 000000000..ce8e112b6 --- /dev/null +++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php @@ -0,0 +1,63 @@ +createContainer(); + + $this->container->httpAccess = $this->createMock(HttpAccess::class); + $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class); + $this->controller = new ShaarePublishController($this->container); + } + + /** + * TODO + */ + public function testDisplayCreateFormBatch(): void + { + $urls = [ + 'https://domain1.tld/url1', + 'https://domain2.tld/url2', + ' ', + 'https://domain3.tld/url3', + ]; + + $request = $this->createMock(Request::class); + $request->method('getParam')->willReturnCallback(function (string $key) use ($urls): ?string { + return $key === 'urls' ? implode(PHP_EOL, $urls) : null; + }); + $response = new Response(); + + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $result = $this->controller->displayCreateBatchForms($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('editlink.batch', (string) $result->getBody()); + + static::assertTrue($assignedVariables['batch_mode']); + static::assertCount(3, $assignedVariables['links']); + static::assertSame($urls[0], $assignedVariables['links'][0]['link']['url']); + static::assertSame($urls[1], $assignedVariables['links'][1]['link']['url']); + static::assertSame($urls[3], $assignedVariables['links'][2]['link']['url']); + } +} diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php similarity index 71% rename from tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php rename to tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php index 2eb952514..964773da1 100644 --- a/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php +++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php @@ -2,13 +2,14 @@ declare(strict_types=1); -namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; +namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest; use Shaarli\Bookmark\Bookmark; use Shaarli\Config\ConfigManager; use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; -use Shaarli\Front\Controller\Admin\ManageShaareController; +use Shaarli\Front\Controller\Admin\ShaarePublishController; use Shaarli\Http\HttpAccess; +use Shaarli\Http\MetadataRetriever; use Shaarli\TestCase; use Slim\Http\Request; use Slim\Http\Response; @@ -17,7 +18,7 @@ class DisplayCreateFormTest extends TestCase { use FrontAdminControllerMockHelper; - /** @var ManageShaareController */ + /** @var ShaarePublishController */ protected $controller; public function setUp(): void @@ -25,14 +26,15 @@ public function setUp(): void $this->createContainer(); $this->container->httpAccess = $this->createMock(HttpAccess::class); - $this->controller = new ManageShaareController($this->container); + $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class); + $this->controller = new ShaarePublishController($this->container); } /** * 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 @@ public function testDisplayCreateFormWithUrl(): void }); $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()) @@ -119,7 +101,73 @@ function (&$charset, &$title, &$description, &$tags) use ( static::assertSame($expectedUrl, $assignedVariables['link']['url']); static::assertSame($remoteTitle, $assignedVariables['link']['title']); static::assertSame($remoteDesc, $assignedVariables['link']['description']); - static::assertSame($remoteTags, $assignedVariables['link']['tags']); + static::assertSame($remoteTags . ' ', $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); + } + + /** + * 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::atLeastOnce()) + ->method('executeHooks') + ->withConsecutive(['render_editlink'], ['render_includes']) + ->willReturnCallback(function (string $hook, array $data): array { + if ('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']); @@ -127,6 +175,8 @@ function (&$charset, &$title, &$description, &$tags) use ( 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); } /** @@ -142,7 +192,7 @@ public function testDisplayCreateFormWithFullParameters(): void 'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash', 'title' => 'Provided Title', 'description' => 'Provided description.', - 'tags' => 'abc def', + 'tags' => 'abc@def', 'private' => '1', 'source' => 'apps', ]; @@ -166,7 +216,7 @@ public function testDisplayCreateFormWithFullParameters(): void static::assertSame($expectedUrl, $assignedVariables['link']['url']); static::assertSame($parameters['title'], $assignedVariables['link']['title']); static::assertSame($parameters['description'], $assignedVariables['link']['description']); - static::assertSame($parameters['tags'], $assignedVariables['link']['tags']); + static::assertSame($parameters['tags'] . '@', $assignedVariables['link']['tags']); static::assertTrue($assignedVariables['link']['private']); static::assertTrue($assignedVariables['link_is_new']); static::assertSame($parameters['source'], $assignedVariables['source']); @@ -310,7 +360,7 @@ public function testDisplayCreateFormWithExistingUrl(): void static::assertSame($expectedUrl, $assignedVariables['link']['url']); static::assertSame($title, $assignedVariables['link']['title']); static::assertSame($description, $assignedVariables['link']['description']); - static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']); + static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']); static::assertTrue($assignedVariables['link']['private']); static::assertSame($createdAt, $assignedVariables['link']['created']); } diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php similarity index 93% rename from tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php rename to tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php index 2dc3f41c6..738cea123 100644 --- a/tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php +++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; +namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; -use Shaarli\Front\Controller\Admin\ManageShaareController; +use Shaarli\Front\Controller\Admin\ShaarePublishController; use Shaarli\Http\HttpAccess; use Shaarli\Security\SessionManager; use Shaarli\TestCase; @@ -18,7 +18,7 @@ class DisplayEditFormTest extends TestCase { use FrontAdminControllerMockHelper; - /** @var ManageShaareController */ + /** @var ShaarePublishController */ protected $controller; public function setUp(): void @@ -26,7 +26,7 @@ public function setUp(): void $this->createContainer(); $this->container->httpAccess = $this->createMock(HttpAccess::class); - $this->controller = new ManageShaareController($this->container); + $this->controller = new ShaarePublishController($this->container); } /** @@ -74,7 +74,7 @@ public function testDisplayEditFormDefault(): void static::assertSame($url, $assignedVariables['link']['url']); static::assertSame($title, $assignedVariables['link']['title']); static::assertSame($description, $assignedVariables['link']['description']); - static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']); + static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']); static::assertTrue($assignedVariables['link']['private']); static::assertSame($createdAt, $assignedVariables['link']['created']); } diff --git a/tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php similarity index 80% rename from tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php rename to tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php index f7a682261..b6a861bc4 100644 --- a/tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php +++ b/tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; +namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest; use Shaarli\Bookmark\Bookmark; use Shaarli\Config\ConfigManager; use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; -use Shaarli\Front\Controller\Admin\ManageShaareController; +use Shaarli\Front\Controller\Admin\ShaarePublishController; use Shaarli\Front\Exception\WrongTokenException; use Shaarli\Http\HttpAccess; use Shaarli\Security\SessionManager; @@ -20,7 +20,7 @@ class SaveBookmarkTest extends TestCase { use FrontAdminControllerMockHelper; - /** @var ManageShaareController */ + /** @var ShaarePublishController */ protected $controller; public function setUp(): void @@ -28,7 +28,7 @@ public function setUp(): void $this->createContainer(); $this->container->httpAccess = $this->createMock(HttpAccess::class); - $this->controller = new ManageShaareController($this->container); + $this->controller = new ShaarePublishController($this->container); } /** @@ -66,23 +66,27 @@ public function testSaveBookmark(): void $this->container->bookmarkService ->expects(static::once()) ->method('addOrSet') - ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void { + ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark { static::assertFalse($save); $checkBookmark($bookmark); $bookmark->setId($id); + + return $bookmark; }) ; $this->container->bookmarkService ->expects(static::once()) ->method('set') - ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void { + ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark { static::assertTrue($save); $checkBookmark($bookmark); static::assertSame($id, $bookmark->getId()); + + return $bookmark; }) ; @@ -155,21 +159,25 @@ public function testSaveExistingBookmark(): void $this->container->bookmarkService ->expects(static::once()) ->method('addOrSet') - ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void { + ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark { static::assertFalse($save); $checkBookmark($bookmark); + + return $bookmark; }) ; $this->container->bookmarkService ->expects(static::once()) ->method('set') - ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void { + ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark { static::assertTrue($save); $checkBookmark($bookmark); static::assertSame($id, $bookmark->getId()); + + return $bookmark; }) ; @@ -201,7 +209,7 @@ public function testSaveExistingBookmark(): void /** * Test save a bookmark - try to retrieve the thumbnail */ - public function testSaveBookmarkWithThumbnail(): void + public function testSaveBookmarkWithThumbnailSync(): void { $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash']; @@ -216,7 +224,13 @@ public function testSaveBookmarkWithThumbnail(): void $this->container->conf = $this->createMock(ConfigManager::class); $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) { - return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default; + if ($key === 'thumbnails.mode') { + return Thumbnailer::MODE_ALL; + } elseif ($key === 'general.enable_async_metadata') { + return false; + } + + return $default; }); $this->container->thumbnailer = $this->createMock(Thumbnailer::class); @@ -230,8 +244,10 @@ public function testSaveBookmarkWithThumbnail(): void $this->container->bookmarkService ->expects(static::once()) ->method('addOrSet') - ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($thumb): void { + ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($thumb): Bookmark { static::assertSame($thumb, $bookmark->getThumbnail()); + + return $bookmark; }) ; @@ -264,6 +280,51 @@ public function testSaveBookmarkWithIdZero(): void static::assertSame(302, $result->getStatusCode()); } + /** + * Test save a bookmark - do not attempt to retrieve thumbnails if async mode is enabled. + */ + public function testSaveBookmarkWithThumbnailAsync(): void + { + $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash']; + + $request = $this->createMock(Request::class); + $request + ->method('getParam') + ->willReturnCallback(function (string $key) use ($parameters): ?string { + return $parameters[$key] ?? null; + }) + ; + $response = new Response(); + + $this->container->conf = $this->createMock(ConfigManager::class); + $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) { + if ($key === 'thumbnails.mode') { + return Thumbnailer::MODE_ALL; + } elseif ($key === 'general.enable_async_metadata') { + return true; + } + + return $default; + }); + + $this->container->thumbnailer = $this->createMock(Thumbnailer::class); + $this->container->thumbnailer->expects(static::never())->method('get'); + + $this->container->bookmarkService + ->expects(static::once()) + ->method('addOrSet') + ->willReturnCallback(function (Bookmark $bookmark): Bookmark { + static::assertNull($bookmark->getThumbnail()); + + return $bookmark; + }) + ; + + $result = $this->controller->save($request, $response); + + static::assertSame(302, $result->getStatusCode()); + } + /** * Change the password with a wrong existing password */ diff --git a/tests/front/controller/admin/ThumbnailsControllerTest.php b/tests/front/controller/admin/ThumbnailsControllerTest.php index f4a8acffb..e5749654b 100644 --- a/tests/front/controller/admin/ThumbnailsControllerTest.php +++ b/tests/front/controller/admin/ThumbnailsControllerTest.php @@ -89,8 +89,10 @@ public function testAjaxUpdateValid(): void $this->container->bookmarkService ->expects(static::once()) ->method('set') - ->willReturnCallback(function (Bookmark $bookmark) use ($thumb) { + ->willReturnCallback(function (Bookmark $bookmark) use ($thumb): Bookmark { static::assertSame($thumb, $bookmark->getThumbnail()); + + return $bookmark; }) ; diff --git a/tests/front/controller/visitor/BookmarkListControllerTest.php b/tests/front/controller/visitor/BookmarkListControllerTest.php index 0c95df975..dec938f20 100644 --- a/tests/front/controller/visitor/BookmarkListControllerTest.php +++ b/tests/front/controller/visitor/BookmarkListControllerTest.php @@ -173,7 +173,7 @@ public function testIndexDefaultWithFilters(): void $request = $this->createMock(Request::class); $request->method('getParam')->willReturnCallback(function (string $key) { if ('searchtags' === $key) { - return 'abc def'; + return 'abc@def'; } if ('searchterm' === $key) { return 'ghi jkl'; @@ -204,7 +204,7 @@ public function testIndexDefaultWithFilters(): void ->expects(static::once()) ->method('search') ->with( - ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'], + ['searchtags' => 'abc@def', 'searchterm' => 'ghi jkl'], 'private', false, true @@ -222,7 +222,7 @@ public function testIndexDefaultWithFilters(): void static::assertSame('linklist', (string) $result->getBody()); static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']); - static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc+def', $assignedVariables['previous_page_url']); + static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc%40def', $assignedVariables['previous_page_url']); } /** @@ -291,6 +291,37 @@ public function testPermalinkNotFound(): void ); } + /** + * Test GET /shaare/{hash}?key={key} - Find a link by hash using a private link. + */ + public function testPermalinkWithPrivateKey(): void + { + $hash = 'abcdef'; + $privateKey = 'this is a private key'; + + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $request = $this->createMock(Request::class); + $request->method('getParam')->willReturnCallback(function (string $key, $default = null) use ($privateKey) { + return $key === 'key' ? $privateKey : $default; + }); + $response = new Response(); + + $this->container->bookmarkService + ->expects(static::once()) + ->method('findByHash') + ->with($hash, $privateKey) + ->willReturn((new Bookmark())->setId(123)->setTitle('Title 1')->setUrl('http://url1.tld')) + ; + + $result = $this->controller->permalink($request, $response, ['hash' => $hash]); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('linklist', (string) $result->getBody()); + static::assertCount(1, $assignedVariables['links']); + } + /** * Test getting link list with thumbnail updates. * -> 2 thumbnails update, only 1 datastore write @@ -307,7 +338,13 @@ public function testThumbnailUpdateFromLinkList(): void $this->container->conf ->method('get') ->willReturnCallback(function (string $key, $default) { - return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default; + if ($key === 'thumbnails.mode') { + return Thumbnailer::MODE_ALL; + } elseif ($key === 'general.enable_async_metadata') { + return false; + } + + return $default; }) ; @@ -357,7 +394,13 @@ public function testThumbnailUpdateFromPermalink(): void $this->container->conf ->method('get') ->willReturnCallback(function (string $key, $default) { - return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default; + if ($key === 'thumbnails.mode') { + return Thumbnailer::MODE_ALL; + } elseif ($key === 'general.enable_async_metadata') { + return false; + } + + return $default; }) ; @@ -378,6 +421,47 @@ public function testThumbnailUpdateFromPermalink(): void static::assertSame('linklist', (string) $result->getBody()); } + /** + * Test getting a permalink with thumbnail update with async setting: no update should run. + */ + public function testThumbnailUpdateFromPermalinkAsync(): void + { + $request = $this->createMock(Request::class); + $response = new Response(); + + $this->container->loginManager = $this->createMock(LoginManager::class); + $this->container->loginManager->method('isLoggedIn')->willReturn(true); + + $this->container->conf = $this->createMock(ConfigManager::class); + $this->container->conf + ->method('get') + ->willReturnCallback(function (string $key, $default) { + if ($key === 'thumbnails.mode') { + return Thumbnailer::MODE_ALL; + } elseif ($key === 'general.enable_async_metadata') { + return true; + } + + return $default; + }) + ; + + $this->container->thumbnailer = $this->createMock(Thumbnailer::class); + $this->container->thumbnailer->expects(static::never())->method('get'); + + $this->container->bookmarkService + ->expects(static::once()) + ->method('findByHash') + ->willReturn((new Bookmark())->setId(2)->setUrl('https://url.tld')->setTitle('Title 1')) + ; + $this->container->bookmarkService->expects(static::never())->method('set'); + $this->container->bookmarkService->expects(static::never())->method('save'); + + $result = $this->controller->permalink($request, $response, ['hash' => 'abc']); + + static::assertSame(200, $result->getStatusCode()); + } + /** * Trigger legacy controller in link list controller: permalink */ diff --git a/tests/front/controller/visitor/DailyControllerTest.php b/tests/front/controller/visitor/DailyControllerTest.php index fc78bc13d..70fbce548 100644 --- a/tests/front/controller/visitor/DailyControllerTest.php +++ b/tests/front/controller/visitor/DailyControllerTest.php @@ -28,52 +28,49 @@ public function setUp(): void public function testValidIndexControllerInvokeDefault(): void { $currentDay = new \DateTimeImmutable('2020-05-13'); + $previousDate = new \DateTime('2 days ago 00:00:00'); + $nextDate = new \DateTime('today 00:00:00'); $request = $this->createMock(Request::class); - $request->method('getQueryParam')->willReturn($currentDay->format('Ymd')); + $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string { + return $key === 'day' ? $currentDay->format('Ymd') : null; + }); $response = new Response(); // Save RainTPL assigned variables $assignedVariables = []; $this->assignTemplateVars($assignedVariables); - // Links dataset: 2 links with thumbnails - $this->container->bookmarkService - ->expects(static::once()) - ->method('days') - ->willReturnCallback(function () use ($currentDay): array { - return [ - '20200510', - $currentDay->format('Ymd'), - '20200516', - ]; - }) - ; $this->container->bookmarkService ->expects(static::once()) - ->method('filterDay') - ->willReturnCallback(function (): array { - return [ - (new Bookmark()) - ->setId(1) - ->setUrl('http://url.tld') - ->setTitle(static::generateString(50)) - ->setDescription(static::generateString(500)) - , - (new Bookmark()) - ->setId(2) - ->setUrl('http://url2.tld') - ->setTitle(static::generateString(50)) - ->setDescription(static::generateString(500)) - , - (new Bookmark()) - ->setId(3) - ->setUrl('http://url3.tld') - ->setTitle(static::generateString(50)) - ->setDescription(static::generateString(500)) - , - ]; - }) + ->method('findByDate') + ->willReturnCallback( + function ($from, $to, &$previous, &$next) use ($currentDay, $previousDate, $nextDate): array { + $previous = $previousDate; + $next = $nextDate; + + return [ + (new Bookmark()) + ->setId(1) + ->setUrl('http://url.tld') + ->setTitle(static::generateString(50)) + ->setDescription(static::generateString(500)) + , + (new Bookmark()) + ->setId(2) + ->setUrl('http://url2.tld') + ->setTitle(static::generateString(50)) + ->setDescription(static::generateString(500)) + , + (new Bookmark()) + ->setId(3) + ->setUrl('http://url3.tld') + ->setTitle(static::generateString(50)) + ->setDescription(static::generateString(500)) + , + ]; + } + ) ; // Make sure that PluginManager hook is triggered @@ -81,20 +78,22 @@ public function testValidIndexControllerInvokeDefault(): void ->expects(static::atLeastOnce()) ->method('executeHooks') ->withConsecutive(['render_daily']) - ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array { - if ('render_daily' === $hook) { - static::assertArrayHasKey('linksToDisplay', $data); - static::assertCount(3, $data['linksToDisplay']); - static::assertSame(1, $data['linksToDisplay'][0]['id']); - static::assertSame($currentDay->getTimestamp(), $data['day']); - static::assertSame('20200510', $data['previousday']); - static::assertSame('20200516', $data['nextday']); - - static::assertArrayHasKey('loggedin', $param); + ->willReturnCallback( + function (string $hook, array $data, array $param) use ($currentDay, $previousDate, $nextDate): array { + if ('render_daily' === $hook) { + static::assertArrayHasKey('linksToDisplay', $data); + static::assertCount(3, $data['linksToDisplay']); + static::assertSame(1, $data['linksToDisplay'][0]['id']); + static::assertSame($currentDay->getTimestamp(), $data['day']); + static::assertSame($previousDate->format('Ymd'), $data['previousday']); + static::assertSame($nextDate->format('Ymd'), $data['nextday']); + + static::assertArrayHasKey('loggedin', $param); + } + + return $data; } - - return $data; - }) + ) ; $result = $this->controller->index($request, $response); @@ -107,6 +106,11 @@ public function testValidIndexControllerInvokeDefault(): void ); static::assertEquals($currentDay, $assignedVariables['dayDate']); static::assertEquals($currentDay->getTimestamp(), $assignedVariables['day']); + static::assertSame($previousDate->format('Ymd'), $assignedVariables['previousday']); + static::assertSame($nextDate->format('Ymd'), $assignedVariables['nextday']); + static::assertSame('day', $assignedVariables['type']); + static::assertSame('May 13, 2020', $assignedVariables['dayDesc']); + static::assertSame('Daily', $assignedVariables['localizedType']); static::assertCount(3, $assignedVariables['linksToDisplay']); $link = $assignedVariables['linksToDisplay'][0]; @@ -171,26 +175,19 @@ public function testValidIndexControllerInvokeNoFutureOrPast(): void $currentDay = new \DateTimeImmutable('2020-05-13'); $request = $this->createMock(Request::class); + $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string { + return $key === 'day' ? $currentDay->format('Ymd') : null; + }); $response = new Response(); // Save RainTPL assigned variables $assignedVariables = []; $this->assignTemplateVars($assignedVariables); - // Links dataset: 2 links with thumbnails $this->container->bookmarkService ->expects(static::once()) - ->method('days') + ->method('findByDate') ->willReturnCallback(function () use ($currentDay): array { - return [ - $currentDay->format($currentDay->format('Ymd')), - ]; - }) - ; - $this->container->bookmarkService - ->expects(static::once()) - ->method('filterDay') - ->willReturnCallback(function (): array { return [ (new Bookmark()) ->setId(1) @@ -250,20 +247,10 @@ public function testValidIndexControllerInvokeHeightAdjustment(): void $assignedVariables = []; $this->assignTemplateVars($assignedVariables); - // Links dataset: 2 links with thumbnails $this->container->bookmarkService ->expects(static::once()) - ->method('days') + ->method('findByDate') ->willReturnCallback(function () use ($currentDay): array { - return [ - $currentDay->format($currentDay->format('Ymd')), - ]; - }) - ; - $this->container->bookmarkService - ->expects(static::once()) - ->method('filterDay') - ->willReturnCallback(function (): array { return [ (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'), (new Bookmark()) @@ -320,14 +307,7 @@ public function testValidIndexControllerInvokeNoBookmark(): void // Links dataset: 2 links with thumbnails $this->container->bookmarkService ->expects(static::once()) - ->method('days') - ->willReturnCallback(function (): array { - return []; - }) - ; - $this->container->bookmarkService - ->expects(static::once()) - ->method('filterDay') + ->method('findByDate') ->willReturnCallback(function (): array { return []; }) @@ -347,7 +327,7 @@ public function testValidIndexControllerInvokeNoBookmark(): void static::assertSame(200, $result->getStatusCode()); static::assertSame('daily', (string) $result->getBody()); static::assertCount(0, $assignedVariables['linksToDisplay']); - static::assertSame('Today', $assignedVariables['dayDesc']); + static::assertSame('Today - ' . (new \DateTime())->format('F j, Y'), $assignedVariables['dayDesc']); static::assertEquals((new \DateTime())->setTime(0, 0)->getTimestamp(), $assignedVariables['day']); static::assertEquals((new \DateTime())->setTime(0, 0), $assignedVariables['dayDate']); } @@ -361,6 +341,7 @@ public function testValidRssControllerInvokeDefault(): void new \DateTimeImmutable('2020-05-17'), new \DateTimeImmutable('2020-05-15'), new \DateTimeImmutable('2020-05-13'), + new \DateTimeImmutable('+1 month'), ]; $request = $this->createMock(Request::class); @@ -371,6 +352,7 @@ public function testValidRssControllerInvokeDefault(): void (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'), (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'), (new Bookmark())->setId(4)->setCreated($dates[2])->setUrl('http://domain.tld/4'), + (new Bookmark())->setId(5)->setCreated($dates[3])->setUrl('http://domain.tld/5'), ]); $this->container->pageCacheManager @@ -397,13 +379,14 @@ public function testValidRssControllerInvokeDefault(): void static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']); static::assertSame('http://shaarli/subfolder/daily-rss', $assignedVariables['page_url']); static::assertFalse($assignedVariables['hide_timestamps']); - static::assertCount(2, $assignedVariables['days']); + static::assertCount(3, $assignedVariables['days']); $day = $assignedVariables['days'][$dates[0]->format('Ymd')]; + $date = $dates[0]->setTime(23, 59, 59); - static::assertEquals($dates[0], $day['date']); - static::assertSame($dates[0]->format(\DateTime::RSS), $day['date_rss']); - static::assertSame(format_date($dates[0], false), $day['date_human']); + static::assertEquals($date, $day['date']); + static::assertSame($date->format(\DateTime::RSS), $day['date_rss']); + static::assertSame(format_date($date, false), $day['date_human']); static::assertSame('http://shaarli/subfolder/daily?day='. $dates[0]->format('Ymd'), $day['absolute_url']); static::assertCount(1, $day['links']); static::assertSame(1, $day['links'][0]['id']); @@ -411,10 +394,11 @@ public function testValidRssControllerInvokeDefault(): void static::assertEquals($dates[0], $day['links'][0]['created']); $day = $assignedVariables['days'][$dates[1]->format('Ymd')]; + $date = $dates[1]->setTime(23, 59, 59); - static::assertEquals($dates[1], $day['date']); - static::assertSame($dates[1]->format(\DateTime::RSS), $day['date_rss']); - static::assertSame(format_date($dates[1], false), $day['date_human']); + static::assertEquals($date, $day['date']); + static::assertSame($date->format(\DateTime::RSS), $day['date_rss']); + static::assertSame(format_date($date, false), $day['date_human']); static::assertSame('http://shaarli/subfolder/daily?day='. $dates[1]->format('Ymd'), $day['absolute_url']); static::assertCount(2, $day['links']); @@ -424,6 +408,18 @@ public function testValidRssControllerInvokeDefault(): void static::assertSame(3, $day['links'][1]['id']); static::assertSame('http://domain.tld/3', $day['links'][1]['url']); static::assertEquals($dates[1], $day['links'][1]['created']); + + $day = $assignedVariables['days'][$dates[2]->format('Ymd')]; + $date = $dates[2]->setTime(23, 59, 59); + + static::assertEquals($date, $day['date']); + static::assertSame($date->format(\DateTime::RSS), $day['date_rss']); + static::assertSame(format_date($date, false), $day['date_human']); + static::assertSame('http://shaarli/subfolder/daily?day='. $dates[2]->format('Ymd'), $day['absolute_url']); + static::assertCount(1, $day['links']); + static::assertSame(4, $day['links'][0]['id']); + static::assertSame('http://domain.tld/4', $day['links'][0]['url']); + static::assertEquals($dates[2], $day['links'][0]['created']); } /** @@ -475,4 +471,246 @@ public function testValidRssControllerInvokeNoBookmark(): void static::assertFalse($assignedVariables['hide_timestamps']); static::assertCount(0, $assignedVariables['days']); } + + /** + * Test simple display index with week parameter + */ + public function testSimpleIndexWeekly(): void + { + $currentDay = new \DateTimeImmutable('2020-05-13'); + $expectedDay = new \DateTimeImmutable('2020-05-11'); + + $request = $this->createMock(Request::class); + $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string { + return $key === 'week' ? $currentDay->format('YW') : null; + }); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $this->container->bookmarkService + ->expects(static::once()) + ->method('findByDate') + ->willReturnCallback( + function (): array { + return [ + (new Bookmark()) + ->setId(1) + ->setUrl('http://url.tld') + ->setTitle(static::generateString(50)) + ->setDescription(static::generateString(500)) + , + (new Bookmark()) + ->setId(2) + ->setUrl('http://url2.tld') + ->setTitle(static::generateString(50)) + ->setDescription(static::generateString(500)) + , + ]; + } + ) + ; + + $result = $this->controller->index($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('daily', (string) $result->getBody()); + static::assertSame( + 'Weekly - Week 20 (May 11, 2020) - Shaarli', + $assignedVariables['pagetitle'] + ); + + static::assertCount(2, $assignedVariables['linksToDisplay']); + static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']); + static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']); + static::assertSame('', $assignedVariables['previousday']); + static::assertSame('', $assignedVariables['nextday']); + static::assertSame('Week 20 (May 11, 2020)', $assignedVariables['dayDesc']); + static::assertSame('week', $assignedVariables['type']); + static::assertSame('Weekly', $assignedVariables['localizedType']); + } + + /** + * Test simple display index with month parameter + */ + public function testSimpleIndexMonthly(): void + { + $currentDay = new \DateTimeImmutable('2020-05-13'); + $expectedDay = new \DateTimeImmutable('2020-05-01'); + + $request = $this->createMock(Request::class); + $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string { + return $key === 'month' ? $currentDay->format('Ym') : null; + }); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $this->container->bookmarkService + ->expects(static::once()) + ->method('findByDate') + ->willReturnCallback( + function (): array { + return [ + (new Bookmark()) + ->setId(1) + ->setUrl('http://url.tld') + ->setTitle(static::generateString(50)) + ->setDescription(static::generateString(500)) + , + (new Bookmark()) + ->setId(2) + ->setUrl('http://url2.tld') + ->setTitle(static::generateString(50)) + ->setDescription(static::generateString(500)) + , + ]; + } + ) + ; + + $result = $this->controller->index($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertSame('daily', (string) $result->getBody()); + static::assertSame( + 'Monthly - May, 2020 - Shaarli', + $assignedVariables['pagetitle'] + ); + + static::assertCount(2, $assignedVariables['linksToDisplay']); + static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']); + static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']); + static::assertSame('', $assignedVariables['previousday']); + static::assertSame('', $assignedVariables['nextday']); + static::assertSame('May, 2020', $assignedVariables['dayDesc']); + static::assertSame('month', $assignedVariables['type']); + static::assertSame('Monthly', $assignedVariables['localizedType']); + } + + /** + * Test simple display RSS with week parameter + */ + public function testSimpleRssWeekly(): void + { + $dates = [ + new \DateTimeImmutable('2020-05-19'), + new \DateTimeImmutable('2020-05-13'), + ]; + $expectedDates = [ + new \DateTimeImmutable('2020-05-24 23:59:59'), + new \DateTimeImmutable('2020-05-17 23:59:59'), + ]; + + $this->container->environment['QUERY_STRING'] = 'week'; + $request = $this->createMock(Request::class); + $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string { + return $key === 'week' ? '' : null; + }); + $response = new Response(); + + $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([ + (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'), + (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'), + (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'), + ]); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $result = $this->controller->rss($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]); + static::assertSame('dailyrss', (string) $result->getBody()); + static::assertSame('Shaarli', $assignedVariables['title']); + static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']); + static::assertSame('http://shaarli/subfolder/daily-rss?week', $assignedVariables['page_url']); + static::assertFalse($assignedVariables['hide_timestamps']); + static::assertCount(2, $assignedVariables['days']); + + $day = $assignedVariables['days'][$dates[0]->format('YW')]; + $date = $expectedDates[0]; + + static::assertEquals($date, $day['date']); + static::assertSame($date->format(\DateTime::RSS), $day['date_rss']); + static::assertSame('Week 21 (May 18, 2020)', $day['date_human']); + static::assertSame('http://shaarli/subfolder/daily?week='. $dates[0]->format('YW'), $day['absolute_url']); + static::assertCount(1, $day['links']); + + $day = $assignedVariables['days'][$dates[1]->format('YW')]; + $date = $expectedDates[1]; + + static::assertEquals($date, $day['date']); + static::assertSame($date->format(\DateTime::RSS), $day['date_rss']); + static::assertSame('Week 20 (May 11, 2020)', $day['date_human']); + static::assertSame('http://shaarli/subfolder/daily?week='. $dates[1]->format('YW'), $day['absolute_url']); + static::assertCount(2, $day['links']); + } + + /** + * Test simple display RSS with month parameter + */ + public function testSimpleRssMonthly(): void + { + $dates = [ + new \DateTimeImmutable('2020-05-19'), + new \DateTimeImmutable('2020-04-13'), + ]; + $expectedDates = [ + new \DateTimeImmutable('2020-05-31 23:59:59'), + new \DateTimeImmutable('2020-04-30 23:59:59'), + ]; + + $this->container->environment['QUERY_STRING'] = 'month'; + $request = $this->createMock(Request::class); + $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string { + return $key === 'month' ? '' : null; + }); + $response = new Response(); + + $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([ + (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'), + (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'), + (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'), + ]); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $result = $this->controller->rss($request, $response); + + static::assertSame(200, $result->getStatusCode()); + static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]); + static::assertSame('dailyrss', (string) $result->getBody()); + static::assertSame('Shaarli', $assignedVariables['title']); + static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']); + static::assertSame('http://shaarli/subfolder/daily-rss?month', $assignedVariables['page_url']); + static::assertFalse($assignedVariables['hide_timestamps']); + static::assertCount(2, $assignedVariables['days']); + + $day = $assignedVariables['days'][$dates[0]->format('Ym')]; + $date = $expectedDates[0]; + + static::assertEquals($date, $day['date']); + static::assertSame($date->format(\DateTime::RSS), $day['date_rss']); + static::assertSame('May, 2020', $day['date_human']); + static::assertSame('http://shaarli/subfolder/daily?month='. $dates[0]->format('Ym'), $day['absolute_url']); + static::assertCount(1, $day['links']); + + $day = $assignedVariables['days'][$dates[1]->format('Ym')]; + $date = $expectedDates[1]; + + static::assertEquals($date, $day['date']); + static::assertSame($date->format(\DateTime::RSS), $day['date_rss']); + static::assertSame('April, 2020', $day['date_human']); + static::assertSame('http://shaarli/subfolder/daily?month='. $dates[1]->format('Ym'), $day['absolute_url']); + static::assertCount(2, $day['links']); + } } diff --git a/tests/front/controller/visitor/ErrorControllerTest.php b/tests/front/controller/visitor/ErrorControllerTest.php index 75408cf40..e18a6fa2f 100644 --- a/tests/front/controller/visitor/ErrorControllerTest.php +++ b/tests/front/controller/visitor/ErrorControllerTest.php @@ -50,7 +50,31 @@ public function testDisplayFrontExceptionError(): void } /** - * Test displaying error with any exception (no debug): only display an error occurred with HTTP 500. + * Test displaying error with any exception (no debug) while logged in: + * display full error details + */ + public function testDisplayAnyExceptionErrorNoDebugLoggedIn(): void + { + $request = $this->createMock(Request::class); + $response = new Response(); + + // Save RainTPL assigned variables + $assignedVariables = []; + $this->assignTemplateVars($assignedVariables); + + $this->container->loginManager->method('isLoggedIn')->willReturn(true); + + $result = ($this->controller)($request, $response, new \Exception('abc')); + + static::assertSame(500, $result->getStatusCode()); + static::assertSame('Error: abc', $assignedVariables['message']); + static::assertContainsPolyfill('Please report it on Github', $assignedVariables['text']); + static::assertArrayHasKey('stacktrace', $assignedVariables); + } + + /** + * Test displaying error with any exception (no debug) while logged out: + * display standard error without detail */ public function testDisplayAnyExceptionErrorNoDebug(): void { @@ -61,10 +85,13 @@ public function testDisplayAnyExceptionErrorNoDebug(): void $assignedVariables = []; $this->assignTemplateVars($assignedVariables); + $this->container->loginManager->method('isLoggedIn')->willReturn(false); + $result = ($this->controller)($request, $response, new \Exception('abc')); static::assertSame(500, $result->getStatusCode()); static::assertSame('An unexpected error occurred.', $assignedVariables['message']); + static::assertArrayNotHasKey('text', $assignedVariables); static::assertArrayNotHasKey('stacktrace', $assignedVariables); } } diff --git a/tests/front/controller/visitor/FrontControllerMockHelper.php b/tests/front/controller/visitor/FrontControllerMockHelper.php index fc0bb7d1a..02229f680 100644 --- a/tests/front/controller/visitor/FrontControllerMockHelper.php +++ b/tests/front/controller/visitor/FrontControllerMockHelper.php @@ -41,6 +41,10 @@ protected function createContainer(): void // Config $this->container->conf = $this->createMock(ConfigManager::class); $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) { + if ($parameter === 'general.tags_separator') { + return '@'; + } + return $default === null ? $parameter : $default; }); diff --git a/tests/front/controller/visitor/InstallControllerTest.php b/tests/front/controller/visitor/InstallControllerTest.php index 345ad544b..2105ed770 100644 --- a/tests/front/controller/visitor/InstallControllerTest.php +++ b/tests/front/controller/visitor/InstallControllerTest.php @@ -79,6 +79,15 @@ public function testInstallIndexWithValidSession(): void static::assertIsArray($assignedVariables['languages']); static::assertSame('Automatic', $assignedVariables['languages']['auto']); static::assertSame('French', $assignedVariables['languages']['fr']); + + static::assertSame(PHP_VERSION, $assignedVariables['php_version']); + static::assertArrayHasKey('php_has_reached_eol', $assignedVariables); + static::assertArrayHasKey('php_eol', $assignedVariables); + static::assertArrayHasKey('php_extensions', $assignedVariables); + static::assertArrayHasKey('permissions', $assignedVariables); + static::assertEmpty($assignedVariables['permissions']); + + static::assertSame('Install Shaarli', $assignedVariables['pagetitle']); } /** diff --git a/tests/front/controller/visitor/LoginControllerTest.php b/tests/front/controller/visitor/LoginControllerTest.php index 1312ccb79..00d9eab3b 100644 --- a/tests/front/controller/visitor/LoginControllerTest.php +++ b/tests/front/controller/visitor/LoginControllerTest.php @@ -195,7 +195,7 @@ public function testProcessLoginWithValidParameters(): void $this->container->loginManager ->expects(static::once()) ->method('checkCredentials') - ->with('1.2.3.4', '1.2.3.4', 'bob', 'pass') + ->with('1.2.3.4', 'bob', 'pass') ->willReturn(true) ; $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8))); diff --git a/tests/front/controller/visitor/TagCloudControllerTest.php b/tests/front/controller/visitor/TagCloudControllerTest.php index 9305612ed..4915573d1 100644 --- a/tests/front/controller/visitor/TagCloudControllerTest.php +++ b/tests/front/controller/visitor/TagCloudControllerTest.php @@ -100,7 +100,7 @@ public function testValidCloudControllerInvokeWithParameters(): void ->with() ->willReturnCallback(function (string $key): ?string { if ('searchtags' === $key) { - return 'ghi def'; + return 'ghi@def'; } return null; @@ -131,7 +131,7 @@ public function testValidCloudControllerInvokeWithParameters(): void ->withConsecutive(['render_tagcloud']) ->willReturnCallback(function (string $hook, array $data, array $param): array { if ('render_tagcloud' === $hook) { - static::assertSame('ghi def', $data['search_tags']); + static::assertSame('ghi@def@', $data['search_tags']); static::assertCount(1, $data['tags']); static::assertArrayHasKey('loggedin', $param); @@ -147,7 +147,7 @@ public function testValidCloudControllerInvokeWithParameters(): void static::assertSame('tag.cloud', (string) $result->getBody()); static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']); - static::assertSame('ghi def', $assignedVariables['search_tags']); + static::assertSame('ghi@def@', $assignedVariables['search_tags']); static::assertCount(1, $assignedVariables['tags']); static::assertArrayHasKey('abc', $assignedVariables['tags']); @@ -277,7 +277,7 @@ public function testValidListControllerInvokeWithParameters(): void ->with() ->willReturnCallback(function (string $key): ?string { if ('searchtags' === $key) { - return 'ghi def'; + return 'ghi@def'; } elseif ('sort' === $key) { return 'alpha'; } @@ -310,7 +310,7 @@ public function testValidListControllerInvokeWithParameters(): void ->withConsecutive(['render_taglist']) ->willReturnCallback(function (string $hook, array $data, array $param): array { if ('render_taglist' === $hook) { - static::assertSame('ghi def', $data['search_tags']); + static::assertSame('ghi@def@', $data['search_tags']); static::assertCount(1, $data['tags']); static::assertArrayHasKey('loggedin', $param); @@ -326,7 +326,7 @@ public function testValidListControllerInvokeWithParameters(): void static::assertSame('tag.list', (string) $result->getBody()); static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']); - static::assertSame('ghi def', $assignedVariables['search_tags']); + static::assertSame('ghi@def@', $assignedVariables['search_tags']); static::assertCount(1, $assignedVariables['tags']); static::assertSame(3, $assignedVariables['tags']['abc']); } diff --git a/tests/front/controller/visitor/TagControllerTest.php b/tests/front/controller/visitor/TagControllerTest.php index 750ea02d8..5a556c6de 100644 --- a/tests/front/controller/visitor/TagControllerTest.php +++ b/tests/front/controller/visitor/TagControllerTest.php @@ -50,7 +50,7 @@ public function testAddTagWithRefererAndExistingSearch(): void static::assertInstanceOf(Response::class, $result); static::assertSame(302, $result->getStatusCode()); - static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); + static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location')); } public function testAddTagWithoutRefererAndExistingSearch(): void @@ -80,7 +80,7 @@ public function testAddTagRemoveLegacyQueryParam(): void static::assertInstanceOf(Response::class, $result); static::assertSame(302, $result->getStatusCode()); - static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); + static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location')); } public function testAddTagResetPagination(): void @@ -96,7 +96,7 @@ public function testAddTagResetPagination(): void static::assertInstanceOf(Response::class, $result); static::assertSame(302, $result->getStatusCode()); - static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); + static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location')); } public function testAddTagWithRefererAndEmptySearch(): void diff --git a/tests/ApplicationUtilsTest.php b/tests/helper/ApplicationUtilsTest.php similarity index 81% rename from tests/ApplicationUtilsTest.php rename to tests/helper/ApplicationUtilsTest.php index a232b351f..654857b94 100644 --- a/tests/ApplicationUtilsTest.php +++ b/tests/helper/ApplicationUtilsTest.php @@ -1,7 +1,8 @@ set('resource.thumbnails_cache', 'null/cache'); + $conf->set('resource.config', 'null/data/config.php'); + $conf->set('resource.data_dir', 'null/data'); + $conf->set('resource.datastore', 'null/data/store.php'); + $conf->set('resource.ban_file', 'null/data/ipbans.php'); + $conf->set('resource.log', 'null/data/log.txt'); + $conf->set('resource.page_cache', 'null/pagecache'); + $conf->set('resource.raintpl_tmp', 'null/tmp'); + $conf->set('resource.raintpl_tpl', 'null/tpl'); + $conf->set('resource.raintpl_theme', 'null/tpl/default'); + $conf->set('resource.update_check', 'null/data/lastupdatecheck.txt'); + + static::assertSame( + [ + '"null/tpl" directory is not readable', + '"null/tpl/default" directory is not readable', + '"null/tmp" directory is not readable', + '"null/tmp" directory is not writable' + ], + ApplicationUtils::checkResourcePermissions($conf, true) + ); + } + /** * Check update with 'dev' as curent version (master branch). * It should always return false. @@ -349,4 +379,37 @@ public function testCheckUpdateDev() ApplicationUtils::checkUpdate('dev', self::$testUpdateFile, 100, true, true) ); } + + /** + * Basic test of getPhpExtensionsRequirement() + */ + public function testGetPhpExtensionsRequirementSimple(): void + { + static::assertCount(8, ApplicationUtils::getPhpExtensionsRequirement()); + static::assertSame([ + 'name' => 'json', + 'required' => true, + 'desc' => 'Configuration parsing', + 'loaded' => true, + ], ApplicationUtils::getPhpExtensionsRequirement()[0]); + } + + /** + * Test getPhpEol with a known version: 7.4 -> 2022 + */ + public function testGetKnownPhpEol(): void + { + static::assertSame('2022-11-28', ApplicationUtils::getPhpEol('7.4.7')); + } + + /** + * Test getPhpEol with an unknown version: 7.4 -> 2022 + */ + public function testGetUnknownPhpEol(): void + { + static::assertSame( + (((int) (new \DateTime())->format('Y')) + 2) . (new \DateTime())->format('-m-d'), + ApplicationUtils::getPhpEol('7.51.34') + ); + } } diff --git a/tests/helper/DailyPageHelperTest.php b/tests/helper/DailyPageHelperTest.php new file mode 100644 index 000000000..5255b7b16 --- /dev/null +++ b/tests/helper/DailyPageHelperTest.php @@ -0,0 +1,262 @@ +createMock(Request::class); + $request->method('getQueryParam')->willReturnCallback(function ($key) use ($queryParams): ?string { + return $queryParams[$key] ?? null; + }); + + $type = DailyPageHelper::extractRequestedType($request); + + static::assertSame($type, $expectedType); + } + + /** + * @dataProvider getRequestedDateTimes + */ + public function testExtractRequestedDateTime( + string $type, + string $input, + ?Bookmark $bookmark, + \DateTimeInterface $expectedDateTime, + string $compareFormat = 'Ymd' + ): void { + $dateTime = DailyPageHelper::extractRequestedDateTime($type, $input, $bookmark); + + static::assertSame($dateTime->format($compareFormat), $expectedDateTime->format($compareFormat)); + } + + public function testExtractRequestedDateTimeExceptionUnknownType(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported daily format type'); + + DailyPageHelper::extractRequestedDateTime('nope', null, null); + } + + /** + * @dataProvider getFormatsByType + */ + public function testGetFormatByType(string $type, string $expectedFormat): void + { + $format = DailyPageHelper::getFormatByType($type); + + static::assertSame($expectedFormat, $format); + } + + public function testGetFormatByTypeExceptionUnknownType(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported daily format type'); + + DailyPageHelper::getFormatByType('nope'); + } + + /** + * @dataProvider getStartDatesByType + */ + public function testGetStartDatesByType( + string $type, + \DateTimeImmutable $dateTime, + \DateTimeInterface $expectedDateTime + ): void { + $startDateTime = DailyPageHelper::getStartDateTimeByType($type, $dateTime); + + static::assertEquals($expectedDateTime, $startDateTime); + } + + public function testGetStartDatesByTypeExceptionUnknownType(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported daily format type'); + + DailyPageHelper::getStartDateTimeByType('nope', new \DateTimeImmutable()); + } + + /** + * @dataProvider getEndDatesByType + */ + public function testGetEndDatesByType( + string $type, + \DateTimeImmutable $dateTime, + \DateTimeInterface $expectedDateTime + ): void { + $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dateTime); + + static::assertEquals($expectedDateTime, $endDateTime); + } + + public function testGetEndDatesByTypeExceptionUnknownType(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported daily format type'); + + DailyPageHelper::getEndDateTimeByType('nope', new \DateTimeImmutable()); + } + + /** + * @dataProvider getDescriptionsByType + */ + public function testGeDescriptionsByType( + string $type, + \DateTimeImmutable $dateTime, + string $expectedDescription + ): void { + $description = DailyPageHelper::getDescriptionByType($type, $dateTime); + + static::assertEquals($expectedDescription, $description); + } + + public function getDescriptionByTypeExceptionUnknownType(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported daily format type'); + + DailyPageHelper::getDescriptionByType('nope', new \DateTimeImmutable()); + } + + /** + * @dataProvider getRssLengthsByType + */ + public function testGeRssLengthsByType(string $type): void { + $length = DailyPageHelper::getRssLengthByType($type); + + static::assertIsInt($length); + } + + public function testGeRssLengthsByTypeExceptionUnknownType(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported daily format type'); + + DailyPageHelper::getRssLengthByType('nope'); + } + + /** + * Data provider for testExtractRequestedType() test method. + */ + public function getRequestedTypes(): array + { + return [ + [['month' => null], DailyPageHelper::DAY], + [['month' => ''], DailyPageHelper::MONTH], + [['month' => 'content'], DailyPageHelper::MONTH], + [['week' => null], DailyPageHelper::DAY], + [['week' => ''], DailyPageHelper::WEEK], + [['week' => 'content'], DailyPageHelper::WEEK], + [['day' => null], DailyPageHelper::DAY], + [['day' => ''], DailyPageHelper::DAY], + [['day' => 'content'], DailyPageHelper::DAY], + ]; + } + + /** + * Data provider for testExtractRequestedDateTime() test method. + */ + public function getRequestedDateTimes(): array + { + return [ + [DailyPageHelper::DAY, '20201013', null, new \DateTime('2020-10-13')], + [ + DailyPageHelper::DAY, + '', + (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')), + $date, + ], + [DailyPageHelper::DAY, '', null, new \DateTime()], + [DailyPageHelper::WEEK, '202030', null, new \DateTime('2020-07-20')], + [ + DailyPageHelper::WEEK, + '', + (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')), + new \DateTime('2020-10-13'), + ], + [DailyPageHelper::WEEK, '', null, new \DateTime(), 'Ym'], + [DailyPageHelper::MONTH, '202008', null, new \DateTime('2020-08-01'), 'Ym'], + [ + DailyPageHelper::MONTH, + '', + (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')), + new \DateTime('2020-10-13'), + 'Ym' + ], + [DailyPageHelper::MONTH, '', null, new \DateTime(), 'Ym'], + ]; + } + + /** + * Data provider for testGetFormatByType() test method. + */ + public function getFormatsByType(): array + { + return [ + [DailyPageHelper::DAY, 'Ymd'], + [DailyPageHelper::WEEK, 'YW'], + [DailyPageHelper::MONTH, 'Ym'], + ]; + } + + /** + * Data provider for testGetStartDatesByType() test method. + */ + public function getStartDatesByType(): array + { + return [ + [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 00:00:00')], + [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-05 00:00:00')], + [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-01 00:00:00')], + ]; + } + + /** + * Data provider for testGetEndDatesByType() test method. + */ + public function getEndDatesByType(): array + { + return [ + [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 23:59:59')], + [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-11 23:59:59')], + [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-31 23:59:59')], + ]; + } + + /** + * Data provider for testGetDescriptionsByType() test method. + */ + public function getDescriptionsByType(): array + { + return [ + [DailyPageHelper::DAY, $date = new \DateTimeImmutable(), 'Today - ' . $date->format('F j, Y')], + [DailyPageHelper::DAY, $date = new \DateTimeImmutable('-1 day'), 'Yesterday - ' . $date->format('F j, Y')], + [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October 9, 2020'], + [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), 'Week 41 (October 5, 2020)'], + [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October, 2020'], + ]; + } + + /** + * Data provider for testGetDescriptionsByType() test method. + */ + public function getRssLengthsByType(): array + { + return [ + [DailyPageHelper::DAY], + [DailyPageHelper::WEEK], + [DailyPageHelper::MONTH], + ]; + } +} diff --git a/tests/FileUtilsTest.php b/tests/helper/FileUtilsTest.php similarity index 53% rename from tests/FileUtilsTest.php rename to tests/helper/FileUtilsTest.php index 9163bdf1f..8035f79cf 100644 --- a/tests/FileUtilsTest.php +++ b/tests/helper/FileUtilsTest.php @@ -1,27 +1,51 @@ assertEquals(null, FileUtils::readFlatDB(self::$file)); $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test'])); } + + /** + * Test clearFolder with self delete and excluded files + */ + public function testClearFolderSelfDeleteWithExclusion(): void + { + FileUtils::clearFolder('sandbox', true, ['file2']); + + static::assertFileExists('sandbox/folder1/file2'); + static::assertFileExists('sandbox/folder1'); + static::assertFileExists('sandbox/file2'); + static::assertFileExists('sandbox'); + + static::assertFileNotExists('sandbox/folder1/file1'); + static::assertFileNotExists('sandbox/file1'); + static::assertFileNotExists('sandbox/folder3'); + } + + /** + * Test clearFolder with self delete and excluded files + */ + public function testClearFolderSelfDeleteWithoutExclusion(): void + { + FileUtils::clearFolder('sandbox', true); + + static::assertFileNotExists('sandbox'); + } + + /** + * Test clearFolder with self delete and excluded files + */ + public function testClearFolderNoSelfDeleteWithoutExclusion(): void + { + FileUtils::clearFolder('sandbox', false); + + static::assertFileExists('sandbox'); + + // 2 because '.' and '..' + static::assertCount(2, new \DirectoryIterator('sandbox')); + } + + /** + * Test clearFolder on a file instead of a folder + */ + public function testClearFolderOnANonDirectory(): void + { + $this->expectException(IOException::class); + $this->expectExceptionMessage('Provided path is not a directory.'); + + FileUtils::clearFolder('sandbox/file1', false); + } + + /** + * Test clearFolder on a file instead of a folder + */ + public function testClearFolderOutsideOfShaarliDirectory(): void + { + $this->expectException(IOException::class); + $this->expectExceptionMessage('Trying to delete a folder outside of Shaarli path.'); + + + FileUtils::clearFolder('/tmp/shaarli-to-delete', true); + } } diff --git a/tests/http/MetadataRetrieverTest.php b/tests/http/MetadataRetrieverTest.php new file mode 100644 index 000000000..3c9eaa0e7 --- /dev/null +++ b/tests/http/MetadataRetrieverTest.php @@ -0,0 +1,154 @@ +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'; + $remoteCharset = 'utf-8'; + + $expectedResult = [ + 'title' => $remoteTitle, + 'description' => $remoteDesc, + 'tags' => $remoteTags, + ]; + + $this->httpAccess + ->expects(static::once()) + ->method('getCurlHeaderCallback') + ->willReturnCallback( + function (&$charset) use ( + $remoteCharset + ): callable { + return function () use ( + &$charset, + $remoteCharset + ): void { + $charset = $remoteCharset; + }; + } + ) + ; + $this->httpAccess + ->expects(static::once()) + ->method('getCurlDownloadCallback') + ->willReturnCallback( + function (&$charset, &$title, &$description, &$tags) use ( + $remoteCharset, + $remoteTitle, + $remoteDesc, + $remoteTags + ): callable { + return function () use ( + &$charset, + &$title, + &$description, + &$tags, + $remoteCharset, + $remoteTitle, + $remoteDesc, + $remoteTags + ): void { + static::assertSame($remoteCharset, $charset); + + $title = $remoteTitle; + $description = $remoteDesc; + $tags = $remoteTags; + }; + } + ) + ; + $this->httpAccess + ->expects(static::once()) + ->method('getHttpResponse') + ->with($url, 30, 4194304) + ->willReturnCallback(function($url, $timeout, $maxBytes, $headerCallback, $dlCallback): void { + $headerCallback(); + $dlCallback(); + }) + ; + + $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 (): callable { + return function (): void {}; + } + ) + ; + $this->httpAccess + ->expects(static::once()) + ->method('getCurlHeaderCallback') + ->willReturnCallback( + function (): callable { + return function (): void {}; + } + ) + ; + $this->httpAccess + ->expects(static::once()) + ->method('getHttpResponse') + ->with($url, 30, 4194304) + ->willReturnCallback(function($url, $timeout, $maxBytes, $headerCallback, $dlCallback): void { + $headerCallback(); + $dlCallback(); + }) + ; + + $result = $this->retriever->retrieve($url); + + static::assertSame($expectedResult, $result); + } +} diff --git a/tests/legacy/LegacyLinkDBTest.php b/tests/legacy/LegacyLinkDBTest.php index df2cad622..5c3fd425f 100644 --- a/tests/legacy/LegacyLinkDBTest.php +++ b/tests/legacy/LegacyLinkDBTest.php @@ -296,6 +296,10 @@ public function testAllTags() // They need to be grouped with the first case found - order by date DESC: `sTuff`. 'sTuff' => 2, 'ut' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ), self::$publicLinkDB->linksCountPerTag() ); @@ -324,6 +328,10 @@ public function testAllTags() 'tag3' => 1, 'tag4' => 1, 'ut' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ), self::$privateLinkDB->linksCountPerTag() ); @@ -544,6 +552,10 @@ public function testCountLinkPerTagAllNoFilter() 'tag4' => 1, 'ut' => 1, 'w3c' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ]; $tags = self::$privateLinkDB->linksCountPerTag(); diff --git a/tests/legacy/LegacyUpdaterTest.php b/tests/legacy/LegacyUpdaterTest.php index f7391b867..395dd4b70 100644 --- a/tests/legacy/LegacyUpdaterTest.php +++ b/tests/legacy/LegacyUpdaterTest.php @@ -51,10 +51,10 @@ protected function setUp(): void */ public function testReadEmptyUpdatesFile() { - $this->assertEquals(array(), UpdaterUtils::read_updates_file('')); + $this->assertEquals(array(), UpdaterUtils::readUpdatesFile('')); $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; touch($updatesFile); - $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile)); + $this->assertEquals(array(), UpdaterUtils::readUpdatesFile($updatesFile)); unlink($updatesFile); } @@ -66,14 +66,14 @@ public function testReadWriteUpdatesFile() $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; $updatesMethods = array('m1', 'm2', 'm3'); - UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); - $readMethods = UpdaterUtils::read_updates_file($updatesFile); + UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods); + $readMethods = UpdaterUtils::readUpdatesFile($updatesFile); $this->assertEquals($readMethods, $updatesMethods); // Update $updatesMethods[] = 'm4'; - UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); - $readMethods = UpdaterUtils::read_updates_file($updatesFile); + UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods); + $readMethods = UpdaterUtils::readUpdatesFile($updatesFile); $this->assertEquals($readMethods, $updatesMethods); unlink($updatesFile); } @@ -86,7 +86,7 @@ public function testWriteEmptyUpdatesFile() $this->expectException(\Exception::class); $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/'); - UpdaterUtils::write_updates_file('', array('test')); + UpdaterUtils::writeUpdatesFile('', array('test')); } /** @@ -101,7 +101,7 @@ public function testWriteUpdatesFileNotWritable() touch($updatesFile); chmod($updatesFile, 0444); try { - @UpdaterUtils::write_updates_file($updatesFile, array('test')); + @UpdaterUtils::writeUpdatesFile($updatesFile, array('test')); } catch (Exception $e) { unlink($updatesFile); throw $e; diff --git a/tests/netscape/BookmarkExportTest.php b/tests/netscape/BookmarkExportTest.php index 9b95ccc9e..ad288f78e 100644 --- a/tests/netscape/BookmarkExportTest.php +++ b/tests/netscape/BookmarkExportTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Netscape; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; use Shaarli\Formatter\BookmarkFormatter; @@ -56,12 +57,13 @@ class BookmarkExportTest extends TestCase */ public static function setUpBeforeClass(): void { + $mutex = new NoMutex(); static::$conf = new ConfigManager('tests/utils/config/configJson'); static::$conf->set('resource.datastore', static::$testDatastore); static::$refDb = new \ReferenceLinkDB(); static::$refDb->write(static::$testDatastore); static::$history = new History('sandbox/history.php'); - static::$bookmarkService = new BookmarkFileService(static::$conf, static::$history, true); + static::$bookmarkService = new BookmarkFileService(static::$conf, static::$history, $mutex, true); $factory = new FormatterFactory(static::$conf, true); static::$formatter = $factory->getFormatter('raw'); } diff --git a/tests/netscape/BookmarkImportTest.php b/tests/netscape/BookmarkImportTest.php index c1e49b5f4..6856ebcaf 100644 --- a/tests/netscape/BookmarkImportTest.php +++ b/tests/netscape/BookmarkImportTest.php @@ -3,6 +3,7 @@ namespace Shaarli\Netscape; use DateTime; +use malkusch\lock\mutex\NoMutex; use Psr\Http\Message\UploadedFileInterface; use Shaarli\Bookmark\Bookmark; use Shaarli\Bookmark\BookmarkFileService; @@ -87,6 +88,7 @@ public static function setUpBeforeClass(): void */ protected function setUp(): void { + $mutex = new NoMutex(); if (file_exists(self::$testDatastore)) { unlink(self::$testDatastore); } @@ -97,7 +99,7 @@ protected function setUp(): void $this->conf->set('resource.page_cache', $this->pagecache); $this->conf->set('resource.datastore', self::$testDatastore); $this->history = new History(self::$historyFilePath); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true); $this->netscapeBookmarkUtils = new NetscapeBookmarkUtils($this->bookmarkService, $this->conf, $this->history); } @@ -529,7 +531,7 @@ public function testSetDefaultTags() { $post = array( 'privacy' => 'public', - 'default_tags' => 'tag1,tag2 tag3' + 'default_tags' => 'tag1 tag2 tag3' ); $files = file2array('netscape_basic.htm'); $this->assertStringMatchesFormat( @@ -550,7 +552,7 @@ public function testSanitizeDefaultTags() { $post = array( 'privacy' => 'public', - 'default_tags' => 'tag1&,tag2 "tag3"' + 'default_tags' => 'tag1& tag2 "tag3"' ); $files = file2array('netscape_basic.htm'); $this->assertStringMatchesFormat( @@ -570,6 +572,43 @@ public function testSanitizeDefaultTags() ); } + /** + * Add user-specified tags to all imported bookmarks + */ + public function testSetDefaultTagsWithCustomSeparator() + { + $separator = '@'; + $this->conf->set('general.tags_separator', $separator); + $post = [ + 'privacy' => 'public', + 'default_tags' => 'tag1@tag2@tag3@multiple words tag' + ]; + $files = file2array('netscape_basic.htm'); + $this->assertStringMatchesFormat( + 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:' + .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.', + $this->netscapeBookmarkUtils->import($post, $files) + ); + $this->assertEquals(2, $this->bookmarkService->count()); + $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE)); + $this->assertEquals( + 'tag1@tag2@tag3@multiple words tag@private@secret', + $this->bookmarkService->get(0)->getTagsString($separator) + ); + $this->assertEquals( + ['tag1', 'tag2', 'tag3', 'multiple words tag', 'private', 'secret'], + $this->bookmarkService->get(0)->getTags() + ); + $this->assertEquals( + 'tag1@tag2@tag3@multiple words tag@public@hello@world', + $this->bookmarkService->get(1)->getTagsString($separator) + ); + $this->assertEquals( + ['tag1', 'tag2', 'tag3', 'multiple words tag', 'public', 'hello', 'world'], + $this->bookmarkService->get(1)->getTags() + ); + } + /** * Ensure each imported bookmark has a unique id * diff --git a/tests/plugins/PluginWallabagTest.php b/tests/plugins/PluginWallabagTest.php index 363172159..9a402fb75 100644 --- a/tests/plugins/PluginWallabagTest.php +++ b/tests/plugins/PluginWallabagTest.php @@ -49,14 +49,15 @@ public function testWallabagLinklist() $conf = new ConfigManager(''); $conf->set('plugins.WALLABAG_URL', 'value'); $str = 'http://randomstr.com/test'; - $data = array( + $data = [ 'title' => $str, - 'links' => array( - array( + 'links' => [ + [ 'url' => $str, - ) - ) - ); + ] + ], + '_LOGGEDIN_' => true, + ]; $data = hook_wallabag_render_linklist($data, $conf); $link = $data['links'][0]; @@ -69,4 +70,26 @@ public function testWallabagLinklist() $this->assertNotFalse(strpos($link['link_plugin'][0], urlencode($str))); $this->assertNotFalse(strpos($link['link_plugin'][0], $conf->get('plugins.WALLABAG_URL'))); } + + /** + * Test render_linklist hook while logged out: no change. + */ + public function testWallabagLinklistLoggedOut(): void + { + $conf = new ConfigManager(''); + $str = 'http://randomstr.com/test'; + $data = [ + 'title' => $str, + 'links' => [ + [ + 'url' => $str, + ] + ], + '_LOGGEDIN_' => false, + ]; + + $result = hook_wallabag_render_linklist($data, $conf); + + static::assertSame($data, $result); + } } diff --git a/tests/security/BanManagerTest.php b/tests/security/BanManagerTest.php index 698d3d10b..29d2791b0 100644 --- a/tests/security/BanManagerTest.php +++ b/tests/security/BanManagerTest.php @@ -3,7 +3,8 @@ namespace Shaarli\Security; -use Shaarli\FileUtils; +use Psr\Log\LoggerInterface; +use Shaarli\Helper\FileUtils; use Shaarli\TestCase; /** @@ -387,7 +388,7 @@ protected function getNewBanManagerInstance() 3, 1800, $this->banFile, - $this->logFile + $this->createMock(LoggerInterface::class) ); } } diff --git a/tests/security/LoginManagerTest.php b/tests/security/LoginManagerTest.php index d302983de..f7609fc67 100644 --- a/tests/security/LoginManagerTest.php +++ b/tests/security/LoginManagerTest.php @@ -2,6 +2,8 @@ namespace Shaarli\Security; +use Psr\Log\LoggerInterface; +use Shaarli\FakeConfigManager; use Shaarli\TestCase; /** @@ -9,7 +11,7 @@ */ class LoginManagerTest extends TestCase { - /** @var \FakeConfigManager Configuration Manager instance */ + /** @var FakeConfigManager Configuration Manager instance */ protected $configManager = null; /** @var LoginManager Login Manager instance */ @@ -60,6 +62,9 @@ class LoginManagerTest extends TestCase /** @var CookieManager */ protected $cookieManager; + /** @var BanManager */ + protected $banManager; + /** * Prepare or reset test resources */ @@ -71,7 +76,7 @@ protected function setUp(): void $this->passwordHash = sha1($this->password . $this->login . $this->salt); - $this->configManager = new \FakeConfigManager([ + $this->configManager = new FakeConfigManager([ 'credentials.login' => $this->login, 'credentials.hash' => $this->passwordHash, 'credentials.salt' => $this->salt, @@ -91,18 +96,29 @@ protected function setUp(): void return $this->cookie[$key] ?? null; }); $this->sessionManager = new SessionManager($this->session, $this->configManager, 'session_path'); - $this->loginManager = new LoginManager($this->configManager, $this->sessionManager, $this->cookieManager); + $this->banManager = $this->createMock(BanManager::class); + $this->loginManager = new LoginManager( + $this->configManager, + $this->sessionManager, + $this->cookieManager, + $this->banManager, + $this->createMock(LoggerInterface::class) + ); $this->server['REMOTE_ADDR'] = $this->ipAddr; } /** * Record a failed login attempt */ - public function testHandleFailedLogin() + public function testHandleFailedLogin(): void { + $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt'); + $this->banManager->method('isBanned')->willReturn(true); + $this->loginManager->handleFailedLogin($this->server); $this->loginManager->handleFailedLogin($this->server); - $this->assertFalse($this->loginManager->canLogin($this->server)); + + static::assertFalse($this->loginManager->canLogin($this->server)); } /** @@ -114,8 +130,13 @@ public function testHandleFailedLoginBehindTrustedProxy() 'REMOTE_ADDR' => $this->trustedProxy, 'HTTP_X_FORWARDED_FOR' => $this->ipAddr, ]; + + $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt'); + $this->banManager->method('isBanned')->willReturn(true); + $this->loginManager->handleFailedLogin($server); $this->loginManager->handleFailedLogin($server); + $this->assertFalse($this->loginManager->canLogin($server)); } @@ -196,10 +217,16 @@ public function testGenerateStaySignedInTokenSessionProtectionDisabled() */ public function testCheckLoginStateNotConfigured() { - $configManager = new \FakeConfigManager([ + $configManager = new FakeConfigManager([ 'resource.ban_file' => $this->banFile, ]); - $loginManager = new LoginManager($configManager, null, $this->cookieManager); + $loginManager = new LoginManager( + $configManager, + $this->sessionManager, + $this->cookieManager, + $this->banManager, + $this->createMock(LoggerInterface::class) + ); $loginManager->checkLoginState(''); $this->assertFalse($loginManager->isLoggedIn()); @@ -270,7 +297,7 @@ public function testCheckLoginStateClientIpChanged() public function testCheckCredentialsWrongLogin() { $this->assertFalse( - $this->loginManager->checkCredentials('', '', 'b4dl0g1n', $this->password) + $this->loginManager->checkCredentials('', 'b4dl0g1n', $this->password) ); } @@ -280,7 +307,7 @@ public function testCheckCredentialsWrongLogin() public function testCheckCredentialsWrongPassword() { $this->assertFalse( - $this->loginManager->checkCredentials('', '', $this->login, 'b4dp455wd') + $this->loginManager->checkCredentials('', $this->login, 'b4dp455wd') ); } @@ -290,7 +317,7 @@ public function testCheckCredentialsWrongPassword() public function testCheckCredentialsWrongLoginAndPassword() { $this->assertFalse( - $this->loginManager->checkCredentials('', '', 'b4dl0g1n', 'b4dp455wd') + $this->loginManager->checkCredentials('', 'b4dl0g1n', 'b4dp455wd') ); } @@ -300,7 +327,7 @@ public function testCheckCredentialsWrongLoginAndPassword() public function testCheckCredentialsGoodLoginAndPassword() { $this->assertTrue( - $this->loginManager->checkCredentials('', '', $this->login, $this->password) + $this->loginManager->checkCredentials('', $this->login, $this->password) ); } @@ -311,7 +338,7 @@ public function testCheckCredentialsFromUnreachableLdap() { $this->configManager->set('ldap.host', 'dummy'); $this->assertFalse( - $this->loginManager->checkCredentials('', '', $this->login, $this->password) + $this->loginManager->checkCredentials('', $this->login, $this->password) ); } diff --git a/tests/security/SessionManagerTest.php b/tests/security/SessionManagerTest.php index 3f9c3ef59..6830d7146 100644 --- a/tests/security/SessionManagerTest.php +++ b/tests/security/SessionManagerTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Security; +use Shaarli\FakeConfigManager; use Shaarli\TestCase; /** @@ -12,7 +13,7 @@ class SessionManagerTest extends TestCase /** @var array Session ID hashes */ protected static $sidHashes = null; - /** @var \FakeConfigManager ConfigManager substitute for testing */ + /** @var FakeConfigManager ConfigManager substitute for testing */ protected $conf = null; /** @var array $_SESSION array for testing */ @@ -34,7 +35,7 @@ public static function setUpBeforeClass(): void */ protected function setUp(): void { - $this->conf = new \FakeConfigManager([ + $this->conf = new FakeConfigManager([ 'credentials.login' => 'johndoe', 'credentials.salt' => 'salt', 'security.session_protection_disabled' => false, diff --git a/tests/updater/UpdaterTest.php b/tests/updater/UpdaterTest.php index a6280b8c9..cadd82653 100644 --- a/tests/updater/UpdaterTest.php +++ b/tests/updater/UpdaterTest.php @@ -2,6 +2,7 @@ namespace Shaarli\Updater; use Exception; +use malkusch\lock\mutex\NoMutex; use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; @@ -44,12 +45,13 @@ class UpdaterTest extends TestCase */ protected function setUp(): void { + $mutex = new NoMutex(); $this->refDB = new \ReferenceLinkDB(); $this->refDB->write(self::$testDatastore); copy('tests/utils/config/configJson.json.php', self::$configFile .'.json.php'); $this->conf = new ConfigManager(self::$configFile); - $this->bookmarkService = new BookmarkFileService($this->conf, $this->createMock(History::class), true); + $this->bookmarkService = new BookmarkFileService($this->conf, $this->createMock(History::class), $mutex, true); $this->updater = new Updater([], $this->bookmarkService, $this->conf, true); } @@ -58,10 +60,10 @@ protected function setUp(): void */ public function testReadEmptyUpdatesFile() { - $this->assertEquals(array(), UpdaterUtils::read_updates_file('')); + $this->assertEquals(array(), UpdaterUtils::readUpdatesFile('')); $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; touch($updatesFile); - $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile)); + $this->assertEquals(array(), UpdaterUtils::readUpdatesFile($updatesFile)); unlink($updatesFile); } @@ -73,14 +75,14 @@ public function testReadWriteUpdatesFile() $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; $updatesMethods = array('m1', 'm2', 'm3'); - UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); - $readMethods = UpdaterUtils::read_updates_file($updatesFile); + UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods); + $readMethods = UpdaterUtils::readUpdatesFile($updatesFile); $this->assertEquals($readMethods, $updatesMethods); // Update $updatesMethods[] = 'm4'; - UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); - $readMethods = UpdaterUtils::read_updates_file($updatesFile); + UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods); + $readMethods = UpdaterUtils::readUpdatesFile($updatesFile); $this->assertEquals($readMethods, $updatesMethods); unlink($updatesFile); } @@ -93,7 +95,7 @@ public function testWriteEmptyUpdatesFile() $this->expectException(\Exception::class); $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/'); - UpdaterUtils::write_updates_file('', array('test')); + UpdaterUtils::writeUpdatesFile('', array('test')); } /** @@ -108,7 +110,7 @@ public function testWriteUpdatesFileNotWritable() touch($updatesFile); chmod($updatesFile, 0444); try { - @UpdaterUtils::write_updates_file($updatesFile, array('test')); + @UpdaterUtils::writeUpdatesFile($updatesFile, array('test')); } catch (Exception $e) { unlink($updatesFile); throw $e; diff --git a/tests/utils/FakeApplicationUtils.php b/tests/utils/FakeApplicationUtils.php index de83d5985..d5289ede2 100644 --- a/tests/utils/FakeApplicationUtils.php +++ b/tests/utils/FakeApplicationUtils.php @@ -2,6 +2,8 @@ namespace Shaarli; +use Shaarli\Helper\ApplicationUtils; + /** * Fake ApplicationUtils class to avoid HTTP requests */ diff --git a/tests/utils/FakeConfigManager.php b/tests/utils/FakeConfigManager.php index 360b34a98..014c2af0d 100644 --- a/tests/utils/FakeConfigManager.php +++ b/tests/utils/FakeConfigManager.php @@ -1,9 +1,13 @@ values[$key] = $value; } @@ -35,7 +39,7 @@ public function set($key, $value) * * @return mixed The value if set, else the name of the key */ - public function get($key) + public function get($key, $default = '') { if (isset($this->values[$key])) { return $this->values[$key]; diff --git a/tests/utils/ReferenceHistory.php b/tests/utils/ReferenceHistory.php index 516c9f51e..aed5d2cf1 100644 --- a/tests/utils/ReferenceHistory.php +++ b/tests/utils/ReferenceHistory.php @@ -1,6 +1,6 @@ addLink( diff --git a/tpl/default/addlink.html b/tpl/default/addlink.html index 67d3ebd1c..4aac7ff1e 100644 --- a/tpl/default/addlink.html +++ b/tpl/default/addlink.html @@ -20,6 +20,62 @@

{"Shaare a new link"|t}

+ + + + + {include="page.footer"} diff --git a/tpl/default/changetag.html b/tpl/default/changetag.html index 16c558969..13b7f24a6 100644 --- a/tpl/default/changetag.html +++ b/tpl/default/changetag.html @@ -27,14 +27,38 @@

{"Manage tags"|t}

{'Case sensitive'|t}
- - + +

{'You can also edit tags in the'|t} {'tag list'|t}.

+ +
+
+
+

{"Change tags separator"|t}

+
+

+ {'Your current tag separator is'|t} {$tags_separator}{if="!empty($tags_separator_desc)"} ({$tags_separator_desc}){/if}. +

+
+ +
+ +
+ +
+

+ {'Note that hashtags won\'t fully work with a non-whitespace separator.'|t} +

+
+
+
{include="page.footer"} diff --git a/tpl/default/daily.html b/tpl/default/daily.html index 3ab8053f7..5e038c393 100644 --- a/tpl/default/daily.html +++ b/tpl/default/daily.html @@ -6,12 +6,25 @@ {include="page.header"} + + +

- {'The Daily Shaarli'|t} - + {$localizedType} Shaarli + t($type)])"}" + > + +

@@ -25,19 +38,19 @@

- {'All links of one day in a single page.'|t} + {function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}

- {if="!empty($dayDesc)"} - {$dayDesc} - - {/if} - {function="format_date($dayDate, false)"} + {$dayDesc}

@@ -76,7 +86,7 @@

{if="$thumbnails_enabled && !empty($link.thumbnail)"}
- thumbnail
diff --git a/tpl/default/dailyrss.html b/tpl/default/dailyrss.html index d40d94968..871a3ba75 100644 --- a/tpl/default/dailyrss.html +++ b/tpl/default/dailyrss.html @@ -1,9 +1,9 @@ - Daily - {$title} + {$localizedType} - {$title} {$index_url} - Daily shaared bookmarks + {function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"} {$language} {$index_url} Shaarli @@ -18,12 +18,15 @@ {loop="$value.links"}

{$value.title}

- {if="!$hide_timestamps"}{$value.created|format_date} - {/if}{if="$value.tags"}{$value.tags}{/if}
+ {if="!$hide_timestamps"}{$value.created|format_date} — {/if} + {'Permalink'|t} + {if="$value.tags"} — {$value.tags}{/if} +
{$value.url}

{if="$value.thumbnail"}thumbnail{/if}
{if="$value.description"}{$value.description}{/if} -


+

{/loop} ]]> diff --git a/tpl/default/editlink.batch.html b/tpl/default/editlink.batch.html new file mode 100644 index 000000000..b1f8e5bd9 --- /dev/null +++ b/tpl/default/editlink.batch.html @@ -0,0 +1,32 @@ + + + + {include="includes"} + + +
+
+
/
+
+
+
+
+
+ +{include="page.header"} + +
+ +
+ +{loop="$links"} + {include="editlink"} +{/loop} + +
+ +
+ +{include="page.footer"} +{if="$async_metadata"}{/if} + diff --git a/tpl/default/editlink.html b/tpl/default/editlink.html index 568545bd5..83e541fdf 100644 --- a/tpl/default/editlink.html +++ b/tpl/default/editlink.html @@ -1,3 +1,4 @@ +{if="empty($batch_mode)"} @@ -5,6 +6,10 @@ {include="page.header"} +{else} + {ignore}Lil hack: when included in a loop in batch mode, `$value` is assigned by RainTPL with template vars.{/ignore} + {function="extract($value) ? '' : ''"} +{/if}