From: ArthurHoaro Date: Thu, 12 Nov 2020 12:02:36 +0000 (+0100) Subject: Merge branch 'master' into v0.12 X-Git-Tag: v0.12.1^0 X-Git-Url: https://git.immae.eu/?p=github%2Fshaarli%2FShaarli.git;a=commitdiff_plain;h=1409f1c89a7ca01456ae2dcd6357d296e2b99f5a;hp=054e03f37fa29da8066f1a637919f13c7e7dc5d2 Merge branch 'master' into v0.12 --- diff --git a/.docker/nginx.conf b/.docker/nginx.conf index 07fba33f..30810a87 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 96fd31c5..19fd87a5 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 25fcfb03..9d1522df 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 d7460947..422bf835 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 0ec52acc..be815364 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 f1686d67..18404049 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 e2ff71fd..f6120b71 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 0ff6bd3f..181b61c4 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 4fb0bfe0..46dda8d5 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 4fd2f294..d230f39d 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 @@ class Languages $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 @@ class Languages $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 @@ class Languages // 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 @@ class Languages 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 5aec23c8..c4ff8d7a 100644 --- a/application/Thumbnailer.php +++ b/application/Thumbnailer.php @@ -13,7 +13,7 @@ use WebThumbnailer\WebThumbnailer; */ 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 @@ class Thumbnailer // 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 @@ class Thumbnailer */ 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 c1869ef8..a420eb96 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 bcfda65c..952378ab 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 f5b53b01..9fb88358 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 @@ class ApiMiddleware $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 faebb8f5..05a2840a 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 @@ class ApiUtils * 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 @@ class ApiUtils $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 c4b3d0c3..88a845eb 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php @@ -4,6 +4,7 @@ namespace Shaarli\Api\Controllers; 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 505647a9..d83a3a25 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 29247950..c379b962 100644 --- a/application/api/controllers/Links.php +++ b/application/api/controllers/Links.php @@ -96,11 +96,12 @@ class Links extends ApiController */ 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 @@ class Links extends ApiController */ 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 @@ class Links extends ApiController $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 @@ class Links extends ApiController */ 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 @@ class Links extends ApiController ); } - $responseBookmark = $this->bookmarkService->get($args['id']); + $responseBookmark = $this->bookmarkService->get($id); $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); $this->bookmarkService->set($responseBookmark); @@ -189,10 +193,11 @@ class Links extends ApiController */ 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 0e3f4776..c77e9eea 100644 --- a/application/api/exceptions/ApiAuthorizationException.php +++ b/application/api/exceptions/ApiAuthorizationException.php @@ -28,7 +28,7 @@ class ApiAuthorizationException extends ApiException */ 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 d6b66323..7deafb96 100644 --- a/application/api/exceptions/ApiException.php +++ b/application/api/exceptions/ApiException.php @@ -44,7 +44,7 @@ abstract class ApiException extends \Exception } 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 1beb8be2..4238ef25 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 @@ class Bookmark * - 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 @@ class Bookmark * - 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 @@ class Bookmark /** * Get the Id. * - * @return int + * @return int|null */ - public function getId() + public function getId(): ?int { return $this->id; } @@ -149,9 +161,9 @@ class Bookmark /** * Get the ShortUrl. * - * @return string + * @return string|null */ - public function getShortUrl() + public function getShortUrl(): ?string { return $this->shortUrl; } @@ -159,9 +171,9 @@ class Bookmark /** * Get the Url. * - * @return string + * @return string|null */ - public function getUrl() + public function getUrl(): ?string { return $this->url; } @@ -171,7 +183,7 @@ class Bookmark * * @return string */ - public function getTitle() + public function getTitle(): ?string { return $this->title; } @@ -181,7 +193,7 @@ class Bookmark * * @return string */ - public function getDescription() + public function getDescription(): string { return ! empty($this->description) ? $this->description : ''; } @@ -191,7 +203,7 @@ class Bookmark * * @return DateTimeInterface */ - public function getCreated() + public function getCreated(): ?DateTimeInterface { return $this->created; } @@ -201,7 +213,7 @@ class Bookmark * * @return DateTimeInterface */ - public function getUpdated() + public function getUpdated(): ?DateTimeInterface { return $this->updated; } @@ -209,11 +221,11 @@ class Bookmark /** * 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 @@ class Bookmark /** * 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 @@ class Bookmark /** * 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 @@ class Bookmark /** * 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 @@ class Bookmark * 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 @@ class Bookmark /** * 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 @@ class Bookmark * * @return bool */ - public function isPrivate() + public function isPrivate(): bool { return $this->private ? true : false; } @@ -309,11 +321,11 @@ class Bookmark /** * 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 @@ class Bookmark /** * 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 @@ class Bookmark /** * 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 @@ class Bookmark /** * 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 @@ class Bookmark /** * 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 @@ class Bookmark } /** - * @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 @@ class Bookmark * - 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 @@ class Bookmark * * @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 3bd5eb20..b9328116 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 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess /** * 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 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess * * @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 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess } /** - * @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 c9ec2609..6666a251 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 @@ class BookmarkFileService implements BookmarkServiceInterface } 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 @@ class BookmarkFileService implements BookmarkServiceInterface 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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @inheritDoc */ - public function findByUrl($url) + public function findByUrl(string $url): ?Bookmark { return $this->bookmarks->getByUrl($url); } @@ -115,10 +125,10 @@ class BookmarkFileService implements BookmarkServiceInterface * @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 @@ class BookmarkFileService implements BookmarkServiceInterface } // 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 @@ class BookmarkFileService implements BookmarkServiceInterface return $this->bookmarkFilter->filter( BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, - [$searchtags, $searchterm], + [$searchTags, $searchTerm], $caseSensitive, $visibility, $untaggedOnly @@ -145,7 +155,7 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @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 @@ class BookmarkFileService implements BookmarkServiceInterface } $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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @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 @@ class BookmarkFileService implements BookmarkServiceInterface } $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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @inheritDoc */ - public function count($visibility = null) + public function count(string $visibility = null): int { return count($this->search([], $visibility)); } @@ -288,7 +288,7 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @inheritDoc */ - public function save() + public function save(): void { if (true !== $this->isLoggedIn) { // TODO: raise an Exception instead @@ -303,14 +303,15 @@ class BookmarkFileService implements BookmarkServiceInterface /** * @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 @@ class BookmarkFileService implements BookmarkServiceInterface $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 @@ class BookmarkFileService implements BookmarkServiceInterface /** * 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 @@ class BookmarkFileService implements BookmarkServiceInterface 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 6636bbfe..db83c51c 100644 --- a/application/bookmark/BookmarkFilter.php +++ b/application/bookmark/BookmarkFilter.php @@ -1,9 +1,12 @@ bookmarks = $bookmarks; + $this->conf = $conf; } /** @@ -77,8 +84,13 @@ class BookmarkFilter * * @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 @@ class BookmarkFilter $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 @@ class BookmarkFilter * * @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 @@ class BookmarkFilter * * @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 @@ class BookmarkFilter * @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 @@ class BookmarkFilter $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 @@ class BookmarkFilter } } - // 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 @@ class BookmarkFilter * * @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 @@ class BookmarkFilter $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 @@ class BookmarkFilter $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 @@ class BookmarkFilter * 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 @@ class BookmarkFilter } // 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 @@ class BookmarkFilter 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 @@ class BookmarkFilter } } - if (empty(trim($link->getTagsString()))) { + if (empty($link->getTags())) { $filtered[$key] = $link; } } @@ -427,11 +453,11 @@ class BookmarkFilter * @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 @@ class BookmarkFilter * @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 @@ class BookmarkFilter 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 6bf7f365..c78dbe41 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 @@ class BookmarkIO 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 @@ class BookmarkIO /** * 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 @@ class BookmarkIO 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 815047e3..8ab5c441 100644 --- a/application/bookmark/BookmarkInitializer.php +++ b/application/bookmark/BookmarkInitializer.php @@ -1,5 +1,7 @@ bookmarkService = $bookmarkService; } @@ -31,13 +36,13 @@ class BookmarkInitializer /** * 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 @@ Now you can edit or delete the default shaares. $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 @@ Markdown also supports tables: '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 b9b483eb..08cdbb4e 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 e7af4d55..d65e97ed 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 827a3d35..a91d1efa 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 95f34b50..df91f3bc 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 3efe5b6f..a623bc8b 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 4c98be30..717a038f 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -1,4 +1,5 @@ getConfigFileExt()) && !$isLoggedIn) { @@ -366,10 +367,12 @@ class ConfigManager $this->setEmpty('general.links_per_page', 20); $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); $this->setEmpty('general.default_note_title', 'Note: '); - $this->setEmpty('general.retrieve_description', false); + $this->setEmpty('general.retrieve_description', true); + $this->setEmpty('general.enable_async_metadata', true); + $this->setEmpty('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 @@ class ConfigManager $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 cad34594..53d6a7a3 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 @@ class ConfigPhp implements ConfigIO */ 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 @@ class ConfigPhp implements ConfigIO 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 ea8dfbda..6cadef12 100644 --- a/application/config/ConfigPlugin.php +++ b/application/config/ConfigPlugin.php @@ -39,8 +39,8 @@ function save_plugin_config($formData) throw new PluginConfigOrderException(); } - $plugins = array(); - $newEnabledPlugins = array(); + $plugins = []; + $newEnabledPlugins = []; foreach ($formData as $key => $data) { if (startsWith($key, 'order')) { continue; @@ -62,7 +62,7 @@ function save_plugin_config($formData) throw new PluginConfigOrderException(); } - $finalPlugins = array(); + $finalPlugins = []; // Make plugins order continuous. foreach ($plugins as $plugin) { $finalPlugins[] = $plugin; @@ -81,7 +81,7 @@ function save_plugin_config($formData) */ 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 9e0a9359..a5f4356a 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 @@ class ContainerBuilder $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 @@ class ContainerBuilder 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 @@ class ContainerBuilder $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 66e669aa..3e5bd252 100644 --- a/application/container/ShaarliContainer.php +++ b/application/container/ShaarliContainer.php @@ -4,12 +4,14 @@ declare(strict_types=1); 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 @@ use Slim\Container; * @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 2aa25e5c..c1a9ffbe 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 @@ class FeedBuilder $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 9d4a0fa0..7e0afafc 100644 --- a/application/formatter/BookmarkDefaultFormatter.php +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -12,10 +12,13 @@ namespace Shaarli\Formatter; */ 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 @@ class BookmarkDefaultFormatter extends BookmarkFormatter /** * @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 @@ class BookmarkDefaultFormatter extends BookmarkFormatter /** * @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 @@ class BookmarkDefaultFormatter extends BookmarkFormatter 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 @@ class BookmarkDefaultFormatter extends BookmarkFormatter { 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 0042dafe..124ce78b 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 @@ use Shaarli\Config\ConfigManager; * * 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 @@ abstract class BookmarkFormatter $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 @@ abstract class BookmarkFormatter $out['updated'] = $this->formatUpdated($bookmark); $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); + return $out; } @@ -135,6 +162,18 @@ abstract class BookmarkFormatter 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 @@ abstract class BookmarkFormatter 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 @@ abstract class BookmarkFormatter * * @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 @@ abstract class BookmarkFormatter */ protected function formatTagString($bookmark) { - return implode(' ', $this->formatTagList($bookmark)); + return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark)); } /** @@ -214,9 +277,9 @@ abstract class BookmarkFormatter * * @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 @@ abstract class BookmarkFormatter * * @param Bookmark $bookmark instance * - * @return DateTime instance + * @return DateTimeInterface instance */ protected function formatCreated(Bookmark $bookmark) { @@ -249,7 +312,7 @@ abstract class BookmarkFormatter * * @param Bookmark $bookmark instance * - * @return DateTime instance + * @return DateTimeInterface instance */ protected function formatUpdated(Bookmark $bookmark) { @@ -288,6 +351,7 @@ abstract class BookmarkFormatter /** * 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 00000000..0694b23f --- /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 5d244d4c..ee4e8dca 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 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter 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 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter ->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 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter 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 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter * \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 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter */ 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 bc372273..4ff07cdf 100644 --- a/application/formatter/BookmarkRawFormatter.php +++ b/application/formatter/BookmarkRawFormatter.php @@ -10,4 +10,6 @@ namespace Shaarli\Formatter; * * @package Shaarli\Formatter */ -class BookmarkRawFormatter extends BookmarkFormatter {} +class BookmarkRawFormatter extends BookmarkFormatter +{ +} diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php index a029579f..bb865aed 100644 --- a/application/formatter/FormatterFactory.php +++ b/application/formatter/FormatterFactory.php @@ -41,7 +41,7 @@ class FormatterFactory 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 d1aa1399..164217f4 100644 --- a/application/front/ShaarliMiddleware.php +++ b/application/front/ShaarliMiddleware.php @@ -42,7 +42,8 @@ class ShaarliMiddleware $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 @@ class ShaarliMiddleware */ 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 e675fcca..dc421661 100644 --- a/application/front/controller/admin/ConfigureController.php +++ b/application/front/controller/admin/ConfigureController.php @@ -30,7 +30,7 @@ class ConfigureController extends ShaarliAdminController '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 @@ class ConfigureController extends ShaarliAdminController $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 @@ class ConfigureController extends ShaarliAdminController } $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 2be957fa..f01d7e9b 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 @@ class ExportController extends ShaarliAdminController $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 758d5ef9..c2ad6a09 100644 --- a/application/front/controller/admin/ImportController.php +++ b/application/front/controller/admin/ImportController.php @@ -38,7 +38,7 @@ class ImportController extends ShaarliAdminController 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 @@ class ImportController extends ShaarliAdminController $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 bb083486..00000000 --- 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 2065c3e2..8675a0c5 100644 --- a/application/front/controller/admin/ManageTagController.php +++ b/application/front/controller/admin/ManageTagController.php @@ -24,9 +24,15 @@ class ManageTagController extends ShaarliAdminController $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 @@ class ManageTagController extends ShaarliAdminController $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 00000000..ff845944 --- /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 5ec0d24b..4aaf1f82 100644 --- a/application/front/controller/admin/PasswordController.php +++ b/application/front/controller/admin/PasswordController.php @@ -25,7 +25,7 @@ class PasswordController extends ShaarliAdminController $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 @@ class PasswordController extends ShaarliAdminController // 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 8e059681..ae47c1af 100644 --- a/application/front/controller/admin/PluginsController.php +++ b/application/front/controller/admin/PluginsController.php @@ -42,7 +42,7 @@ class PluginsController extends ShaarliAdminController $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 @@ class PluginsController extends ShaarliAdminController 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 00000000..fabeaf2f --- /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 d9a7a2e0..0917b6d2 100644 --- a/application/front/controller/admin/SessionFilterController.php +++ b/application/front/controller/admin/SessionFilterController.php @@ -45,6 +45,4 @@ class SessionFilterController extends ShaarliAdminController 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 00000000..ab8e7f40 --- /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 00000000..35837baa --- /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 00000000..4cbfcdc5 --- /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 81c87ed0..94d97d4b 100644 --- a/application/front/controller/admin/ThumbnailsController.php +++ b/application/front/controller/admin/ThumbnailsController.php @@ -34,7 +34,7 @@ class ThumbnailsController extends ShaarliAdminController $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 @@ class ThumbnailsController extends ShaarliAdminController } 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 a87f20d2..560e5e3e 100644 --- a/application/front/controller/admin/ToolsController.php +++ b/application/front/controller/admin/ToolsController.php @@ -28,7 +28,7 @@ class ToolsController extends ShaarliAdminController $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 18368751..fe8231be 100644 --- a/application/front/controller/visitor/BookmarkListController.php +++ b/application/front/controller/visitor/BookmarkListController.php @@ -35,7 +35,8 @@ class BookmarkListController extends ShaarliVisitorController $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 @@ class BookmarkListController extends ShaarliVisitorController $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 @@ class BookmarkListController extends ShaarliVisitorController '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 @@ class BookmarkListController extends ShaarliVisitorController 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 @@ class BookmarkListController extends ShaarliVisitorController */ 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 @@ class BookmarkListController extends ShaarliVisitorController $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 @@ class BookmarkListController extends ShaarliVisitorController */ 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 @@ class BookmarkListController extends ShaarliVisitorController '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 07617cf1..846cfe22 100644 --- a/application/front/controller/visitor/DailyController.php +++ b/application/front/controller/visitor/DailyController.php @@ -5,8 +5,8 @@ declare(strict_types=1); 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 @@ class DailyController extends ShaarliVisitorController $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 @@ class DailyController extends ShaarliVisitorController $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 @@ class DailyController extends ShaarliVisitorController } $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 @@ class DailyController extends ShaarliVisitorController /** @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 @@ class DailyController extends ShaarliVisitorController // 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 @@ class DailyController extends ShaarliVisitorController 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 10aa84c8..428e8254 100644 --- a/application/front/controller/visitor/ErrorController.php +++ b/application/front/controller/visitor/ErrorController.php @@ -26,12 +26,15 @@ class ErrorController extends ShaarliVisitorController $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 @@ class ErrorController extends ShaarliVisitorController $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 8d8b546a..edc7ef43 100644 --- a/application/front/controller/visitor/FeedController.php +++ b/application/front/controller/visitor/FeedController.php @@ -27,7 +27,7 @@ class FeedController extends ShaarliVisitorController 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 7cb32777..bf965929 100644 --- a/application/front/controller/visitor/InstallController.php +++ b/application/front/controller/visitor/InstallController.php @@ -4,10 +4,10 @@ declare(strict_types=1); 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 @@ class InstallController extends ShaarliVisitorController // 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 @@ class InstallController extends ShaarliVisitorController $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 @@ class InstallController extends ShaarliVisitorController // 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 @@ class InstallController extends ShaarliVisitorController 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 @@ class InstallController extends ShaarliVisitorController $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 @@ class InstallController extends ShaarliVisitorController } 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 @@ class InstallController extends ShaarliVisitorController 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 121ba40b..4b881535 100644 --- a/application/front/controller/visitor/LoginController.php +++ b/application/front/controller/visitor/LoginController.php @@ -43,7 +43,7 @@ class LoginController extends ShaarliVisitorController $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 @@ class LoginController extends ShaarliVisitorController 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 @@ class LoginController extends ShaarliVisitorController */ 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 3c57f8dd..23553ee6 100644 --- a/application/front/controller/visitor/PictureWallController.php +++ b/application/front/controller/visitor/PictureWallController.php @@ -26,7 +26,7 @@ class PictureWallController extends ShaarliVisitorController $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 55c075a2..ae946c59 100644 --- a/application/front/controller/visitor/ShaarliVisitorController.php +++ b/application/front/controller/visitor/ShaarliVisitorController.php @@ -106,6 +106,7 @@ abstract class ShaarliVisitorController '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 @@ abstract class ShaarliVisitorController 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 @@ abstract class ShaarliVisitorController } } - $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 76ed7690..46d62779 100644 --- a/application/front/controller/visitor/TagCloudController.php +++ b/application/front/controller/visitor/TagCloudController.php @@ -47,13 +47,14 @@ class TagCloudController extends ShaarliVisitorController */ 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 @@ class TagCloudController extends ShaarliVisitorController $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 @@ class TagCloudController extends ShaarliVisitorController $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 de4e7ea2..3aa58542 100644 --- a/application/front/controller/visitor/TagController.php +++ b/application/front/controller/visitor/TagController.php @@ -27,7 +27,7 @@ class TagController extends ShaarliVisitorController // 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 @@ class TagController extends ShaarliVisitorController 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 @@ class TagController extends ShaarliVisitorController 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 @@ class TagController extends ShaarliVisitorController $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 @@ class TagController extends ShaarliVisitorController 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 @@ class TagController extends ShaarliVisitorController } 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 3aa21829..212dd8e2 100644 --- a/application/ApplicationUtils.php +++ b/application/helper/ApplicationUtils.php @@ -1,5 +1,6 @@ '; @@ -63,8 +65,8 @@ class ApplicationUtils } 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 @@ class ApplicationUtils // 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 @@ class ApplicationUtils /** * 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 @@ class ApplicationUtils } } + 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 @@ class ApplicationUtils { 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 00000000..5fabc907 --- /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 30560bfc..e8a2168c 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 81d9e076..e80e0c01 100644 --- a/application/http/HttpAccess.php +++ b/application/http/HttpAccess.php @@ -14,9 +14,14 @@ namespace Shaarli\Http; */ 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 @@ class HttpAccess &$description, &$keywords, $retrieveDescription, - $curlGetInfo = 'curl_getinfo' + $tagsSeparator ) { return get_curl_download_callback( $charset, @@ -33,7 +38,12 @@ class HttpAccess $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 9f414073..4bde1d5b 100644 --- a/application/http/HttpUtils.php +++ b/application/http/HttpUtils.php @@ -6,12 +6,14 @@ use Shaarli\Http\Url; * 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 @@ use Shaarli\Http\Url; * @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 get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF * 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 get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF $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 get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF $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 get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF } } - 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 00000000..2e1401ec --- /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 90444a2f..fe87088f 100644 --- a/application/http/Url.php +++ b/application/http/Url.php @@ -17,7 +17,7 @@ namespace Shaarli\Http; */ 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 @@ class Url 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 e8d1a283..de5b7db1 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 7bf76fd4..d3beafe0 100644 --- a/application/legacy/LegacyLinkDB.php +++ b/application/legacy/LegacyLinkDB.php @@ -8,7 +8,7 @@ use DateTime; 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 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess } // 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 @@ You use the community supported version of the original Shaarli project, by Seba '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 @@ You use the community supported version of the original Shaarli project, by Seba '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 @@ You use the community supported version of the original Shaarli project, by Seba { // 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 @@ You use the community supported version of the original Shaarli project, by Seba $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 @@ You use the community supported version of the original Shaarli project, by Seba * @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 @@ You use the community supported version of the original Shaarli project, by Seba */ 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 7cf93d60..e6d186c4 100644 --- a/application/legacy/LegacyLinkFilter.php +++ b/application/legacy/LegacyLinkFilter.php @@ -120,7 +120,7 @@ class LegacyLinkFilter return $this->links; } - $out = array(); + $out = []; foreach ($this->links as $key => $value) { if ($value['private'] && $visibility === 'private') { $out[$key] = $value; @@ -143,7 +143,7 @@ class LegacyLinkFilter */ 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 @@ class LegacyLinkFilter 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 @@ class LegacyLinkFilter $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 @@ class LegacyLinkFilter } } - $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 @@ class LegacyLinkFilter } // create resulting array - $filtered = array(); + $filtered = []; // iterate over each link foreach ($this->links as $key => $link) { @@ -352,7 +352,7 @@ class LegacyLinkFilter $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 0ab3a55b..9bda54b8 100644 --- a/application/legacy/LegacyUpdater.php +++ b/application/legacy/LegacyUpdater.php @@ -7,7 +7,6 @@ use RainTPL; 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\ConfigJson; 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 @@ class LegacyUpdater */ public function update() { - $updatesRan = array(); + $updatesRan = []; // If the user isn't logged in, exit without updating. if ($this->isLoggedIn !== true) { @@ -106,7 +106,8 @@ class LegacyUpdater 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 @@ class LegacyUpdater } // 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 @@ class LegacyUpdater $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 @@ class LegacyUpdater */ 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 @@ class LegacyUpdater $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 b83f16f8..2d97b4c8 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -59,11 +59,11 @@ class NetscapeBookmarkUtils $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 @@ class NetscapeBookmarkUtils // 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 @@ class NetscapeBookmarkUtils $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 1b2197c9..3ea55728 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 @@ class PluginManager * * @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 @@ class PluginManager */ public function getPluginsMeta() { - $metaData = array(); + $metaData = []; $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); // Browse all plugin directories. @@ -216,9 +218,9 @@ class PluginManager 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 e5386f02..21ac6604 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 @@ class PageBuilder $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 @@ class PageBuilder $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 @@ class PageBuilder } } + $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 8af8228a..03b424f3 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 86096c64..18471f0a 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 68190c54..7077af5b 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 @@ class BanManager 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 @@ class BanManager 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 d74c3118..b795b80e 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 @@ class LoginManager // 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 @@ class LoginManager * * @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 @@ class LoginManager /** * 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 @@ class LoginManager * * @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 @@ class LoginManager */ 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 36df8c1c..f957b91a 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 @@ class SessionManager 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 88a7bc7b..4f557d0f 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php @@ -88,7 +88,8 @@ class Updater 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 @@ class Updater 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 @@ class Updater $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 828a49fc..206f826e 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 @@ class UpdaterUtils return explode(';', $content); } } - return array(); + return []; } /** @@ -30,7 +30,7 @@ class UpdaterUtils * * @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 @@ class UpdaterUtils $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 00000000..d5a28a35 --- /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 00000000..557325ee --- /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 be986ae0..dd532bb7 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 a528adb0..cc8ccc1e 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 1688dce0..33e178af 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 66830b59..55f1c37d 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 cd9fcf5b..138319ca 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 2c8b0ea7..0023df88 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 c152fe92..fc406c00 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 297d7c29..a49b6033 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 263fb761..b1326cce 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 5c085e03..c42e8ffe 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 c29774de..f09fadc2 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 a3de4b1c..4ebae447 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 9a6e3958..26dede4e 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 b420bb51..57f42fc2 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 b10397dd..1eb7659a 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,5 @@ get('dev.debug', false)) { }); } +$logger = new Logger( + dirname($conf->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 @@ if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { 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 @@ $app->group('/admin', function () { $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 @@ $app->group('/admin', function () { $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 @@ $app->group('/api/v1', function () { $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 f0b84368..d8462712 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_only_cookies', 1); 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 8a24512a..b879b223 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 29b95d56..c559e35d 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 ab6ed6de..80b1dd95 100644 --- a/plugins/addlink_toolbar/addlink_toolbar.php +++ b/plugins/addlink_toolbar/addlink_toolbar.php @@ -17,26 +17,26 @@ use Shaarli\Render\TemplatePage; 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 922b5966..88f2b653 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 defb01f7..22d27b68 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 79e7380b..a5450989 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 8fe6799c..299b84fb 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 95499e39..2ae10476 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 805c1ad9..f2003cb9 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 6dc0e5b7..e810104e 100644 --- a/tests/HistoryTest.php +++ b/tests/HistoryTest.php @@ -89,14 +89,6 @@ class HistoryTest extends \Shaarli\TestCase $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 6e787d7f..59dca75f 100644 --- a/tests/UtilsTest.php +++ b/tests/UtilsTest.php @@ -63,41 +63,25 @@ class UtilsTest extends \Shaarli\TestCase } /** - * 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 1598e1e8..10b29ab2 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 @@ class InfoTest extends TestCase $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 cf9464f0..805c9be3 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 @@ class DeleteLinkTest extends \Shaarli\TestCase $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 @@ class DeleteLinkTest extends \Shaarli\TestCase $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 99dc606f..1ec56ef3 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 @@ class GetLinkIdTest extends \Shaarli\TestCase $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 ca1bfc63..b1c46ee2 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 @@ class GetLinksTest extends \Shaarli\TestCase $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 @@ class GetLinksTest extends \Shaarli\TestCase $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 fe3de66f..e12f803b 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 @@ class PostLinkTest extends TestCase $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 @@ class PostLinkTest extends TestCase $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 @@ class PostLinkTest extends TestCase $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 @@ class PostLinkTest extends TestCase '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 @@ class PostLinkTest extends TestCase $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 @@ class PostLinkTest extends TestCase $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 a2e87c59..240ee323 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 @@ class PutLinkTest extends \Shaarli\TestCase $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 1326eb47..37f07229 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 @@ class DeleteTagTest extends \Shaarli\TestCase $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 @@ class DeleteTagTest extends \Shaarli\TestCase $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 @@ class DeleteTagTest extends \Shaarli\TestCase $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 9c05954b..878de5a4 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 @@ class GetTagNameTest extends \Shaarli\TestCase $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 3459fdfa..b565a8c4 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 74edde78..c73f6d3b 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 @@ class PutTagTest extends \Shaarli\TestCase $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 ebed9bfc..1953078c 100644 --- a/tests/bookmark/BookmarkArrayTest.php +++ b/tests/bookmark/BookmarkArrayTest.php @@ -90,19 +90,6 @@ class BookmarkArrayTest extends TestCase $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 c399822b..f619aff3 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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase { 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 @@ class BookmarkFileServiceTest extends TestCase { 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 @@ class BookmarkFileServiceTest extends TestCase */ 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 @@ class BookmarkFileServiceTest extends TestCase 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 @@ class BookmarkFileServiceTest extends TestCase // 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 @@ class BookmarkFileServiceTest extends TestCase 'tag3' => 1, 'tag4' => 1, 'ut' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ], $this->privateLinkDB->bookmarksCountPerTag() ); @@ -927,6 +881,37 @@ class BookmarkFileServiceTest extends TestCase $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 @@ class BookmarkFileServiceTest extends TestCase 'tag4' => 1, 'ut' => 1, 'w3c' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ]; $tags = $this->privateLinkDB->bookmarksCountPerTag(); @@ -1054,6 +1043,10 @@ class BookmarkFileServiceTest extends TestCase '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 @@ class BookmarkFileServiceTest extends TestCase } /** - * 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 48c7f824..835674f2 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 @@ class BookmarkFilterTest extends TestCase )) ); } + + /** + * 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 25704004..0c8420ce 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 @@ class BookmarkInitializerTest extends TestCase $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 @@ class BookmarkInitializerTest extends TestCase { $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 @@ class BookmarkInitializerTest extends TestCase $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 @@ class BookmarkInitializerTest extends TestCase 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 afec2440..cb91b26b 100644 --- a/tests/bookmark/BookmarkTest.php +++ b/tests/bookmark/BookmarkTest.php @@ -78,6 +78,23 @@ class BookmarkTest extends TestCase $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 @@ class BookmarkTest extends TestCase $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 @@ class BookmarkTest extends TestCase $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 @@ class BookmarkTest extends TestCase { $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 @@ class BookmarkTest extends TestCase $array = [ 'tag1 ', ' tag2', - 'tag3.tag3-2,', - ', tag4', - ', ', + 'tag3.tag3-2', + ' tag4', + ' ', '-tag5 ', ]; $bookmark->setTags($array); @@ -385,4 +364,48 @@ class BookmarkTest extends TestCase $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 ef00b92f..ddab4e3c 100644 --- a/tests/bookmark/LinkUtilsTest.php +++ b/tests/bookmark/LinkUtilsTest.php @@ -94,8 +94,108 @@ class LinkUtilsTest extends TestCase 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 @@ class LinkUtilsTest extends TestCase { $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 @@ class LinkUtilsTest extends TestCase $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 @@ class LinkUtilsTest extends TestCase /** * 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 @@ class LinkUtilsTest extends TestCase . '' . '', ]; - 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 @@ class LinkUtilsTest extends TestCase /** * 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 @@ class LinkUtilsTest extends TestCase * 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 @@ class LinkUtilsTest extends TestCase '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 00000000..d4941ef3 --- /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 aca6cff3..d82db0a7 100644 --- a/tests/front/controller/admin/ConfigureControllerTest.php +++ b/tests/front/controller/admin/ConfigureControllerTest.php @@ -51,7 +51,7 @@ class ConfigureControllerTest extends TestCase 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 0f27ec2f..00000000 --- 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 8a0ff7a9..af6f273f 100644 --- a/tests/front/controller/admin/ManageTagControllerTest.php +++ b/tests/front/controller/admin/ManageTagControllerTest.php @@ -6,6 +6,7 @@ namespace Shaarli\Front\Controller\Admin; 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 @@ class ManageTagControllerTest extends TestCase 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 @@ class ManageTagControllerTest extends TestCase 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 00000000..355cce7d --- /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 00000000..a27ebe64 --- /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 096d0774..28b1c023 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\BookmarkFormatter; 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 @@ class ChangeVisibilityBookmarkTest extends TestCase $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 ba774e21..a276d988 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 @@ class DeleteBookmarkTest extends TestCase $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 @@ class DeleteBookmarkTest extends TestCase { $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 @@ class DeleteBookmarkTest extends TestCase { $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 @@ class DeleteBookmarkTest extends TestCase $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 @@ class DeleteBookmarkTest extends TestCase ; $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 50ce7df1..b89206ce 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 @@ class PinBookmarkTest extends TestCase $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 00000000..ae61dfb7 --- /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 00000000..ce8e112b --- /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 2eb95251..964773da 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 @@ class DisplayCreateFormTest extends TestCase $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 @@ class DisplayCreateFormTest extends TestCase }); $response = new Response(); - $this->container->httpAccess - ->expects(static::once()) - ->method('getCurlDownloadCallback') - ->willReturnCallback( - function (&$charset, &$title, &$description, &$tags) use ( - $remoteTitle, - $remoteDesc, - $remoteTags - ): callable { - return function () use ( - &$charset, - &$title, - &$description, - &$tags, - $remoteTitle, - $remoteDesc, - $remoteTags - ): void { - $charset = 'ISO-8859-1'; - $title = $remoteTitle; - $description = $remoteDesc; - $tags = $remoteTags; - }; - } - ) - ; - $this->container->httpAccess - ->expects(static::once()) - ->method('getHttpResponse') - ->with($expectedUrl, 30, 4194304) - ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void { - $callback(); - }) - ; + $this->container->conf = $this->createMock(ConfigManager::class); + $this->container->conf->method('get')->willReturnCallback(function (string $param, $default) { + if ($param === 'general.enable_async_metadata') { + return false; + } + + return $default; + }); + + $this->container->metadataRetriever->expects(static::once())->method('retrieve')->willReturn([ + 'title' => $remoteTitle, + 'description' => $remoteDesc, + 'tags' => $remoteTags, + ]); $this->container->bookmarkService ->expects(static::once()) @@ -119,7 +101,73 @@ class DisplayCreateFormTest extends TestCase 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 @@ class DisplayCreateFormTest extends TestCase static::assertSame($tags, $assignedVariables['tags']); static::assertArrayHasKey('source', $assignedVariables); static::assertArrayHasKey('default_private_links', $assignedVariables); + static::assertArrayHasKey('async_metadata', $assignedVariables); + static::assertArrayHasKey('retrieve_description', $assignedVariables); } /** @@ -142,7 +192,7 @@ class DisplayCreateFormTest extends TestCase '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 @@ class DisplayCreateFormTest extends TestCase 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 @@ class DisplayCreateFormTest extends TestCase 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 2dc3f41c..738cea12 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 @@ class DisplayEditFormTest extends TestCase $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 @@ class DisplayEditFormTest extends TestCase 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 f7a68226..b6a861bc 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 @@ class SaveBookmarkTest extends TestCase $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 @@ class SaveBookmarkTest extends TestCase $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 @@ class SaveBookmarkTest extends TestCase $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 @@ class SaveBookmarkTest extends TestCase /** * 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 @@ class SaveBookmarkTest extends TestCase $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 @@ class SaveBookmarkTest extends TestCase $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 @@ class SaveBookmarkTest extends TestCase 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 f4a8acff..e5749654 100644 --- a/tests/front/controller/admin/ThumbnailsControllerTest.php +++ b/tests/front/controller/admin/ThumbnailsControllerTest.php @@ -89,8 +89,10 @@ class ThumbnailsControllerTest extends TestCase $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 0c95df97..dec938f2 100644 --- a/tests/front/controller/visitor/BookmarkListControllerTest.php +++ b/tests/front/controller/visitor/BookmarkListControllerTest.php @@ -173,7 +173,7 @@ class BookmarkListControllerTest extends TestCase $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 @@ class BookmarkListControllerTest extends TestCase ->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 @@ class BookmarkListControllerTest extends TestCase 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 @@ class BookmarkListControllerTest extends TestCase ); } + /** + * 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 @@ class BookmarkListControllerTest extends TestCase $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 @@ class BookmarkListControllerTest extends TestCase $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 @@ class BookmarkListControllerTest extends TestCase 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 fc78bc13..70fbce54 100644 --- a/tests/front/controller/visitor/DailyControllerTest.php +++ b/tests/front/controller/visitor/DailyControllerTest.php @@ -28,52 +28,49 @@ class DailyControllerTest extends TestCase 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 @@ class DailyControllerTest extends TestCase ->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 @@ class DailyControllerTest extends TestCase ); 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 @@ class DailyControllerTest extends TestCase $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 @@ class DailyControllerTest extends TestCase $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 @@ class DailyControllerTest extends TestCase // 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 @@ class DailyControllerTest extends TestCase 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 @@ class DailyControllerTest extends TestCase 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 @@ class DailyControllerTest extends TestCase (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 @@ class DailyControllerTest extends TestCase 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 @@ class DailyControllerTest extends TestCase 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 @@ class DailyControllerTest extends TestCase 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 @@ class DailyControllerTest extends TestCase 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 75408cf4..e18a6fa2 100644 --- a/tests/front/controller/visitor/ErrorControllerTest.php +++ b/tests/front/controller/visitor/ErrorControllerTest.php @@ -50,7 +50,31 @@ class ErrorControllerTest extends TestCase } /** - * 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 @@ class ErrorControllerTest extends TestCase $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 fc0bb7d1..02229f68 100644 --- a/tests/front/controller/visitor/FrontControllerMockHelper.php +++ b/tests/front/controller/visitor/FrontControllerMockHelper.php @@ -41,6 +41,10 @@ trait FrontControllerMockHelper // 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 345ad544..2105ed77 100644 --- a/tests/front/controller/visitor/InstallControllerTest.php +++ b/tests/front/controller/visitor/InstallControllerTest.php @@ -79,6 +79,15 @@ class InstallControllerTest extends TestCase 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 1312ccb7..00d9eab3 100644 --- a/tests/front/controller/visitor/LoginControllerTest.php +++ b/tests/front/controller/visitor/LoginControllerTest.php @@ -195,7 +195,7 @@ class LoginControllerTest extends TestCase $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 9305612e..4915573d 100644 --- a/tests/front/controller/visitor/TagCloudControllerTest.php +++ b/tests/front/controller/visitor/TagCloudControllerTest.php @@ -100,7 +100,7 @@ class TagCloudControllerTest extends TestCase ->with() ->willReturnCallback(function (string $key): ?string { if ('searchtags' === $key) { - return 'ghi def'; + return 'ghi@def'; } return null; @@ -131,7 +131,7 @@ class TagCloudControllerTest extends TestCase ->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 @@ class TagCloudControllerTest extends TestCase 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 @@ class TagCloudControllerTest extends TestCase ->with() ->willReturnCallback(function (string $key): ?string { if ('searchtags' === $key) { - return 'ghi def'; + return 'ghi@def'; } elseif ('sort' === $key) { return 'alpha'; } @@ -310,7 +310,7 @@ class TagCloudControllerTest extends TestCase ->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 @@ class TagCloudControllerTest extends TestCase 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 750ea02d..5a556c6d 100644 --- a/tests/front/controller/visitor/TagControllerTest.php +++ b/tests/front/controller/visitor/TagControllerTest.php @@ -50,7 +50,7 @@ class TagControllerTest extends TestCase 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 @@ class TagControllerTest extends TestCase 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 @@ class TagControllerTest extends TestCase 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 a232b351..654857b9 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 @@ class ApplicationUtilsTest extends \Shaarli\TestCase 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 00000000..5255b7b1 --- /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 9163bdf1..8035f79c 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 00000000..3c9eaa0e --- /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 df2cad62..5c3fd425 100644 --- a/tests/legacy/LegacyLinkDBTest.php +++ b/tests/legacy/LegacyLinkDBTest.php @@ -296,6 +296,10 @@ class LegacyLinkDBTest extends \Shaarli\TestCase // 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 @@ class LegacyLinkDBTest extends \Shaarli\TestCase 'tag3' => 1, 'tag4' => 1, 'ut' => 1, + 'assurance' => 1, + 'coding-style' => 1, + 'quality' => 1, + 'standards' => 1, ), self::$privateLinkDB->linksCountPerTag() ); @@ -544,6 +552,10 @@ class LegacyLinkDBTest extends \Shaarli\TestCase '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 f7391b86..395dd4b7 100644 --- a/tests/legacy/LegacyUpdaterTest.php +++ b/tests/legacy/LegacyUpdaterTest.php @@ -51,10 +51,10 @@ class LegacyUpdaterTest extends \Shaarli\TestCase */ 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 @@ class LegacyUpdaterTest extends \Shaarli\TestCase $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 @@ class LegacyUpdaterTest extends \Shaarli\TestCase $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 @@ class LegacyUpdaterTest extends \Shaarli\TestCase 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 9b95ccc9..ad288f78 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 c1e49b5f..6856ebca 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 @@ class BookmarkImportTest extends TestCase */ protected function setUp(): void { + $mutex = new NoMutex(); if (file_exists(self::$testDatastore)) { unlink(self::$testDatastore); } @@ -97,7 +99,7 @@ class BookmarkImportTest extends TestCase $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 @@ class BookmarkImportTest extends TestCase { $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 @@ class BookmarkImportTest extends TestCase { $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 @@ class BookmarkImportTest extends TestCase ); } + /** + * 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 36317215..9a402fb7 100644 --- a/tests/plugins/PluginWallabagTest.php +++ b/tests/plugins/PluginWallabagTest.php @@ -49,14 +49,15 @@ class PluginWallabagTest extends \Shaarli\TestCase $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 @@ class PluginWallabagTest extends \Shaarli\TestCase $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 698d3d10..29d2791b 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 @@ class BanManagerTest extends TestCase 3, 1800, $this->banFile, - $this->logFile + $this->createMock(LoggerInterface::class) ); } } diff --git a/tests/security/LoginManagerTest.php b/tests/security/LoginManagerTest.php index d302983d..f7609fc6 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 @@ use Shaarli\TestCase; */ 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 @@ class LoginManagerTest extends TestCase $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 @@ class LoginManagerTest extends TestCase 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 @@ class LoginManagerTest extends TestCase '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 @@ class LoginManagerTest extends TestCase */ 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 @@ class LoginManagerTest extends TestCase public function testCheckCredentialsWrongLogin() { $this->assertFalse( - $this->loginManager->checkCredentials('', '', 'b4dl0g1n', $this->password) + $this->loginManager->checkCredentials('', 'b4dl0g1n', $this->password) ); } @@ -280,7 +307,7 @@ class LoginManagerTest extends TestCase public function testCheckCredentialsWrongPassword() { $this->assertFalse( - $this->loginManager->checkCredentials('', '', $this->login, 'b4dp455wd') + $this->loginManager->checkCredentials('', $this->login, 'b4dp455wd') ); } @@ -290,7 +317,7 @@ class LoginManagerTest extends TestCase public function testCheckCredentialsWrongLoginAndPassword() { $this->assertFalse( - $this->loginManager->checkCredentials('', '', 'b4dl0g1n', 'b4dp455wd') + $this->loginManager->checkCredentials('', 'b4dl0g1n', 'b4dp455wd') ); } @@ -300,7 +327,7 @@ class LoginManagerTest extends TestCase public function testCheckCredentialsGoodLoginAndPassword() { $this->assertTrue( - $this->loginManager->checkCredentials('', '', $this->login, $this->password) + $this->loginManager->checkCredentials('', $this->login, $this->password) ); } @@ -311,7 +338,7 @@ class LoginManagerTest extends TestCase { $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 3f9c3ef5..6830d714 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 @@ class SessionManagerTest extends TestCase */ 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 a6280b8c..cadd8265 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 @@ class UpdaterTest extends TestCase */ 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 @@ class UpdaterTest extends TestCase $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 @@ class UpdaterTest extends TestCase $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 @@ class UpdaterTest extends TestCase 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 de83d598..d5289ede 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 360b34a9..014c2af0 100644 --- a/tests/utils/FakeConfigManager.php +++ b/tests/utils/FakeConfigManager.php @@ -1,9 +1,13 @@ values[$key] = $value; } @@ -35,7 +39,7 @@ class FakeConfigManager * * @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 516c9f51..aed5d2cf 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 67d3ebd1..4aac7ff1 100644 --- a/tpl/default/addlink.html +++ b/tpl/default/addlink.html @@ -20,6 +20,62 @@ + + + + + {include="page.footer"} diff --git a/tpl/default/changetag.html b/tpl/default/changetag.html index 16c55896..13b7f24a 100644 --- a/tpl/default/changetag.html +++ b/tpl/default/changetag.html @@ -27,14 +27,38 @@
{'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 3ab8053f..5e038c39 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 d40d9496..871a3ba7 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 00000000..b1f8e5bd --- /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 568545bd..83e541fd 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}