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;
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;
- }
}
}
.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
pagecache/*
tmp/*
+# Shaarli's docs are created during the build
+doc/html/
+
# Eclipse project files
.settings
.buildpath
# 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
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
script:
- make clean
- make check_permissions
+ - make code_sniffer
- make all_tests
- 991 ArthurHoaro <arthur@hoa.ro>
+ 1097 ArthurHoaro <arthur@hoa.ro>
402 VirtualTam <virtualtam@flibidi.net>
294 nodiscc <nodiscc@gmail.com>
56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
2 Alexandre G.-Raymond <alex@ndre.gr>
2 Chris Kuethe <chris.kuethe@gmail.com>
2 Felix Bartels <felix@host-consultants.de>
+ 2 Ganesh Kandu <kanduganesh@gmail.com>
2 Guillaume Virlet <github@virlet.org>
2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
2 Mathieu Chabanon <git@matchab.fr>
2 pips <pips@e5150.fr>
2 trailjeep <trailjeep@gmail.com>
2 yude <yudesleepy@gmail.com>
+ 2 yudete <yu@yude.moe>
1 Adrien Oliva <adrien.oliva@yapbreak.fr>
1 Adrien le Maire <adrien@alemaire.be>
1 Alexis J <alexis@effingo.be>
1 Kevin Masson <kevin.masson@methodinthemadness.eu>
1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
1 Lionel Martin <renarddesmers@gmail.com>
+ 1 Loïc Carr <zizou.xena@gmail.com>
1 Mark Gerarts <mark.gerarts@gmail.com>
1 Marsup <marsup@gmail.com>
1 Paul van den Burg <github@paulvandenburg.nl>
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
php7-openssl \
php7-session \
php7-xml \
+ php7-simplexml \
php7-zlib \
s6
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
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:
_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)
<?php
+
namespace Shaarli;
use DateTime;
use Exception;
use Shaarli\Bookmark\Bookmark;
+use Shaarli\Helper\FileUtils;
/**
* Class History
/**
* @var string Action key: a new link has been created.
*/
- const CREATED = 'CREATED';
+ public const CREATED = 'CREATED';
/**
* @var string Action key: a link has been updated.
*/
- const UPDATED = 'UPDATED';
+ public const UPDATED = 'UPDATED';
/**
* @var string Action key: a link has been deleted.
*/
- const DELETED = 'DELETED';
+ public const DELETED = 'DELETED';
/**
* @var string Action key: settings have been updated.
*/
- const SETTINGS = 'SETTINGS';
+ public const SETTINGS = 'SETTINGS';
/**
* @var string Action key: a bulk import has been processed.
*/
- const IMPORT = 'IMPORT';
+ public const IMPORT = 'IMPORT';
/**
* @var string History file path.
/**
* Core translations domain
*/
- const DEFAULT_DOMAIN = 'shaarli';
+ public const DEFAULT_DOMAIN = 'shaarli';
/**
* @var TranslatorInterface
$this->language = $confLanguage;
}
- if (! extension_loaded('gettext')
+ if (
+ ! extension_loaded('gettext')
|| in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
) {
$this->initPhpTranslator();
$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);
}
$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) {
// 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);
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);
*/
class Thumbnailer
{
- const COMMON_MEDIA_DOMAINS = [
+ protected const COMMON_MEDIA_DOMAINS = [
'imgur.com',
'flickr.com',
'youtube.com',
'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.
// 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.'
));
}
*/
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;
<?php
+
/**
* Generates a list of available timezone continents and cities.
*
// Try to split the provided timezone
$spos = strpos($preselectedTimezone, '/');
$pcontinent = substr($preselectedTimezone, 0, $spos);
- $pcity = substr($preselectedTimezone, $spos+1);
+ $pcity = substr($preselectedTimezone, $spos + 1);
}
$continents = [];
}
$continent = substr($tz, 0, $spos);
- $city = substr($tz, $spos+1);
+ $city = substr($tz, $spos + 1);
$cities[] = ['continent' => $continent, 'city' => $city];
$continents[$continent] = true;
}
function isTimeZoneValid($continent, $city)
{
return in_array(
- $continent.'/'.$city,
+ $continent . '/' . $city,
timezone_identifiers_list()
);
}
<?php
+
/**
* Shaarli utilities
*/
/**
- * Logs a message to a text file
+ * Format log using provided data.
*
- * The log format is compatible with fail2ban.
+ * @param string $message the message to log
+ * @param string|null $clientIp the client's remote IPv4/IPv6 address
*
- * @param string $logFile where to write the logs
- * @param string $clientIp the client's remote IPv4/IPv6 address
- * @param string $message the message to log
+ * @return string Formatted message to log
*/
-function logm($logFile, $clientIp, $message)
+function format_log(string $message, string $clientIp = null): string
{
- file_put_contents(
- $logFile,
- date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
- FILE_APPEND
- );
+ $out = $message;
+
+ if (!empty($clientIp)) {
+ // Note: we keep the first dash to avoid breaking fail2ban configs
+ $out = '- ' . $clientIp . ' - ' . $out;
+ }
+
+ return $out;
}
/**
}
if (is_array($input)) {
- $out = array();
+ $out = [];
foreach ($input as $key => $value) {
$out[escape($key)] = escape($value);
}
*
* @return string $referer - final referer.
*/
-function generateLocation($referer, $host, $loopTerms = array())
+function generateLocation($referer, $host, $loopTerms = [])
{
$finalReferer = './?';
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 = [];
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.
*
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;
}
* 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();
}
<?php
+
namespace Shaarli\Api;
+use malkusch\lock\mutex\FlockMutex;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
use Shaarli\Api\Exceptions\ApiException;
use Shaarli\Bookmark\BookmarkFileService;
*/
protected function checkToken($request)
{
- if (!$request->hasHeader('Authorization')
+ if (
+ !$request->hasHeader('Authorization')
&& !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
) {
throw new ApiAuthorizationException('JWT token not provided');
$linkDb = new BookmarkFileService(
$conf,
$this->container->get('history'),
+ new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
true
);
$this->container['db'] = $linkDb;
<?php
+
namespace Shaarli\Api;
use Shaarli\Api\Exceptions\ApiAuthorizationException;
throw new ApiAuthorizationException('Malformed JWT token');
}
- $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true));
+ $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
if ($parts[2] != $genSign) {
throw new ApiAuthorizationException('Invalid JWT signature');
}
throw new ApiAuthorizationException('Invalid JWT payload');
}
- if (empty($payload->iat)
+ if (
+ empty($payload->iat)
|| $payload->iat > time()
|| time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
) {
* 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']) : '';
$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;
}
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
+use Shaarli\History;
use Slim\Container;
/**
protected $bookmarkService;
/**
- * @var HistoryController
+ * @var History
*/
protected $history;
<?php
-
namespace Shaarli\Api\Controllers;
use Shaarli\Api\Exceptions\ApiBadParametersException;
$info = [
'global_counter' => $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);
*/
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);
}
*/
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(
$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);
}
*/
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),
);
}
- $responseBookmark = $this->bookmarkService->get($args['id']);
+ $responseBookmark = $this->bookmarkService->get($id);
$responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
$this->bookmarkService->set($responseBookmark);
*/
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);
*/
public function setMessage($message)
{
- $original = $this->debug === true ? ': '. $this->getMessage() : '';
+ $original = $this->debug === true ? ': ' . $this->getMessage() : '';
$this->message = $message . $original;
}
}
}
return [
'message' => $this->getMessage(),
- 'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
+ 'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
];
}
<?php
+declare(strict_types=1);
+
namespace Shaarli\Bookmark;
use DateTime;
class Bookmark
{
/** @var string Date format used in string (former ID format) */
- const LINK_DATE_FORMAT = 'Ymd_His';
+ public const LINK_DATE_FORMAT = 'Ymd_His';
/** @var int Bookmark ID */
protected $id;
/** @var bool True if the bookmark can only be seen while logged in */
protected $private;
+ /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
+ protected $additionalContent = [];
+
/**
* Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
*
- * @param array $data
+ * @param array $data
+ * @param string $tagsSeparator Tags separator loaded from the config file.
+ * This is a context data, and it should *never* be stored in the Bookmark object.
*
* @return $this
*/
- public function fromArray($data)
+ public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
{
- $this->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;
}
* - 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']);
+ }
}
/**
* - 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)) {
/**
* Get the Id.
*
- * @return int
+ * @return int|null
*/
- public function getId()
+ public function getId(): ?int
{
return $this->id;
}
/**
* Get the ShortUrl.
*
- * @return string
+ * @return string|null
*/
- public function getShortUrl()
+ public function getShortUrl(): ?string
{
return $this->shortUrl;
}
/**
* Get the Url.
*
- * @return string
+ * @return string|null
*/
- public function getUrl()
+ public function getUrl(): ?string
{
return $this->url;
}
*
* @return string
*/
- public function getTitle()
+ public function getTitle(): ?string
{
return $this->title;
}
*
* @return string
*/
- public function getDescription()
+ public function getDescription(): string
{
return ! empty($this->description) ? $this->description : '';
}
*
* @return DateTimeInterface
*/
- public function getCreated()
+ public function getCreated(): ?DateTimeInterface
{
return $this->created;
}
*
* @return DateTimeInterface
*/
- public function getUpdated()
+ public function getUpdated(): ?DateTimeInterface
{
return $this->updated;
}
/**
* 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;
/**
* 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);
}
/**
* 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;
}
/**
* 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;
* 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;
/**
* 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;
*
* @return bool
*/
- public function isPrivate()
+ public function isPrivate(): bool
{
return $this->private ? true : false;
}
/**
* 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;
/**
* Get the Tags.
*
- * @return array
+ * @return string[]
*/
- public function getTags()
+ public function getTags(): array
{
return is_array($this->tags) ? $this->tags : [];
}
/**
* 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;
}
/**
* 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;
}
/**
* 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;
}
/**
- * @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] === '?';
* - 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);
}
}
*
* @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);
}
<?php
+declare(strict_types=1);
+
namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\InvalidBookmarkException;
*/
public function offsetSet($offset, $value)
{
- if (! $value instanceof Bookmark
+ if (
+ ! $value instanceof Bookmark
|| $value->getId() === null || empty($value->getUrl())
|| ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
|| $offset !== null && $offset !== $value->getId()
/**
* 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;
*
* @return int next ID.
*/
- public function getNextId()
+ public function getNextId(): int
{
if (!empty($this->ids)) {
return max(array_keys($this->ids)) + 1;
}
/**
- * @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]])
) {
<?php
+declare(strict_types=1);
namespace Shaarli\Bookmark;
-
+use DateTime;
use Exception;
+use malkusch\lock\mutex\Mutex;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
/** @var bool true for logged in users. Default value to retrieve private bookmarks. */
protected $isLoggedIn;
+ /** @var Mutex */
+ protected $mutex;
+
/**
* @inheritDoc
*/
- public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
+ public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn)
{
$this->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)) {
} else {
try {
$this->bookmarks = $this->bookmarksIO->read();
- } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
+ } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
$this->bookmarks = new BookmarkArray();
if ($this->isLoggedIn) {
if (! $this->bookmarks instanceof BookmarkArray) {
$this->migrate();
exit(
- 'Your data store has been migrated, please reload the page.'. PHP_EOL .
+ 'Your data store has been migrated, please reload the page.' . PHP_EOL .
'If this message keeps showing up, please delete data/updates.txt file.'
);
}
}
- $this->bookmarkFilter = new BookmarkFilter($this->bookmarks);
+ $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
}
/**
* @inheritDoc
*/
- public function findByHash($hash)
+ public function findByHash(string $hash, string $privateKey = null): Bookmark
{
$bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
// PHP 7.3 introduced array_key_first() to avoid this hack
$first = reset($bookmark);
- if (! $this->isLoggedIn && $first->isPrivate()) {
- throw new Exception('Not authorized');
+ if (
+ !$this->isLoggedIn
+ && $first->isPrivate()
+ && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
+ ) {
+ throw new BookmarkNotFoundException();
}
return $first;
/**
* @inheritDoc
*/
- public function findByUrl($url)
+ public function findByUrl(string $url): ?Bookmark
{
return $this->bookmarks->getByUrl($url);
}
* @inheritDoc
*/
public function search(
- $request = [],
- $visibility = null,
- $caseSensitive = false,
- $untaggedOnly = false,
+ array $request = [],
+ string $visibility = null,
+ bool $caseSensitive = false,
+ bool $untaggedOnly = false,
bool $ignoreSticky = false
) {
if ($visibility === null) {
}
// 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);
return $this->bookmarkFilter->filter(
BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
- [$searchtags, $searchterm],
+ [$searchTags, $searchTerm],
$caseSensitive,
$visibility,
$untaggedOnly
/**
* @inheritDoc
*/
- public function get($id, $visibility = null)
+ public function get(int $id, string $visibility = null): Bookmark
{
if (! isset($this->bookmarks[$id])) {
throw new BookmarkNotFoundException();
}
$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');
/**
* @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();
/**
* @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());
/**
* @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);
}
/**
* @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();
}
/**
* @inheritDoc
*/
- public function exists($id, $visibility = null)
+ public function exists(int $id, string $visibility = null): bool
{
if (! isset($this->bookmarks[$id])) {
return false;
}
$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;
/**
* @inheritDoc
*/
- public function count($visibility = null)
+ public function count(string $visibility = null): int
{
return count($this->search([], $visibility));
}
/**
* @inheritDoc
*/
- public function save()
+ public function save(): void
{
if (true !== $this->isLoggedIn) {
// TODO: raise an Exception instead
/**
* @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)
$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();
/**
* Handles migration to the new database format (BookmarksArray).
*/
- protected function migrate()
+ protected function migrate(): void
{
$bookmarkDb = new LegacyLinkDB(
$this->conf->get('resource.datastore'),
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()
);
<?php
+declare(strict_types=1);
+
namespace Shaarli\Bookmark;
use Exception;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Config\ConfigManager;
/**
* Class LinkFilter.
*/
private $bookmarks;
+ /** @var ConfigManager */
+ protected $conf;
+
/**
* @param Bookmark[] $bookmarks initialization.
*/
- public function __construct($bookmarks)
+ public function __construct($bookmarks, ConfigManager $conf)
{
$this->bookmarks = $bookmarks;
+ $this->conf = $conf;
}
/**
*
* @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';
}
$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:
*
* @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;
*
* @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()) {
* @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.
$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);
}
}
- // 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;
}
}
*
* @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
$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);
$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;
}
* 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);
}
}
// 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';
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(
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
);
if (count($descTags[1])) {
// there were some tags in the description, add them to the search string
- $search .= ' ' . implode(' ', $descTags[1]);
+ $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
}
- };
+ }
// match regular expression with search string
if (!preg_match($re, $search)) {
// this entry does _not_ match our regex
*
* @param string $visibility return only all/private/public bookmarks.
*
- * @return array filtered bookmarks.
+ * @return Bookmark[] filtered bookmarks.
*/
- public function filterUntagged($visibility)
+ public function filterUntagged(string $visibility)
{
$filtered = [];
foreach ($this->bookmarks as $key => $link) {
}
}
- if (empty(trim($link->getTagsString()))) {
+ if (empty($link->getTags())) {
$filtered[$key] = $link;
}
}
* @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');
* @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');
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;
+ }
}
<?php
+declare(strict_types=1);
+
namespace Shaarli\Bookmark;
+use malkusch\lock\mutex\Mutex;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
use Shaarli\Bookmark\Exception\EmptyDataStoreException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
*/
protected $conf;
+
+ /** @var Mutex */
+ protected $mutex;
+
/**
* string Datastore PHP prefix
*/
protected static $phpPrefix = '<?php /* ';
-
/**
* string Datastore PHP suffix
*/
*
* @param ConfigManager $conf instance
*/
- public function __construct($conf)
+ public function __construct(ConfigManager $conf, Mutex $mutex = null)
{
+ if ($mutex === null) {
+ // This should only happen with legacy classes
+ $mutex = new NoMutex();
+ }
$this->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
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) {
/**
* Saves the database from memory to disk
*
- * @param BookmarkArray $links instance.
+ * @param Bookmark[] $links
*
* @throws NotWritableDataStoreException the datastore is not writable
*/
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
+ );
+ });
}
}
<?php
+declare(strict_types=1);
+
namespace Shaarli\Bookmark;
/**
* To prevent data corruption, it does not overwrite existing bookmarks,
* even though there should not be any.
*
+ * We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
+ * @phpcs:disable Generic.Files.LineLength.TooLong
+ *
* @package Shaarli\Bookmark
*/
class BookmarkInitializer
*
* @param BookmarkServiceInterface $bookmarkService
*/
- public function __construct($bookmarkService)
+ public function __construct(BookmarkServiceInterface $bookmarkService)
{
$this->bookmarkService = $bookmarkService;
}
/**
* Initialize the data store with default bookmarks
*/
- public function initialize()
+ public function initialize(): void
{
$bookmark = new Bookmark();
- $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
- $bookmark->setUrl('https://vimeo.com/153493904');
+ $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
+ $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
$bookmark->setDescription(t(
-'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
+ 'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
Explore your new Shaarli instance by trying out controls and menus.
Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
$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.
'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.
<?php
-namespace Shaarli\Bookmark;
+declare(strict_types=1);
+namespace Shaarli\Bookmark;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
-use Shaarli\Config\ConfigManager;
-use Shaarli\History;
/**
* Class BookmarksService
*
* This is the entry point to manipulate the bookmark DB.
+ *
+ * Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
+ * so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
*/
interface BookmarkServiceInterface
{
- /**
- * BookmarksService constructor.
- *
- * @param ConfigManager $conf instance
- * @param History $history instance
- * @param bool $isLoggedIn true if the current user is logged in
- */
- public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
-
/**
* Find a bookmark by hash
*
- * @param string $hash
+ * @param string $hash Bookmark's hash
+ * @param string|null $privateKey Optional key used to access private links while logged out
*
- * @return mixed
+ * @return Bookmark
*
* @throws \Exception
*/
- public function findByHash($hash);
+ public function findByHash(string $hash, string $privateKey = null);
/**
* @param $url
*
* @return Bookmark|null
*/
- public function findByUrl($url);
+ public function findByUrl(string $url): ?Bookmark;
/**
* Search bookmarks
*
- * @param mixed $request
- * @param string $visibility
- * @param bool $caseSensitive
- * @param bool $untaggedOnly
- * @param bool $ignoreSticky
+ * @param array $request
+ * @param ?string $visibility
+ * @param bool $caseSensitive
+ * @param bool $untaggedOnly
+ * @param bool $ignoreSticky
*
* @return Bookmark[]
*/
public function search(
- $request = [],
- $visibility = null,
- $caseSensitive = false,
- $untaggedOnly = false,
+ array $request = [],
+ string $visibility = null,
+ bool $caseSensitive = false,
+ bool $untaggedOnly = false,
bool $ignoreSticky = false
);
/**
* Get a single bookmark by its ID.
*
- * @param int $id Bookmark ID
- * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
- * exception
+ * @param int $id Bookmark ID
+ * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+ * exception
*
* @return Bookmark
*
* @throws BookmarkNotFoundException
* @throws \Exception
*/
- public function get($id, $visibility = null);
+ public function get(int $id, string $visibility = null);
/**
* Updates an existing bookmark (depending on its ID).
* @throws BookmarkNotFoundException
* @throws \Exception
*/
- public function set($bookmark, $save = true);
+ public function set(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Adds a new bookmark (the ID must be empty).
*
* @throws \Exception
*/
- public function add($bookmark, $save = true);
+ public function add(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Adds or updates a bookmark depending on its ID:
*
* @throws \Exception
*/
- public function addOrSet($bookmark, $save = true);
+ public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
/**
* Deletes a bookmark.
*
* @throws \Exception
*/
- public function remove($bookmark, $save = true);
+ public function remove(Bookmark $bookmark, bool $save = true): void;
/**
* Get a single bookmark by its ID.
*
- * @param int $id Bookmark ID
- * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
- * exception
+ * @param int $id Bookmark ID
+ * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+ * exception
*
* @return bool
*/
- public function exists($id, $visibility = null);
+ public function exists(int $id, string $visibility = null): bool;
/**
* Return the number of available bookmarks for given visibility.
*
- * @param string $visibility public|private|all
+ * @param ?string $visibility public|private|all
*
* @return int Number of bookmarks
*/
- public function count($visibility = null);
+ public function count(string $visibility = null): int;
/**
* Write the datastore.
*
* @throws NotWritableDataStoreException
*/
- public function save();
+ public function save(): void;
/**
* Returns the list tags appearing in the bookmarks with the given tags
*
- * @param array $filteringTags tags selecting the bookmarks to consider
- * @param string $visibility process only all/private/public bookmarks
+ * @param array|null $filteringTags tags selecting the bookmarks to consider
+ * @param string|null $visibility process only all/private/public bookmarks
*
* @return array tag => 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;
}
{
$propertiesKey = ['property', 'name', 'itemprop'];
$properties = implode('|', $propertiesKey);
- // Try to retrieve OpenGraph image.
- $ogRegex = '#<meta[^>]+(?:'. $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 = '#<meta[^>]+(?:' . $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 = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#';
+ $ogRegexReverse = '#<meta[^>]+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;
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
- $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
+ $replacement = '$1<a href="' . $indexUrl . './add-tag/$2" title="Hashtag $2">#$2</a>';
return preg_replace($regex, $replacement, $description);
}
*
* @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));
}
/**
{
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 ?? [])));
+}
<?php
+
namespace Shaarli\Bookmark\Exception;
use Exception;
<?php
-
namespace Shaarli\Bookmark\Exception;
-
-class EmptyDataStoreException extends \Exception {}
+class EmptyDataStoreException extends \Exception
+{
+}
} else {
$created = 'Not a DateTime object';
}
- $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;
+ $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);
}
}
<?php
-
namespace Shaarli\Bookmark\Exception;
-
class NotWritableDataStoreException extends \Exception
{
/**
*/
public function __construct($dataStore)
{
- $this->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.';
}
}
<?php
+
namespace Shaarli\Config;
/**
$data = file_get_contents($filepath);
$data = str_replace(self::getPhpHeaders(), '', $data);
$data = str_replace(self::getPhpSuffix(), '', $data);
- $data = json_decode($data, true);
+ $data = json_decode(trim($data), true);
if ($data === null) {
$errorCode = json_last_error();
$error = sprintf(
*/
public static function getPhpHeaders()
{
- return '<?php /*'. PHP_EOL;
+ return '<?php /*';
}
/**
*/
public static function getPhpSuffix()
{
- return PHP_EOL . '*/ ?>';
+ return '*/ ?>';
}
}
<?php
+
namespace Shaarli\Config;
use Shaarli\Config\Exception\MissingFieldConfigException;
*/
protected static $NOT_FOUND = 'NOT_FOUND';
- public static $DEFAULT_PLUGINS = array('qrcode');
+ public static $DEFAULT_PLUGINS = ['qrcode'];
/**
* @var string Config folder.
public function set($setting, $value, $write = false, $isLoggedIn = false)
{
if (empty($setting) || ! is_string($setting)) {
- throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
+ throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
}
// During the ConfigIO transition, map legacy settings to the new ones.
public function remove($setting, $write = false, $isLoggedIn = false)
{
if (empty($setting) || ! is_string($setting)) {
- throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting));
+ throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
}
// During the ConfigIO transition, map legacy settings to the new ones.
public function write($isLoggedIn)
{
// These fields are required in configuration.
- $mandatoryFields = array(
+ $mandatoryFields = [
'credentials.login',
'credentials.hash',
'credentials.salt',
'general.title',
'general.header_link',
'privacy.default_private_links',
- );
+ ];
// Only logged in user can alter config.
if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
$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);
$this->setEmpty('translation.mode', 'php');
$this->setEmpty('translation.extensions', []);
- $this->setEmpty('plugins', array());
+ $this->setEmpty('plugins', []);
$this->setEmpty('formatter', 'markdown');
}
<?php
+
namespace Shaarli\Config;
/**
/**
* @var array List of config key without group.
*/
- public static $ROOT_KEYS = array(
+ public static $ROOT_KEYS = [
'login',
'hash',
'salt',
'redirector',
'disablesessionprotection',
'privateLinkByDefault',
- );
+ ];
/**
* Map legacy config keys with the new ones.
*
* @var array current key => legacy key.
*/
- public static $LEGACY_KEYS_MAPPING = array(
+ public static $LEGACY_KEYS_MAPPING = [
'credentials.login' => 'login',
'credentials.hash' => 'hash',
'credentials.salt' => 'salt',
'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
'security.open_shaarli' => 'config.OPEN_SHAARLI',
- );
+ ];
/**
* @inheritdoc
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] : '';
}
*/
public function write($filepath, $conf)
{
- $configStr = '<?php '. PHP_EOL;
+ $configStr = '<?php ' . PHP_EOL;
foreach (self::$ROOT_KEYS as $key) {
if (isset($conf[$key])) {
$configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
foreach ($conf['config'] as $key => $value) {
$configStr .= '$GLOBALS[\'config\'][\''
. $key
- .'\'] = '
- .var_export($conf['config'][$key], true).';'
+ . '\'] = '
+ . var_export($conf['config'][$key], true) . ';'
. PHP_EOL;
}
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.')
);
}
throw new PluginConfigOrderException();
}
- $plugins = array();
- $newEnabledPlugins = array();
+ $plugins = [];
+ $newEnabledPlugins = [];
foreach ($formData as $key => $data) {
if (startsWith($key, 'order')) {
continue;
throw new PluginConfigOrderException();
}
- $finalPlugins = array();
+ $finalPlugins = [];
// Make plugins order continuous.
foreach ($plugins as $plugin) {
$finalPlugins[] = $plugin;
*/
function validate_plugin_order($formData)
{
- $orders = array();
+ $orders = [];
foreach ($formData as $key => $value) {
// No duplicate order allowed.
if (in_array($value, $orders, true)) {
<?php
-
namespace Shaarli\Config\Exception;
/**
<?php
-
namespace Shaarli\Config\Exception;
/**
namespace Shaarli\Container;
+use malkusch\lock\mutex\FlockMutex;
+use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
/** @var LoginManager */
protected $login;
+ /** @var LoggerInterface */
+ protected $logger;
+
/** @var string|null */
protected $basePath = null;
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
- LoginManager $login
+ LoginManager $login,
+ LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
+ $this->logger = $logger;
}
public function build(): ShaarliContainer
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
+ $container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;
$container['plugins'] = function (ShaarliContainer $container): PluginManager {
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()
$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()
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;
* @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
<?php
+
namespace Shaarli\Exceptions;
use Exception;
<?php
+
namespace Shaarli\Feed;
use DateTime;
}
// Optionally filter the results:
- $linksToDisplay = $this->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);
}
$data = $this->formatter->format($link);
$data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
if ($this->usePermalinks === true) {
- $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
+ $permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
} else {
- $permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>';
+ $permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
}
$data['description'] .= PHP_EOL . PHP_EOL . '<br>— ' . $permalink;
*/
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());
}
/**
* @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);
}
/**
/**
* @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(), '/'));
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
*/
{
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],
+ ['<span class="search-highlight">', '</span>'],
+ $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;
+ }
}
namespace Shaarli\Formatter;
-use DateTime;
+use DateTimeInterface;
use Shaarli\Bookmark\Bookmark;
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
$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);
$out['updated'] = $this->formatUpdated($bookmark);
$out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
$out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
+
return $out;
}
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
*
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
*
*
* @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
*
*/
protected function formatTagString($bookmark)
{
- return implode(' ', $this->formatTagList($bookmark));
+ return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
}
/**
*
* @return string formatted TagString
*/
- protected function formatUrlEncodedTagString($bookmark)
+ protected function formatTagStringUrlEncoded($bookmark)
{
- return implode(' ', $this->formatUrlEncodedTagList($bookmark));
+ return implode(' ', $this->formatTagListUrlEncoded($bookmark));
}
/**
*
* @param Bookmark $bookmark instance
*
- * @return DateTime instance
+ * @return DateTimeInterface instance
*/
protected function formatCreated(Bookmark $bookmark)
{
*
* @param Bookmark $bookmark instance
*
- * @return DateTime instance
+ * @return DateTimeInterface instance
*/
protected function formatUpdated(Bookmark $bookmark)
{
/**
* Format tag list, e.g. remove private tags if the user is not logged in.
+ * TODO: this method is called multiple time to format tags, the result should be cached.
*
* @param array $tags
*
--- /dev/null
+<?php
+
+namespace Shaarli\Formatter;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkMarkdownExtraFormatter
+ *
+ * Format bookmark description into MarkdownExtra format.
+ *
+ * @see https://michelf.ca/projects/php-markdown/extra/
+ *
+ * @package Shaarli\Formatter
+ */
+class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
+{
+ public function __construct(ConfigManager $conf, bool $isLoggedIn)
+ {
+ parent::__construct($conf, $isLoggedIn);
+
+ $this->parsedown = new \ParsedownExtra();
+ }
+}
/**
* 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;
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);
->setBreaksEnabled(true)
->text($processedDescription);
$processedDescription = $this->sanitizeHtml($processedDescription);
+ $processedDescription = $this->replaceTokens($processedDescription);
if (!empty($processedDescription)) {
- $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
+ $processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
}
return $processedDescription;
function ($match) use ($allowedProtocols, $indexUrl) {
$link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
$link .= whitelist_protocols($match[1], $allowedProtocols);
- return ']('. $link.')';
+ return '](' . $link . ')';
},
$description
);
* \p{Mn} - any non marking space (accents, umlauts, etc)
*/
$regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
- $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
+ $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)';
$descriptionLines = explode(PHP_EOL, $description);
$descriptionOut = '';
*/
protected function sanitizeHtml($description)
{
- $escapeTags = array(
+ $escapeTags = [
'script',
'style',
'link',
'iframe',
'frameset',
'frame',
- );
+ ];
foreach ($escapeTags as $tag) {
$description = preg_replace_callback(
- '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is',
+ '#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
function ($match) {
return escape($match[0]);
},
*
* @package Shaarli\Formatter
*/
-class BookmarkRawFormatter extends BookmarkFormatter {}
+class BookmarkRawFormatter extends BookmarkFormatter
+{
+}
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';
}
$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');
*/
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')
'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')
$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));
}
}
$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.') .
- '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>'
+ '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
+ t('Please synchronize them.') .
+ '</a>'
);
}
$this->container->conf->set('thumbnails.mode', $thumbnailsMode);
*/
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));
}
$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));
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));
}
$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'))
);
+++ /dev/null
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller\Admin;
-
-use Shaarli\Bookmark\Bookmark;
-use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
-use Shaarli\Formatter\BookmarkMarkdownFormatter;
-use Shaarli\Render\TemplatePage;
-use Shaarli\Thumbnailer;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-/**
- * Class PostBookmarkController
- *
- * Slim controller used to handle Shaarli create or edit bookmarks.
- */
-class ManageShaareController extends ShaarliAdminController
-{
- /**
- * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
- */
- public function addShaare(Request $request, Response $response): Response
- {
- $this->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('<script>self.close();</script>');
- }
-
- 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('<script>self.close();</script>');
- }
-
- // 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));
- }
-}
$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));
$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 '<code>' . $character . '</code>';
+ }, $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');
+ }
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Controller used to retrieve/update bookmark's metadata.
+ */
+class MetadataController extends ShaarliAdminController
+{
+ /**
+ * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
+ */
+ public function ajaxRetrieveTitle(Request $request, Response $response): Response
+ {
+ $url = $request->getParam('url');
+
+ // Only try to extract metadata from URL with HTTP(s) scheme
+ if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
+ return $response->withJson($this->container->metadataRetriever->retrieve($url));
+ }
+
+ return $response->withJson([]);
+ }
+}
$this->assignView(
'pagetitle',
- t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+ t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
);
}
// 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(
$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));
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));
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Helper\ApplicationUtils;
+use Shaarli\Helper\FileUtils;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Slim controller used to handle Server administration page, and actions.
+ */
+class ServerController extends ShaarliAdminController
+{
+ /** @var string Cache type - main - by default pagecache/ and tmp/ */
+ protected const CACHE_MAIN = 'main';
+
+ /** @var string Cache type - thumbnails - by default cache/ */
+ protected const CACHE_THUMB = 'thumbnails';
+
+ /**
+ * GET /admin/server - Display page Server administration
+ */
+ public function index(Request $request, Response $response): Response
+ {
+ $releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
+ if ($this->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.') . ' ' .
+ '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
+ t('Please synchronize them.') .
+ '</a>'
+ );
+ } 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');
+ }
+}
return $this->redirectFromReferer($request, $response, ['visibility']);
}
-
-
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Formatter\BookmarkMarkdownFormatter;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ShaareAddController extends ShaarliAdminController
+{
+ /**
+ * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
+ */
+ public function addShaare(Request $request, Response $response): Response
+ {
+ $tags = $this->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));
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class PostBookmarkController
+ *
+ * Slim controller used to handle Shaarli create or edit bookmarks.
+ */
+class ShaareManageController extends ShaarliAdminController
+{
+ /**
+ * 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('<script>self.close();</script>');
+ }
+
+ // 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')
+ );
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkMarkdownFormatter;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ShaarePublishController extends ShaarliAdminController
+{
+ /**
+ * @var BookmarkFormatter[] Statically cached instances of formatters
+ */
+ protected $formatters = [];
+
+ /**
+ * @var array Statically cached bookmark's tags counts
+ */
+ protected $tags;
+
+ /**
+ * 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'));
+ $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('<script>self.close();</script>');
+ } 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;
+ }
+}
$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));
}
try {
- $bookmark = $this->container->bookmarkService->get($id);
+ $bookmark = $this->container->bookmarkService->get((int) $id);
} catch (BookmarkNotFoundException $e) {
return $response->withStatus(404);
}
$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));
}
$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');
$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(),
'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,
]
return '[' . $tag . ']';
};
$data['pagetitle'] .= ! empty($searchTags)
- ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' '
- : '';
+ ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
+ : ''
+ ;
$data['pagetitle'] .= '- ';
}
*/
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());
$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)],
]
);
*/
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;
'page_max' => '',
'search_tags' => '',
'result_count' => '',
+ 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
];
}
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;
*/
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);
$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.
$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));
}
$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;
}
/** @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' => [],
];
// 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);
return $columns;
}
+
+ protected function translateType($type): string
+ {
+ return [
+ t('day') => t('Daily'),
+ t('week') => t('Weekly'),
+ t('month') => t('Monthly'),
+ ][t($type)] ?? t('Daily');
+ }
}
$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',
+ '<a href="https://github.com/shaarli/Shaarli/issues/new">'
+ . t('Please report it on Github.')
+ . '</a>'
);
+ $this->assignView('stacktrace', exception2text($throwable));
} else {
$this->assignView('message', t('An unexpected error occurred.'));
}
$response = $response->withStatus(500);
}
-
return $response->write($this->render('error'));
}
}
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);
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;
// 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);
$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'));
}
// 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(
- '<pre>Sessions do not seem to work correctly on your server.<br>'.
- 'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
- 'and that you have write access to it.<br>'.
- 'It currently points to %s.<br>'.
- 'On some browsers, accessing your server via a hostname like \'localhost\' '.
- 'or any custom hostname without a dot causes cookie storage to fail. '.
+ '<pre>Sessions do not seem to work correctly on your server.<br>' .
+ 'Make sure the variable "session.save_path" is set correctly in your PHP config, ' .
+ 'and that you have write access to it.<br>' .
+ 'It currently points to %s.<br>' .
+ '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.<br>'
);
$msg = sprintf($msg, $this->container->sessionManager->getSavePath());
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'))
) {
$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));
} 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))
);
}
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;
}
$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));
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')
*/
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();
$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:
'target' => $template,
'loggedin' => $this->container->loginManager->isLoggedIn(),
'basePath' => $this->container->basePath,
+ 'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath),
'bookmarkService' => $this->container->bookmarkService
];
}
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);
}
}
- $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);
*/
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);
$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,
$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));
// 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, '/');
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
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) {
$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));
}
/**
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
}
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']);
<?php
-namespace Shaarli;
+
+namespace Shaarli\Helper;
use Exception;
use Shaarli\Config\ConfigManager;
*/
public static $VERSION_FILE = 'shaarli_version.php';
- private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
- private static $GIT_BRANCHES = array('latest', 'stable');
+ public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
+ public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
+ public static $GIT_BRANCHES = ['latest', 'stable'];
private static $VERSION_START_TAG = '<?php /* ';
private static $VERSION_END_TAG = ' */ ?>';
}
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
);
}
// 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) {
/**
* 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');
}
}
}
+ 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;
{
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');
+ }
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Helper;
+
+use Shaarli\Bookmark\Bookmark;
+use Slim\Http\Request;
+
+class DailyPageHelper
+{
+ public const MONTH = 'month';
+ public const WEEK = 'week';
+ public const DAY = 'day';
+
+ /**
+ * Extracts the type of the daily to display from the HTTP request parameters
+ *
+ * @param Request $request HTTP request
+ *
+ * @return string month/week/day
+ */
+ public static function extractRequestedType(Request $request): string
+ {
+ if ($request->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 (<year><month><day>)
+ * - week: 202041 (<year><week number>)
+ * - month: 202010 (<year><month>)
+ *
+ * @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');
+ }
+ }
+}
<?php
-namespace Shaarli;
+namespace Shaarli\Helper;
use Shaarli\Exceptions\IOException;
)
);
}
+
+ /**
+ * Recursively deletes a folder content, and deletes itself optionally.
+ * If an excluded file is found, folders won't be deleted.
+ *
+ * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
+ *
+ * @param string $path
+ * @param bool $selfDelete Delete the provided folder if true, only its content if false.
+ * @param array $exclude
+ */
+ public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
+ {
+ $skipped = false;
+
+ if (!is_dir($path)) {
+ throw new IOException(t('Provided path is not a directory.'));
+ }
+
+ if (!static::isPathInShaarliFolder($path)) {
+ throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
+ }
+
+ foreach (new \DirectoryIterator($path) as $file) {
+ if ($file->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;
+ }
}
*/
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(
&$description,
&$keywords,
$retrieveDescription,
- $curlGetInfo = 'curl_getinfo'
+ $tagsSeparator
) {
return get_curl_download_callback(
$charset,
$description,
$keywords,
$retrieveDescription,
- $curlGetInfo
+ $tagsSeparator
);
}
+
+ public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
+ {
+ return get_curl_header_callback($charset, $curlGetInfo);
+ }
}
* 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
*
* @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 =
$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;
}
* 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
$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;
$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 {
}
}
- return array($headers, $content);
+ return [$headers, $content];
}
/**
$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);
}
if (! $headers) {
- return array($headers, false);
+ return [$headers, false];
}
try {
$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];
}
/**
}
// 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);
}
}
- return array($headers, $url);
+ return [$headers, $url];
}
/**
}
$parts = parse_url($originalUrl);
- $final = $parts['scheme'] .'://'. $parts['host'];
+ $final = $parts['scheme'] . '://' . $parts['host'];
$final .= (!empty($parts['port'])) ? $parts['port'] : '';
$final .= '/';
if ($newUrl[0] != '/') {
$scheme = 'https';
}
- if (($scheme == 'http' && $port != '80')
+ if (
+ ($scheme == 'http' && $port != '80')
|| ($scheme == 'https' && $port != '443')
) {
$port = ':' . $port;
$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;
}
/**
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
*
&$description,
&$keywords,
$retrieveDescription,
- $curlGetInfo = 'curl_getinfo'
+ $tagsSeparator
) {
- $isRedirected = false;
$currentChunk = 0;
$foundChunk = null;
*
* @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);
}
$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;
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);
}
}
// 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))
return false;
}
- return strlen($data);
+ return $chunkLength;
};
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * HTTP Tool used to extract metadata from external URL (title, description, etc.).
+ */
+class MetadataRetriever
+{
+ /** @var ConfigManager */
+ protected $conf;
+
+ /** @var HttpAccess */
+ protected $httpAccess;
+
+ public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
+ {
+ $this->conf = $conf;
+ $this->httpAccess = $httpAccess;
+ }
+
+ /**
+ * Retrieve metadata for given URL.
+ *
+ * @return array [
+ * 'title' => <remote title>,
+ * 'description' => <remote description>,
+ * 'tags' => <remote keywords>,
+ * ]
+ */
+ public function retrieve(string $url): array
+ {
+ $charset = null;
+ $title = null;
+ $description = null;
+ $tags = null;
+
+ // 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,
+ ];
+ }
+}
*/
class Url
{
- private static $annoyingQueryParams = array(
+ private static $annoyingQueryParams = [
// Facebook
'action_object_map=',
'action_ref_map=',
// Other
'campaign_'
- );
+ ];
- private static $annoyingFragments = array(
+ private static $annoyingFragments = [
// ATInternet
'xtor=RSS-',
// Misc.
'tk.rss_all'
- );
+ ];
/*
* URL parts represented as an array
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;
}
}
<?php
+
/**
* Converts an array-represented URL to a string
*
*/
function unparse_url($parsedUrl)
{
- $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'].'://' : '';
+ $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
$host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
- $port = isset($parsedUrl['port']) ? ':'.$parsedUrl['port'] : '';
+ $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '';
$user = isset($parsedUrl['user']) ? $parsedUrl['user'] : '';
- $pass = isset($parsedUrl['pass']) ? ':'.$parsedUrl['pass'] : '';
+ $pass = isset($parsedUrl['pass']) ? ':' . $parsedUrl['pass'] : '';
$pass = ($user || $pass) ? "$pass@" : '';
$path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '';
- $query = isset($parsedUrl['query']) ? '?'.$parsedUrl['query'] : '';
- $fragment = isset($parsedUrl['fragment']) ? '#'.$parsedUrl['fragment'] : '';
+ $query = isset($parsedUrl['query']) ? '?' . $parsedUrl['query'] : '';
+ $fragment = isset($parsedUrl['fragment']) ? '#' . $parsedUrl['fragment'] : '';
return "$scheme$user$pass$host$port$path$query$fragment";
}
if (!$this->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);
use Iterator;
use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Exceptions\IOException;
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
use Shaarli\Render\PageCacheManager;
/**
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"),
}
// 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',
'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=',
'created' => new DateTime('1 minute ago'),
'tags' => 'secretstuff',
'sticky' => false,
- );
+ ];
$link['shorturl'] = link_small_hash($link['created'], $link['id']);
$this->links[0] = $link;
{
// Public bookmarks are hidden and user not logged in => nothing to show
if ($this->hidePublicLinks && !$this->loggedIn) {
- $this->links = array();
+ $this->links = [];
return;
}
$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.
* @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
*/
public function filterSearch(
- $filterRequest = array(),
+ $filterRequest = [],
$casesensitive = false,
$visibility = 'all',
$untaggedonly = false
*/
public function days()
{
- $linkDays = array();
+ $linkDays = [];
foreach ($this->links as $link) {
$linkDays[$link['created']->format('Ymd')] = 0;
}
return $this->links;
}
- $out = array();
+ $out = [];
foreach ($this->links as $key => $value) {
if ($value['private'] && $visibility === 'private') {
$out[$key] = $value;
*/
private function filterSmallHash($smallHash)
{
- $filtered = array();
+ $filtered = [];
foreach ($this->links as $key => $l) {
if ($smallHash == $l['shorturl']) {
// Yes, this is ugly and slow
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.
$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);
}
}
- $keys = array('title', 'description', 'url', 'tags');
+ $keys = ['title', 'description', 'url', 'tags'];
// Iterate over every stored link.
foreach ($this->links as $id => $link) {
}
// create resulting array
- $filtered = array();
+ $filtered = [];
// iterate over each link
foreach ($this->links as $key => $link) {
$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(
'/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
throw new Exception('Invalid date format');
}
- $filtered = array();
+ $filtered = [];
foreach ($this->links as $key => $l) {
if ($l['created']->format('Ymd') == $day) {
$filtered[$key] = $l;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
-use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkArray;
use Shaarli\Bookmark\BookmarkFilter;
use Shaarli\Config\ConfigManager;
use Shaarli\Config\ConfigPhp;
use Shaarli\Exceptions\IOException;
+use Shaarli\Helper\ApplicationUtils;
use Shaarli\Thumbnailer;
use Shaarli\Updater\Exception\UpdaterException;
*/
public function update()
{
- $updatesRan = array();
+ $updatesRan = [];
// If the user isn't logged in, exit without updating.
if ($this->isLoggedIn !== true) {
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;
}
// 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])) {
$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]);
*/
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;
$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);
$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());
// 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', ' ')
);
}
$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++;
<?php
+
namespace Shaarli\Plugin;
use Shaarli\Config\ConfigManager;
*
* @var array $loadedPlugins
*/
- private $loadedPlugins = array();
+ private $loadedPlugins = [];
/**
* @var ConfigManager Configuration Manager instance.
public function __construct(&$conf)
{
$this->conf = $conf;
- $this->errors = array();
+ $this->errors = [];
}
/**
*
* @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_',
];
*/
public function getPluginsMeta()
{
- $metaData = array();
+ $metaData = [];
$dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
// Browse all plugin directories.
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;
<?php
+
namespace Shaarli\Plugin\Exception;
use Exception;
namespace Shaarli\Render;
use Exception;
-use exceptions\MissingBasePathException;
+use Psr\Log\LoggerInterface;
use RainTPL;
-use Shaarli\ApplicationUtils;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
+use Shaarli\Helper\ApplicationUtils;
use Shaarli\Security\SessionManager;
use Shaarli\Thumbnailer;
*/
protected $session;
+ /** @var LoggerInterface */
+ protected $logger;
+
/**
* @var BookmarkServiceInterface $bookmarkService instance.
*/
* PageBuilder constructor.
* $tpl is initialized at false for lazy loading.
*
- * @param ConfigManager $conf Configuration Manager instance (reference).
- * @param array $session $_SESSION array
- * @param BookmarkServiceInterface $linkDB instance.
- * @param string $token Session token
- * @param bool $isLoggedIn
+ * @param ConfigManager $conf Configuration Manager instance (reference).
+ * @param array $session $_SESSION array
+ * @param LoggerInterface $logger
+ * @param null $linkDB instance.
+ * @param null $token Session token
+ * @param bool $isLoggedIn
*/
- public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
- {
+ public function __construct(
+ ConfigManager &$conf,
+ array $session,
+ LoggerInterface $logger,
+ $linkDB = null,
+ $token = null,
+ $isLoggedIn = false
+ ) {
$this->tpl = false;
$this->conf = $conf;
$this->session = $session;
+ $this->logger = $logger;
$this->bookmarkService = $linkDB;
$this->token = $token;
$this->isLoggedIn = $isLoggedIn;
$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()));
}
$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);
}
}
+ $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')
);
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';
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;
<?php
-
namespace Shaarli\Security;
-use Shaarli\FileUtils;
+use Psr\Log\LoggerInterface;
+use Shaarli\Helper\FileUtils;
/**
* Class BanManager
/** @var string Path to the file containing IP bans and failures */
protected $banFile;
- /** @var string Path to the log file, used to log bans */
- protected $logFile;
+ /** @var LoggerInterface Path to the log file, used to log bans */
+ protected $logger;
/** @var array List of IP with their associated number of failed attempts */
protected $failures = [];
/**
* BanManager constructor.
*
- * @param array $trustedProxies List of allowed proxies IP
- * @param int $nbAttempts Number of allowed failed attempt before the ban
- * @param int $banDuration Ban duration in seconds
- * @param string $banFile Path to the file containing IP bans and failures
- * @param string $logFile Path to the log file, used to log bans
+ * @param array $trustedProxies List of allowed proxies IP
+ * @param int $nbAttempts Number of allowed failed attempt before the ban
+ * @param int $banDuration Ban duration in seconds
+ * @param string $banFile Path to the file containing IP bans and failures
+ * @param LoggerInterface $logger PSR-3 logger to save login attempts in log directory
*/
- public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) {
+ public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger)
+ {
$this->trustedProxies = $trustedProxies;
$this->nbAttempts = $nbAttempts;
$this->banDuration = $banDuration;
$this->banFile = $banFile;
- $this->logFile = $logFile;
+ $this->logger = $logger;
+
$this->readBanFile();
}
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();
}
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;
<?php
+
namespace Shaarli\Security;
use Exception;
+use Psr\Log\LoggerInterface;
use Shaarli\Config\ConfigManager;
/**
protected $staySignedInToken = '';
/** @var CookieManager */
protected $cookieManager;
+ /** @var LoggerInterface */
+ protected $logger;
/**
* Constructor
*
- * @param ConfigManager $configManager Configuration Manager instance
+ * @param ConfigManager $configManager Configuration Manager instance
* @param SessionManager $sessionManager SessionManager instance
- * @param CookieManager $cookieManager CookieManager instance
+ * @param CookieManager $cookieManager CookieManager instance
+ * @param BanManager $banManager
+ * @param LoggerInterface $logger Used to log login attempts
*/
- public function __construct($configManager, $sessionManager, $cookieManager)
- {
+ public function __construct(
+ ConfigManager $configManager,
+ SessionManager $sessionManager,
+ CookieManager $cookieManager,
+ BanManager $banManager,
+ LoggerInterface $logger
+ ) {
$this->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;
// 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();
*
* @return true when the user is logged in, false otherwise
*/
- public function isLoggedIn()
+ public function isLoggedIn(): bool
{
if ($this->openShaarli) {
return true;
/**
* 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;
}
*
* @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')
*/
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);
};
<?php
+
namespace Shaarli\Security;
use Shaarli\Config\ConfigManager;
*/
public function generateToken()
{
- $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt'));
+ $token = sha1(uniqid('', true) . '_' . mt_rand() . $this->conf->get('credentials.salt'));
$this->session['tokens'][$token] = 1;
return $token;
}
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
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;
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);
}
/**
$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)
) {
*
* @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);
return explode(';', $content);
}
}
- return array();
+ return [];
}
/**
*
* @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.');
$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 . '.');
}
}
}
--- /dev/null
+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 <div> 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);
+ });
+ }
+})();
--- /dev/null
+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(), '/'));
+ });
+ });
+ }
+})();
import Awesomplete from 'awesomplete';
+import he from 'he';
/**
* Find a parent element according to its tag and its attributes
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))) {
* @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
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.
*
(() => {
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.
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();
}
});
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)}`);
// 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}`);
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');
});
});
}
+
+ 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;
+ }
})();
}
}
+.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 {
content: '';
}
}
+
+ .search-highlight {
+ background-color: yellow;
+ }
}
.linklist-item-buttons {
&.button-red {
background: $red;
}
+
+ &.button-grey {
+ background: $light-grey;
+ }
}
.submit-buttons {
}
table {
- margin: auto;
+ margin: 10px auto 25px auto;
width: 90%;
.order {
position: absolute;
right: 5%;
}
+
+ &.button-grey {
+ position: absolute;
+ left: 5%;
+ }
}
}
}
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;
}
}
}
}
+.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 {
}
}
+// 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 {
float: left;
}
+ul.warnings {
+ color: orange;
+ float: left;
+}
+
+ul.successes {
+ color: green;
+ float: left;
+}
+
#pluginsadmin {
width: 80%;
padding: 20px 0 0 20px;
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;
+}
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;
});
})();
},
"keywords": ["bookmark", "link", "share", "web"],
"config": {
+ "sort-packages": true,
"platform": {
"php": "7.1.29"
}
"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",
"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",
"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",
},
"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",
},
"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",
},
{
"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": {
],
"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",
"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": {
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"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",
"type": "tidelift"
}
],
- "time": "2020-09-24T17:02:11+00:00"
+ "time": "2020-11-01T20:01:47+00:00"
},
{
"name": "sebastian/code-unit-reverse-lookup",
},
{
"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": {
"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"
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.18-dev"
+ "dev-main": "1.20-dev"
},
"thanks": {
"name": "symfony/polyfill",
"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": [
{
"type": "tidelift"
}
],
- "time": "2020-07-14T12:35:20+00:00"
+ "time": "2020-10-23T14:02:19+00:00"
},
{
"name": "theseer/tokenizer",
+
# Docker
[Docker](https://docs.docker.com/get-started/overview/) is an open platform for developing, shipping, and running applications
# 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 <MY_SHAARLI_DOMAIN> and <MY_CONTACT_EMAIL> with your actual information)
+# (replace <shaarli.mydomain.org>, <admin@mydomain.org> and <latest> 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!
- [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/)
Version | Status | Shaarli compatibility
:---:|:---:|:---:
+8.0 | Supported | Yes
+7.4 | Supported | Yes
7.3 | Supported | Yes
7.2 | Supported | Yes
7.1 | Supported | Yes
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
Require all granted
</Directory>
- <LocationMatch "/\.">
- # Prevent accessing dotfiles
- RedirectMatch 404 ".*"
- </LocationMatch>
+ # BE CAREFUL: directives order matter!
- <LocationMatch "\.(?:ico|css|js|gif|jpe?g|png)$">
+ <FilesMatch ".*\.(?!(ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$)[^\.]*$">
+ Require all denied
+ </FilesMatch>
+
+ <Files "index.php">
+ Require all granted
+ </Files>
+
+ <FilesMatch "\.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2)$">
# allow client-side caching of static files
Header set Cache-Control "max-age=2628000, public, must-revalidate, proxy-revalidate"
- </LocationMatch>
+ </FilesMatch>
+
# serve the Shaarli favicon from its custom location
Alias favicon.ico /var/www/shaarli.mydomain.org/images/favicon.ico
-
</VirtualHost>
```
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$ {
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 {
}
# 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;
}
-
}
```
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
before = common.conf
[Definition]
failregex = \s-\s<HOST>\s-\sLogin failed for user.*$
-ignoreregex =
+ignoreregex =
```
```ini
"timezone": "Europe\/Paris",
"title": "My Shaarli",
"header_link": "?"
+ "tags_separator": " "
},
"dev": {
"debug": false,
- **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
- **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.
- [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
## 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.
* `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 `@`.
## 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)
`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!
# 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:
services:
shaarli:
- image: shaarli/shaarli:master
+ image: shaarli/shaarli:${SHAARLI_DOCKER_TAG}
build: ./
networks:
- http-proxy
- "--entrypoints=Name:https Address::443 TLS"
- "--retry"
- "--docker"
- - "--docker.domain=docker.localhost"
+ - "--docker.domain=${SHAARLI_VIRTUAL_HOST}"
- "--docker.exposedbydefault=true"
- "--docker.watch=true"
- "--acme"
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"
"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"
"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"
"\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"
"| 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"
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"
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."
"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"
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"
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 ""
"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
"des cookies. Nous vous recommandons d'accéder à votre serveur depuis son "
"adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>"
-#: 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 :"
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)."
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."
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."
"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] : "
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 "
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."
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."
"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)"
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 <em>general > "
+"enable_async_metadata</em> in your configuration file to use bulk link "
+"creation."
+msgstr ""
+"Nous recommandons d'activer le paramètre <em>general > "
+"enable_async_metadata</em> 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"
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"
"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
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"
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 ""
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"
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"
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"
#: 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
#: 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"
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"
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"
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"
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:"
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...)"
"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...)"
"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\""
"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"
"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"
"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"
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 <yudesleepy@gmail.com>\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"
"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 "
"が必要です。 現在使用している 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."
"ていて、インストールされているディレクトリに書き込みできることを確認してくだ"
"さい。"
-#: application/config/ConfigManager.php:135
+#: application/config/ConfigManager.php:136
+#: application/config/ConfigManager.php:163
msgid "Invalid setting key parameter. String expected, got: "
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 "ã\83\87ã\82¤ã\83ªã\83¼"
+#: application/front/controller/admin/ConfigureController.php:103
+#: application/legacy/LegacyUpdater.php:538
+msgid "Please synchronize them."
+msgstr "ã\81\9dã\82\8cã\82\89ã\82\92å\90\8cæ\9c\9fã\81\97ã\81¦ã\81\8fã\81 ã\81\95ã\81\84ã\80\82"
-#: 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 "ã\82¿ã\82°ä¸\80覧"
+#: application/front/controller/admin/ExportController.php:42
+msgid "Please select an export mode."
+msgstr "ã\82¨ã\82¯ã\82¹ã\83\9dã\83¼ã\83\88 ã\83¢ã\83¼ã\83\89ã\82\92æ\8c\87å®\9aã\81\97ã\81¦ã\81\8fã\81 ã\81\95ã\81\84ã\80\82"
-#: 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] "ã\82¿ã\82°ã\81\8c %d 件のリンクにおいて、名前が変更されました。"
-msgstr[1] "ã\82¿ã\82°ã\81\8c %d 件のリンクにおいて、名前が変更されました。"
+msgid "The tag was renamed in %d bookmark."
+msgid_plural "The tag was renamed in %d bookmarks."
+msgstr[0] "ã\81\93ã\81®ã\82¿ã\82°ã\82\92æ\8c\81ã\81¤ %d 件のリンクにおいて、名前が変更されました。"
+msgstr[1] "ã\81\93ã\81®ã\82¿ã\82°ã\82\92æ\8c\81ã\81¤ %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 "ã\82¨ã\82¯ã\82¹ã\83\9dã\83¼ã\83\88"
+#: application/front/controller/admin/PluginsController.php:45
+msgid "Plugin Administration"
+msgstr "ã\83\97ã\83©ã\82°ã\82¤ã\83³ç®¡ç\90\86"
-#: 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 ""
"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
"ります。IP アドレスや完全なドメイン名でサーバーにアクセスすることをおすすめし"
"ます。<br>"
-#: 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 "<a href=\"./admin/thumbnails\">"
+msgstr "<a href=\"./admin/thumbnails\">"
+
+#: 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."
"テンプレートのデザイナーや、プラグインの開発者のためのすべての状況に対応でき"
"るデモプラグインです。"
-#: 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."
"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.<br><strong>Warning</"
-"strong>:\n"
-"If your shaared descriptions contained HTML tags before enabling the "
-"markdown plugin,\n"
-"enabling it might break your page.\n"
-"See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a>."
-msgstr ""
-"リンクの説明をマークダウン形式で表示します。<br><strong>警告</strong>:\n"
-"リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n"
-"正常にページを表示できなくなるかもしれません。\n"
-"詳しくは <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a> をご覧ください。"
-
-#: 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."
"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 "すべての動画を閲覧するボタンをツールバーに追加します。"
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コードのアイコンを追加する。"
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.<br><strong>Warning</"
+#~ "strong>:\n"
+#~ "If your shaared descriptions contained HTML tags before enabling the "
+#~ "markdown plugin,\n"
+#~ "enabling it might break your page.\n"
+#~ "See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a>."
+#~ msgstr ""
+#~ "リンクの説明をマークダウン形式で表示します。<br><strong>警告</strong>:\n"
+#~ "リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n"
+#~ "正常にページを表示できなくなるかもしれません。\n"
+#~ "詳しくは <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a> をご覧ください。"
-#: 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 <strong>HTTPS</strong> to use this "
-"functionality."
-msgstr ""
-"この機能を使用するには、<strong>HTTPS</strong> 経由で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 <strong>HTTPS</strong> to use this "
+#~ "functionality."
+#~ msgstr ""
+#~ "この機能を使用するには、<strong>HTTPS</strong> 経由で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 ""
+#~ "このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
+#~ "ブックマークに追加」してください"
<?php
+
/**
* Shaarli - The personal, minimalist, super-fast, database free, bookmarking service.
*
require_once __DIR__ . '/init.php';
+use Katzgrau\KLogger\Logger;
+use Psr\Log\LogLevel;
use Shaarli\Config\ConfigManager;
use Shaarli\Container\ContainerBuilder;
use Shaarli\Languages;
+use Shaarli\Security\BanManager;
use Shaarli\Security\CookieManager;
use Shaarli\Security\LoginManager;
use Shaarli\Security\SessionManager;
});
}
+$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.
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);
$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'
$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');
$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)
+ ));
+}
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
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).
"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"
},
<file>index.php</file>
<file>application</file>
<file>plugins</file>
- <file>tests</file>
+<!-- <file>tests</file>-->
<exclude-pattern>*/*.css</exclude-pattern>
<exclude-pattern>*/*.js</exclude-pattern>
<arg name="colors"/>
- <rule ref="PSR1"/>
- <rule ref="PSR2"/>
+ <rule ref="PSR12"/>
+ <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
+
+ <rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
+ <!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
+ <exclude-pattern>index.php</exclude-pattern>
+ </rule>
</ruleset>
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;
}
<?php
+
/**
* Plugin Archive.org.
*
function hook_archiveorg_render_linklist($data)
{
$archive_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/archiveorg/archiveorg.html');
- $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
+ $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
foreach ($data['links'] as &$value) {
$isNote = startsWith($value['real_url'], '/shaare/');
{
$params = [];
foreach (DEFAULT_COLORS_PLACEHOLDERS as $placeholder) {
- $value = trim($conf->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];
}
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 ;
}
$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
: '';
}
}
$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];
}
<?php
+
/**
* Demo Plugin.
*
* A link is an array of its attributes (key="value"),
* and a mandatory `html` key, which contains its value.
*/
- $button = array(
- 'attr' => array (
+ $button = [
+ 'attr' => [
'href' => '#',
'class' => 'mybutton',
'title' => 'hover me',
- ),
+ ],
'html' => 'DEMO buttons toolbar',
- );
+ ];
$data['buttons_toolbar'][] = $button;
}
* <input input-2-attribute-1="input 2 attribute 1 value">
* </form>
*/
- $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;
function hook_demo_plugin_render_footer($data)
{
// Footer text
- $data['text'][] = '<br>'. demo_plugin_t('Shaarli is now enhanced by the awesome demo_plugin.');
+ $data['text'][] = '<br>' . demo_plugin_t('Shaarli is now enhanced by the awesome demo_plugin.');
// Free elements at the end of the page.
$data['endofpage'][] = '<marquee id="demo_marquee">' .
* 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
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'])) {
{
$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];
}
}
$isso = sprintf($issoHtml, $issoUrl, $issoUrl, $link['id'], $link['id']);
$data['plugin_end_zone'][] = $isso;
} else {
- $button = '<span><a href="'. ($data['_BASE_PATH_'] ?? '') . '/shaare/%s#isso-thread">';
+ $button = '<span><a href="' . ($data['_BASE_PATH_'] ?? '') . '/shaare/%s#isso-thread">';
// For the default theme we use a FontAwesome icon which is better than an image
if ($conf->get('resource.theme') === 'default') {
$button .= '<i class="linklist-plugin-icon fa fa-comment"></i>';
} else {
- $button .= '<img class="linklist-plugin-icon" src="plugins/isso/comment.png" ';
+ $button .= '<img class="linklist-plugin-icon" src="' . $data['_ROOT_PATH_'] . '/plugins/isso/comment.png" ';
$button .= 'title="Comment on this shaare" alt="Comments" />';
}
$button .= '</a></span>';
<?php
+
/**
* Piwik plugin.
* Adds tracking code on each page.
if (empty($piwikUrl) || empty($piwikSiteid)) {
$error = t('Piwik plugin error: ' .
'Please define PIWIK_URL and PIWIK_SITEID in the plugin administration page.');
- return array($error);
+ return [$error];
}
}
<?php
+
/**
* Plugin PlayVideos
*
function hook_playvideos_render_header($data)
{
if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
- $playvideo = array(
- 'attr' => array(
+ $playvideo = [
+ 'attr' => [
'href' => '#',
'title' => t('Video player'),
'id' => 'playvideos',
- ),
- 'html' => '► '. t('Play Videos')
- );
+ ],
+ 'html' => '► ' . t('Play Videos')
+ ];
$data['buttons_toolbar'][] = $playvideo;
}
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;
*/
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 {
*/
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);
<?php
+
/**
* Plugin qrcode
* Add QRCode containing URL for each links.
{
$qrcode_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.html');
- $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
+ $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
foreach ($data['links'] as &$value) {
$qrcode = sprintf(
$qrcode_html,
<?php
+
namespace Shaarli\Plugin\Wallabag;
/**
* - key: version ID, must match plugin settings.
* - value: version name.
*/
- private static $wallabagVersions = array(
+ private static $wallabagVersions = [
1 => '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.
<?php
+
/**
* Wallabag plugin
*/
{
$wallabagUrl = $conf->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');
}
/**
function hook_wallabag_render_linklist($data, $conf)
{
$wallabagUrl = $conf->get('plugins.WALLABAG_URL');
- if (empty($wallabagUrl)) {
+ if (empty($wallabagUrl) || !$data['_LOGGEDIN_']) {
return $data;
}
$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
);
$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']);
}
// /**
}
/**
- * 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);
}
/**
<?php
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager;
use Shaarli\History;
*/
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();
$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);
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager;
use Shaarli\History;
*/
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();
$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;
$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];
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager;
*/
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();
$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);
<?php
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\LinkDB;
*/
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();
$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);
$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
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager;
*/
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();
$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;
$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;
$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']);
'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',
$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']);
$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']);
}
/**
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager;
*/
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();
$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;
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\LinkDB;
use Shaarli\Config\ConfigManager;
*/
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();
$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;
$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->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);
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\LinkDB;
use Shaarli\Config\ConfigManager;
*/
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();
$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);
<?php
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\LinkDB;
use Shaarli\Config\ConfigManager;
*/
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();
$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;
namespace Shaarli\Api\Controllers;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Api\Exceptions\ApiBadParametersException;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\LinkDB;
*/
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();
$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;
$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
*/
namespace Shaarli\Bookmark;
use DateTime;
+use malkusch\lock\mutex\NoMutex;
use ReferenceLinkDB;
use ReflectionClass;
use Shaarli;
*/
protected $privateLinkDB = null;
+ /** @var NoMutex */
+ protected $mutex;
+
/**
* Instantiates public and private LinkDBs with test data
*
*/
protected function setUp(): void
{
+ $this->mutex = new NoMutex();
+
if (file_exists(self::$testDatastore)) {
unlink(self::$testDatastore);
}
$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);
}
/**
$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());
}
$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());
$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());
$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);
}
$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
*/
$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());
$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());
$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());
$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.
*/
$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());
$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());
$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
*/
$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());
$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);
}
$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
*/
$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);
}
/**
{
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
{
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());
*/
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());
}
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
*/
// 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()
);
'tag3' => 1,
'tag4' => 1,
'ut' => 1,
+ 'assurance' => 1,
+ 'coding-style' => 1,
+ 'quality' => 1,
+ 'standards' => 1,
],
$this->privateLinkDB->bookmarksCountPerTag()
);
$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.
'tag4' => 1,
'ut' => 1,
'w3c' => 1,
+ 'assurance' => 1,
+ 'coding-style' => 1,
+ 'quality' => 1,
+ 'standards' => 1,
];
$tags = $this->privateLinkDB->bookmarksCountPerTag();
'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]);
}
/**
- * 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);
}
/**
namespace Shaarli\Bookmark;
-use Exception;
+use malkusch\lock\mutex\NoMutex;
use ReferenceLinkDB;
use Shaarli\Config\ConfigManager;
use Shaarli\History;
*/
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);
}
/**
))
);
}
+
+ /**
+ * 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'));
+ }
}
namespace Shaarli\Bookmark;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Config\ConfigManager;
use Shaarli\History;
use Shaarli\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);
}
$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);
}
{
$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();
$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);
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();
$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
*/
$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.
*/
$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.
*/
{
$bookmark = new Bookmark();
- $str = 'tag1 tag2 tag3.tag3-2, tag4 , -tag5 ';
+ $str = 'tag1 tag2 tag3.tag3-2 tag4 -tag5 ';
$bookmark->setTagsString($str);
$this->assertEquals(
[
$array = [
'tag1 ',
' tag2',
- 'tag3.tag3-2,',
- ', tag4',
- ', ',
+ 'tag3.tag3-2',
+ ' tag4',
+ ' ',
'-tag5 ',
];
$bookmark->setTags($array);
$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());
+ }
}
public function testHtmlExtractExistentNameTag()
{
$description = 'Bob and Alice share cookies.';
+
+ // Simple one line
$html = '<html><meta>stuff2</meta><meta name="description" content="' . $description . '"/></html>';
$this->assertEquals($description, html_extract_tag('description', $html));
+
+ // Simple OpenGraph
+ $html = '<meta property="og:description" content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // Simple reversed OpenGraph
+ $html = '<meta content="' . $description . '" property="og:description">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // ItemProp OpenGraph
+ $html = '<meta itemprop="og:description" content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph without quotes
+ $html = '<meta property=og:description content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph reversed without quotes
+ $html = '<meta content="' . $description . '" property=og:description>';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph with noise
+ $html = '<meta tag1="content1" property="og:description" tag2="content2" content="' .
+ $description . '" tag3="content3">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph reversed with noise
+ $html = '<meta tag1="content1" content="' . $description . '" ' .
+ 'tag3="content3" tag2="content2" property="og:description">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph multiple properties start
+ $html = '<meta property="unrelated og:description" content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph multiple properties end
+ $html = '<meta property="og:description unrelated" content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph multiple properties both end
+ $html = '<meta property="og:unrelated1 og:description og:unrelated2" content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph multiple properties both end with noise
+ $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
+ 'tag2="content2" content="' . $description . '" tag3="content3">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph reversed multiple properties start
+ $html = '<meta content="' . $description . '" property="unrelated og:description">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph reversed multiple properties end
+ $html = '<meta content="' . $description . '" property="og:description unrelated">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph reversed multiple properties both end
+ $html = '<meta content="' . $description . '" property="og:unrelated1 og:description og:unrelated2">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // OpenGraph reversed multiple properties both end with noise
+ $html = '<meta tag1="content1" content="' . $description . '" tag2="content2" '.
+ 'property="og:unrelated1 og:description og:unrelated2" tag3="content3">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ // Suggestion from #1375
+ $html = '<meta property="og:description" name="description" content="' . $description . '">';
+ $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 = '<meta property="og:description" content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
+ 'tag2="content2" content="' . $description . '" tag3="content3">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ $html = '<meta property="og:description" name="description" content="' . $description . '">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ $description = 'Bob and Alice share "cookies".';
+
+ $html = '<meta property="og:description" content=\'' . $description . '\'>';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
+ 'tag2="content2" content=\'' . $description . '\' tag3="content3">';
+ $this->assertEquals($description, html_extract_tag('description', $html));
+
+ $html = '<meta property="og:description" name="description" content=\'' . $description . '\'>';
+ $this->assertEquals($description, html_extract_tag('description', $html));
}
/**
{
$html = '<html><meta>stuff2</meta><meta name="image" content="img"/></html>';
$this->assertFalse(html_extract_tag('description', $html));
+
+ // Partial meta tag
+ $html = '<meta content="Brief description">';
+ $this->assertFalse(html_extract_tag('description', $html));
+
+ $html = '<meta property="og:description">';
+ $this->assertFalse(html_extract_tag('description', $html));
+
+ $html = '<meta tag1="content1" property="og:description">';
+ $this->assertFalse(html_extract_tag('description', $html));
+
+ $html = '<meta property="og:description" tag1="content1">';
+ $this->assertFalse(html_extract_tag('description', $html));
+
+ $html = '<meta tag1="content1" content="Brief description">';
+ $this->assertFalse(html_extract_tag('description', $html));
+
+ $html = '<meta content="Brief description" tag1="content1">';
+ $this->assertFalse(html_extract_tag('description', $html));
}
/**
$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">'
. '<title>Refactoring · GitHub</title>'
. '<link rel="search" type="application/opensea',
'<title>ignored</title>'
. '<meta name="description" content="desc" />'
. '<meta name="keywords" content="key1,key2" />',
];
- 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">'
. '<title>Refactoring · GitHub</title>'
. '<link rel="search" type="application/opensea',
. '<meta name="description" content="desc" />'
. '<meta name="keywords" content="key1,key2" />',
];
- 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);
/**
* 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',
'<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />',
'end' => 'th=device-width">'
. '<title>Refactoring · GitHub</title>'
. '<meta name="description" content="desc" />'
. '<meta name="keywords" content="key1,key2" />',
];
- 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);
/**
* 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<link rel="search" type="application/opensea',
'ignored',
];
- 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->assertEquals('utf-8', $charset);
$this->assertEmpty($title);
$this->assertEmpty($desc);
}
/**
- * Test the download callback with an invalid content type.
+ * Test the header callback with an invalid content type.
*/
- public function testCurlDownloadCallbackInvalidContentType()
+ public function testCurlHeaderCallbackInvalidContentType(): void
{
- $callback = get_curl_download_callback(
- $charset,
- $title,
- $desc,
- $keywords,
- false,
- 'ut_curl_getinfo_ct_ko'
- );
- $ignore = null;
- $this->assertFalse($callback($ignore, ''));
- $this->assertEmpty($charset);
- $this->assertEmpty($title);
+ $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_ct_ko');
+ $data = [
+ 'HTTP/1.1 200 OK',
+ ];
+
+ static::assertFalse($callback(null, $data[0]));
+ static::assertNull($charset);
}
/**
- * Test the download callback with an invalid response code.
+ * Test the header callback with an invalid response code.
*/
- public function testCurlDownloadCallbackInvalidResponseCode()
+ public function testCurlHeaderCallbackInvalidResponseCode(): void
{
- $callback = $callback = get_curl_download_callback(
- $charset,
- $title,
- $desc,
- $keywords,
- false,
- 'ut_curl_getinfo_rc_ko'
- );
- $ignore = null;
- $this->assertFalse($callback($ignore, ''));
- $this->assertEmpty($charset);
- $this->assertEmpty($title);
+ $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_rc_ko');
+
+ static::assertFalse($callback(null, ''));
+ static::assertNull($charset);
}
/**
- * Test the download callback with an invalid content type and response code.
+ * Test the header callback with an invalid content type and response code.
*/
- public function testCurlDownloadCallbackInvalidContentTypeAndResponseCode()
+ public function testCurlHeaderCallbackInvalidContentTypeAndResponseCode(): void
{
- $callback = $callback = get_curl_download_callback(
- $charset,
- $title,
- $desc,
- $keywords,
- false,
- 'ut_curl_getinfo_rs_ct_ko'
- );
- $ignore = null;
- $this->assertFalse($callback($ignore, ''));
- $this->assertEmpty($charset);
- $this->assertEmpty($title);
+ $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_rs_ct_ko');
+
+ static::assertFalse($callback(null, ''));
+ static::assertNull($charset);
}
/**
* Test the download callback with valid value, and retrieve_description option enabled.
*/
- public function testCurlDownloadCallbackOkWithDesc()
+ public function testCurlDownloadCallbackOkWithDesc(): void
{
+ $charset = 'utf-8';
$callback = get_curl_download_callback(
$charset,
$title,
$desc,
$keywords,
true,
- '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">'
. '<title>Refactoring · GitHub</title>'
. '<link rel="search" type="application/opensea',
. '<meta name="description" content="link desc" />'
. '<meta name="keywords" content="key1,key2" />',
];
- 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);
* 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,
'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">'
. '<title>Refactoring · GitHub</title>'
. '<link rel="search" type="application/opensea',
'end' => '<title>ignored</title>',
];
- 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->assertFalse(is_note('https://github.com/shaarli/Shaarli/?hi'));
}
+ /**
+ * Test tags_str2array with whitespace separator.
+ */
+ public function testTagsStr2ArrayWithSpaceSeparator(): void
+ {
+ $separator = ' ';
+
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1 tag2 tag3', $separator));
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1 tag2 tag3', $separator));
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array(' tag1 tag2 tag3 ', $separator));
+ static::assertSame(['tag1@', 'tag2,', '.tag3'], tags_str2array(' tag1@ tag2, .tag3 ', $separator));
+ static::assertSame([], tags_str2array('', $separator));
+ static::assertSame([], tags_str2array(' ', $separator));
+ static::assertSame([], tags_str2array(null, $separator));
+ }
+
+ /**
+ * Test tags_str2array with @ separator.
+ */
+ public function testTagsStr2ArrayWithCharSeparator(): void
+ {
+ $separator = '@';
+
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@tag2@tag3', $separator));
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@@@@tag2@@@@tag3', $separator));
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('@@@tag1@@@tag2@@@@tag3@@', $separator));
+ static::assertSame(
+ ['tag1#', 'tag2, and other', '.tag3'],
+ tags_str2array('@@@ tag1# @@@ tag2, and other @@@@.tag3@@', $separator)
+ );
+ static::assertSame([], tags_str2array('', $separator));
+ static::assertSame([], tags_str2array(' ', $separator));
+ static::assertSame([], tags_str2array(null, $separator));
+ }
+
+ /**
+ * Test tags_array2str with ' ' separator.
+ */
+ public function testTagsArray2StrWithSpaceSeparator(): void
+ {
+ $separator = ' ';
+
+ static::assertSame('tag1 tag2 tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
+ static::assertSame('tag1, tag2@ tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
+ static::assertSame('tag1 tag2 tag3', tags_array2str([' tag1 ', 'tag2', 'tag3 '], $separator));
+ static::assertSame('tag1 tag2 tag3', tags_array2str([' tag1 ', ' ', 'tag2', ' ', 'tag3 '], $separator));
+ static::assertSame('tag1', tags_array2str([' tag1 '], $separator));
+ static::assertSame('', tags_array2str([' '], $separator));
+ static::assertSame('', tags_array2str([], $separator));
+ static::assertSame('', tags_array2str(null, $separator));
+ }
+
+ /**
+ * Test tags_array2str with @ separator.
+ */
+ public function testTagsArray2StrWithCharSeparator(): void
+ {
+ $separator = '@';
+
+ static::assertSame('tag1@tag2@tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
+ static::assertSame('tag1,@tag2@tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
+ static::assertSame(
+ 'tag1@tag2, and other@tag3',
+ tags_array2str(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
+ );
+ static::assertSame('tag1@tag2@tag3', tags_array2str(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
+ static::assertSame('tag1', tags_array2str(['@@@@tag1@@@@'], $separator));
+ static::assertSame('', tags_array2str(['@@@'], $separator));
+ static::assertSame('', tags_array2str([], $separator));
+ static::assertSame('', tags_array2str(null, $separator));
+ }
+
+ /**
+ * Test tags_array2str with @ separator.
+ */
+ public function testTagsFilterWithSpaceSeparator(): void
+ {
+ $separator = ' ';
+
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
+ static::assertSame(['tag1,', 'tag2@', 'tag3'], tags_filter(['tag1,', 'tag2@', 'tag3'], $separator));
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter([' tag1 ', 'tag2', 'tag3 '], $separator));
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter([' tag1 ', ' ', 'tag2', ' ', 'tag3 '], $separator));
+ static::assertSame(['tag1'], tags_filter([' tag1 '], $separator));
+ static::assertSame([], tags_filter([' '], $separator));
+ static::assertSame([], tags_filter([], $separator));
+ static::assertSame([], tags_filter(null, $separator));
+ }
+
+ /**
+ * Test tags_array2str with @ separator.
+ */
+ public function testTagsArrayFilterWithSpaceSeparator(): void
+ {
+ $separator = '@';
+
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
+ static::assertSame(['tag1,', 'tag2#', 'tag3'], tags_filter(['tag1,', 'tag2#', 'tag3'], $separator));
+ static::assertSame(
+ ['tag1', 'tag2, and other', 'tag3'],
+ tags_filter(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
+ );
+ static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
+ static::assertSame(['tag1'], tags_filter(['@@@@tag1@@@@'], $separator));
+ static::assertSame([], tags_filter(['@@@'], $separator));
+ static::assertSame([], tags_filter([], $separator));
+ static::assertSame([], tags_filter(null, $separator));
+ }
+
/**
* Util function to build an hashtag link.
*
require_once 'tests/utils/ReferenceSessionIdHashes.php';
\ReferenceSessionIdHashes::genAllHashes();
+
+if (!defined('SHAARLI_MUTEX_FILE')) {
+ define('SHAARLI_MUTEX_FILE', __FILE__);
+}
namespace Shaarli\Container;
+use Psr\Log\LoggerInterface;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
use Shaarli\Feed\FeedBuilder;
use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
use Shaarli\History;
use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
use Shaarli\Netscape\NetscapeBookmarkUtils;
use Shaarli\Plugin\PluginManager;
use Shaarli\Render\PageBuilder;
$this->conf,
$this->sessionManager,
$this->cookieManager,
- $this->loginManager
+ $this->loginManager,
+ $this->createMock(LoggerInterface::class)
);
}
static::assertInstanceOf(History::class, $container->history);
static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
static::assertInstanceOf(LoginManager::class, $container->loginManager);
+ static::assertInstanceOf(LoggerInterface::class, $container->logger);
+ static::assertInstanceOf(MetadataRetriever::class, $container->metadataRetriever);
static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
namespace Shaarli\Feed;
use DateTime;
+use malkusch\lock\mutex\NoMutex;
use ReferenceLinkDB;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkFileService;
*/
public static function setUpBeforeClass(): void
{
+ $mutex = new NoMutex();
$conf = new ConfigManager('tests/utils/config/configJson');
$conf->set('resource.datastore', self::$testDatastore);
$refLinkDB = new \ReferenceLinkDB();
$history = new History('sandbox/history.php');
$factory = new FormatterFactory($conf, true);
self::$formatter = $factory->getFormatter();
- self::$bookmarkService = new BookmarkFileService($conf, $history, true);
+ self::$bookmarkService = new BookmarkFileService($conf, $history, $mutex, true);
self::$serverInfo = array(
'HTTPS' => 'Off',
$this->assertSame($tags, $link['taglist']);
$this->assertSame(implode(' ', $tags), $link['tags']);
}
+
+ /**
+ * Test formatTitleHtml with search result highlight.
+ */
+ public function testFormatTitleHtmlWithSearchHighlight(): void
+ {
+ $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+ $bookmark = new Bookmark();
+ $bookmark->setTitle('PSR-2: Coding Style Guide');
+ $bookmark->addAdditionalContentEntry(
+ 'search_highlight',
+ ['title' => [
+ ['start' => 0, 'end' => 5], // "psr-2"
+ ['start' => 7, 'end' => 13], // coding
+ ['start' => 20, 'end' => 25], // guide
+ ]]
+ );
+
+ $link = $this->formatter->format($bookmark);
+
+ $this->assertSame(
+ '<span class="search-highlight">PSR-2</span>: ' .
+ '<span class="search-highlight">Coding</span> Style ' .
+ '<span class="search-highlight">Guide</span>',
+ $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(
+ '<span class="search-highlight">This guide</span> extends and expands on PSR-1, the ' .
+ '<span class="search-highlight">basic</span> coding ' .
+ '<span class="search-highlight">standard.</span>',
+ $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(
+ '<span class="search-highlight">http</span>://www.php-' .
+ '<span class="search-highlight">fig</span>.org/psr/' .
+ '<span class="search-highlight">psr-2/</span>',
+ $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(
+ [
+ '<span class="search-highlight">coding-style</span>',
+ 'standards',
+ '<span class="search-highlight">quality</span>',
+ '<span class="search-highlight">assurance</span>',
+ ],
+ $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!<br />' . PHP_EOL . 'https://thisisaurl.tld #hashtag',
+ $link['description']
+ );
+ }
}
--- /dev/null
+<?php
+
+namespace Shaarli\Formatter;
+
+use DateTime;
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkMarkdownExtraFormatterTest
+ * @package Shaarli\Formatter
+ */
+class BookmarkMarkdownExtraFormatterTest extends TestCase
+{
+ /** @var string Path of test config file */
+ protected static $testConf = 'sandbox/config';
+
+ /** @var BookmarkFormatter */
+ protected $formatter;
+
+ /** @var ConfigManager instance */
+ protected $conf;
+
+ /**
+ * Initialize formatter instance.
+ */
+ public function setUp(): void
+ {
+ copy('tests/utils/config/configJson.json.php', self::$testConf .'.json.php');
+ $this->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 <strong>bookmark</strong>');
+ $bookmark->setDescription('<h2>Content</h2><p>`Here is some content</p>');
+ $bookmark->setTags($tags = ['tag1', 'bookmark', 'other', '<script>alert("xss");</script>']);
+ $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(
+ '<div class="markdown"><p>'.
+ '<h2>Content</h2><p>`Here is some content</p>'.
+ '</p></div>',
+ $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 <strong>description</strong>'. 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 = '<div class="markdown"><p>';
+ $description .= 'This a <strong>description</strong><br />'. PHP_EOL;
+ $url = 'https://sub.domain.tld?query=here&for=real#hash';
+ $description .= 'text <a href="'. $url .'">'. $url .'</a> more text<br />'. PHP_EOL;
+ $description .= 'Also, there is an <a href="./add-tag/hashtag">#hashtag</a> added<br />'. PHP_EOL;
+ $description .= 'A N D KEEP SPACES ! </p>' . PHP_EOL;
+ $description .= '<h1 class="class">Header</h1>';
+ $description .= '</div>';
+
+ $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 = '<div class="markdown"><p>';
+ $description .= 'Text <a href="'. $root .'./add-tag/hashtag">#hashtag</a> more text';
+ $description .= '</p></div>';
+
+ $link = $this->formatter->format($bookmark);
+ $this->assertEquals($root . $short, $link['url']);
+ $this->assertEquals($root . $short, $link['real_url']);
+ $this->assertEquals(
+ $description,
+ $link['description']
+ );
+ }
+}
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']);
+++ /dev/null
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
-
-use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
-use Shaarli\Http\HttpAccess;
-use Shaarli\TestCase;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-class AddShaareTest extends TestCase
-{
- use FrontAdminControllerMockHelper;
-
- /** @var ManageShaareController */
- protected $controller;
-
- public function setUp(): void
- {
- $this->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']);
- }
-}
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Config\ConfigManager;
use Shaarli\Front\Exception\WrongTokenException;
use Shaarli\Security\SessionManager;
use Shaarli\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.
*/
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]
+ );
+ }
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Test Server administration controller.
+ */
+class ServerControllerTest extends TestCase
+{
+ use FrontAdminControllerMockHelper;
+
+ /** @var ServerController */
+ protected $controller;
+
+ public function setUp(): void
+ {
+ $this->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');
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Formatter\BookmarkMarkdownFormatter;
+use Shaarli\Http\HttpAccess;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ShaareAddControllerTest extends TestCase
+{
+ use FrontAdminControllerMockHelper;
+
+ /** @var ShaareAddController */
+ protected $controller;
+
+ public function setUp(): void
+ {
+ $this->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']);
+ }
+}
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\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;
{
use FrontAdminControllerMockHelper;
- /** @var ManageShaareController */
+ /** @var ShaareManageController */
protected $controller;
public function setUp(): void
$this->createContainer();
$this->container->httpAccess = $this->createMock(HttpAccess::class);
- $this->controller = new ManageShaareController($this->container);
+ $this->controller = new ShaareManageController($this->container);
}
/**
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;
{
use FrontAdminControllerMockHelper;
- /** @var ManageShaareController */
+ /** @var ShaareManageController */
protected $controller;
public function setUp(): void
$this->createContainer();
$this->container->httpAccess = $this->createMock(HttpAccess::class);
- $this->controller = new ManageShaareController($this->container);
+ $this->controller = new ShaareManageController($this->container);
}
/**
{
$parameters = ['id' => '123'];
+ $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/shaare/abcdef';
+
$request = $this->createMock(Request::class);
$request
->method('getParam')
{
$parameters = ['id' => '123 456 789'];
+ $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/?searchtags=abcdef';
+
$request = $this->createMock(Request::class);
$request
->method('getParam')
$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'));
}
/**
;
$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())
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;
{
use FrontAdminControllerMockHelper;
- /** @var ManageShaareController */
+ /** @var ShaareManageController */
protected $controller;
public function setUp(): void
$this->createContainer();
$this->container->httpAccess = $this->createMock(HttpAccess::class);
- $this->controller = new ManageShaareController($this->container);
+ $this->controller = new ShaareManageController($this->container);
}
/**
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Test GET /admin/shaare/private/{hash}
+ */
+class SharePrivateTest extends TestCase
+{
+ use FrontAdminControllerMockHelper;
+
+ /** @var ShaareManageController */
+ protected $controller;
+
+ public function setUp(): void
+ {
+ $this->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'));
+ }
+}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
+
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+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;
+
+class DisplayCreateBatchFormTest extends TestCase
+{
+ use FrontAdminControllerMockHelper;
+
+ /** @var ShaarePublishController */
+ protected $controller;
+
+ public function setUp(): void
+ {
+ $this->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']);
+ }
+}
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;
{
use FrontAdminControllerMockHelper;
- /** @var ManageShaareController */
+ /** @var ShaarePublishController */
protected $controller;
public function setUp(): void
$this->createContainer();
$this->container->httpAccess = $this->createMock(HttpAccess::class);
- $this->controller = new ManageShaareController($this->container);
+ $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
+ $this->controller = new ShaarePublishController($this->container);
}
/**
* Test displaying bookmark create form
* Ensure that every step of the standard workflow works properly.
*/
- public function testDisplayCreateFormWithUrl(): void
+ public function testDisplayCreateFormWithUrlAndWithMetadataRetrieval(): void
{
$this->container->environment = [
'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
});
$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())
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']);
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);
}
/**
'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',
];
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']);
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']);
}
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;
{
use FrontAdminControllerMockHelper;
- /** @var ManageShaareController */
+ /** @var ShaarePublishController */
protected $controller;
public function setUp(): void
$this->createContainer();
$this->container->httpAccess = $this->createMock(HttpAccess::class);
- $this->controller = new ManageShaareController($this->container);
+ $this->controller = new ShaarePublishController($this->container);
}
/**
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']);
}
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;
{
use FrontAdminControllerMockHelper;
- /** @var ManageShaareController */
+ /** @var ShaarePublishController */
protected $controller;
public function setUp(): void
$this->createContainer();
$this->container->httpAccess = $this->createMock(HttpAccess::class);
- $this->controller = new ManageShaareController($this->container);
+ $this->controller = new ShaarePublishController($this->container);
}
/**
$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;
})
;
$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;
})
;
/**
* 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'];
$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);
$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;
})
;
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
*/
$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;
})
;
$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';
->expects(static::once())
->method('search')
->with(
- ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'],
+ ['searchtags' => 'abc@def', 'searchterm' => 'ghi jkl'],
'private',
false,
true
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']);
}
/**
);
}
+ /**
+ * 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
$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->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;
})
;
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
*/
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
->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);
);
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];
$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)
$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())
// 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 [];
})
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']);
}
new \DateTimeImmutable('2020-05-17'),
new \DateTimeImmutable('2020-05-15'),
new \DateTimeImmutable('2020-05-13'),
+ new \DateTimeImmutable('+1 month'),
];
$request = $this->createMock(Request::class);
(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
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']);
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']);
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']);
}
/**
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']);
+ }
}
}
/**
- * 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
{
$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);
}
}
// 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;
});
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']);
}
/**
$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)));
->with()
->willReturnCallback(function (string $key): ?string {
if ('searchtags' === $key) {
- return 'ghi def';
+ return 'ghi@def';
}
return null;
->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);
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']);
->with()
->willReturnCallback(function (string $key): ?string {
if ('searchtags' === $key) {
- return 'ghi def';
+ return 'ghi@def';
} elseif ('sort' === $key) {
return 'alpha';
}
->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);
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']);
}
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
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
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
<?php
-namespace Shaarli;
+namespace Shaarli\Helper;
use Shaarli\Config\ConfigManager;
+use Shaarli\FakeApplicationUtils;
require_once 'tests/utils/FakeApplicationUtils.php';
);
}
+ /**
+ * Checks resource permissions in minimal mode.
+ */
+ public function testCheckCurrentResourcePermissionsErrorsMinimalMode(): void
+ {
+ $conf = new ConfigManager('');
+ $conf->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.
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')
+ );
+ }
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Helper;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+
+class DailyPageHelperTest extends TestCase
+{
+ /**
+ * @dataProvider getRequestedTypes
+ */
+ public function testExtractRequestedType(array $queryParams, string $expectedType): void
+ {
+ $request = $this->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],
+ ];
+ }
+}
<?php
-namespace Shaarli;
+namespace Shaarli\Helper;
use Exception;
+use Shaarli\Exceptions\IOException;
+use Shaarli\TestCase;
/**
* Class FileUtilsTest
*
* Test file utility class.
*/
-class FileUtilsTest extends \Shaarli\TestCase
+class FileUtilsTest extends TestCase
{
/**
* @var string Test file path.
*/
protected static $file = 'sandbox/flat.db';
+ protected function setUp(): void
+ {
+ @mkdir('sandbox');
+ mkdir('sandbox/folder2');
+ touch('sandbox/file1');
+ touch('sandbox/file2');
+ mkdir('sandbox/folder1');
+ touch('sandbox/folder1/file1');
+ touch('sandbox/folder1/file2');
+ mkdir('sandbox/folder3');
+ mkdir('/tmp/shaarli-to-delete');
+ }
+
/**
* Delete test file after every test.
*/
protected function tearDown(): void
{
@unlink(self::$file);
+
+ @unlink('sandbox/folder1/file1');
+ @unlink('sandbox/folder1/file2');
+ @rmdir('sandbox/folder1');
+ @unlink('sandbox/file1');
+ @unlink('sandbox/file2');
+ @rmdir('sandbox/folder2');
+ @rmdir('sandbox/folder3');
+ @rmdir('/tmp/shaarli-to-delete');
}
/**
$this->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);
+ }
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+
+class MetadataRetrieverTest extends TestCase
+{
+ /** @var MetadataRetriever */
+ protected $retriever;
+
+ /** @var ConfigManager */
+ protected $conf;
+
+ /** @var HttpAccess */
+ protected $httpAccess;
+
+ public function setUp(): void
+ {
+ $this->conf = $this->createMock(ConfigManager::class);
+ $this->httpAccess = $this->createMock(HttpAccess::class);
+ $this->retriever = new MetadataRetriever($this->conf, $this->httpAccess);
+
+ $this->conf->method('get')->willReturnCallback(function (string $param, $default) {
+ return $default === null ? $param : $default;
+ });
+ }
+
+ /**
+ * Test metadata retrieve() with values returned
+ */
+ public function testFullRetrieval(): void
+ {
+ $url = 'https://domain.tld/link';
+ $remoteTitle = 'Remote Title ';
+ $remoteDesc = 'Sometimes the meta description is relevant.';
+ $remoteTags = 'abc def';
+ $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);
+ }
+}
// 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()
);
'tag3' => 1,
'tag4' => 1,
'ut' => 1,
+ 'assurance' => 1,
+ 'coding-style' => 1,
+ 'quality' => 1,
+ 'standards' => 1,
),
self::$privateLinkDB->linksCountPerTag()
);
'tag4' => 1,
'ut' => 1,
'w3c' => 1,
+ 'assurance' => 1,
+ 'coding-style' => 1,
+ 'quality' => 1,
+ 'standards' => 1,
];
$tags = self::$privateLinkDB->linksCountPerTag();
*/
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);
}
$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);
}
$this->expectException(\Exception::class);
$this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
- UpdaterUtils::write_updates_file('', array('test'));
+ UpdaterUtils::writeUpdatesFile('', array('test'));
}
/**
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;
namespace Shaarli\Netscape;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Config\ConfigManager;
use Shaarli\Formatter\BookmarkFormatter;
*/
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');
}
namespace Shaarli\Netscape;
use DateTime;
+use malkusch\lock\mutex\NoMutex;
use Psr\Http\Message\UploadedFileInterface;
use Shaarli\Bookmark\Bookmark;
use Shaarli\Bookmark\BookmarkFileService;
*/
protected function setUp(): void
{
+ $mutex = new NoMutex();
if (file_exists(self::$testDatastore)) {
unlink(self::$testDatastore);
}
$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);
}
{
$post = array(
'privacy' => 'public',
- 'default_tags' => 'tag1,tag2 tag3'
+ 'default_tags' => 'tag1 tag2 tag3'
);
$files = file2array('netscape_basic.htm');
$this->assertStringMatchesFormat(
{
$post = array(
'privacy' => 'public',
- 'default_tags' => 'tag1&,tag2 "tag3"'
+ 'default_tags' => 'tag1& tag2 "tag3"'
);
$files = file2array('netscape_basic.htm');
$this->assertStringMatchesFormat(
);
}
+ /**
+ * 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
*
$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];
$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);
+ }
}
namespace Shaarli\Security;
-use Shaarli\FileUtils;
+use Psr\Log\LoggerInterface;
+use Shaarli\Helper\FileUtils;
use Shaarli\TestCase;
/**
3,
1800,
$this->banFile,
- $this->logFile
+ $this->createMock(LoggerInterface::class)
);
}
}
namespace Shaarli\Security;
+use Psr\Log\LoggerInterface;
+use Shaarli\FakeConfigManager;
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 */
/** @var CookieManager */
protected $cookieManager;
+ /** @var BanManager */
+ protected $banManager;
+
/**
* Prepare or reset test resources
*/
$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,
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));
}
/**
'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));
}
*/
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());
public function testCheckCredentialsWrongLogin()
{
$this->assertFalse(
- $this->loginManager->checkCredentials('', '', 'b4dl0g1n', $this->password)
+ $this->loginManager->checkCredentials('', 'b4dl0g1n', $this->password)
);
}
public function testCheckCredentialsWrongPassword()
{
$this->assertFalse(
- $this->loginManager->checkCredentials('', '', $this->login, 'b4dp455wd')
+ $this->loginManager->checkCredentials('', $this->login, 'b4dp455wd')
);
}
public function testCheckCredentialsWrongLoginAndPassword()
{
$this->assertFalse(
- $this->loginManager->checkCredentials('', '', 'b4dl0g1n', 'b4dp455wd')
+ $this->loginManager->checkCredentials('', 'b4dl0g1n', 'b4dp455wd')
);
}
public function testCheckCredentialsGoodLoginAndPassword()
{
$this->assertTrue(
- $this->loginManager->checkCredentials('', '', $this->login, $this->password)
+ $this->loginManager->checkCredentials('', $this->login, $this->password)
);
}
{
$this->configManager->set('ldap.host', 'dummy');
$this->assertFalse(
- $this->loginManager->checkCredentials('', '', $this->login, $this->password)
+ $this->loginManager->checkCredentials('', $this->login, $this->password)
);
}
namespace Shaarli\Security;
+use Shaarli\FakeConfigManager;
use Shaarli\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 */
*/
protected function setUp(): void
{
- $this->conf = new \FakeConfigManager([
+ $this->conf = new FakeConfigManager([
'credentials.login' => 'johndoe',
'credentials.salt' => 'salt',
'security.session_protection_disabled' => false,
namespace Shaarli\Updater;
use Exception;
+use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\BookmarkFileService;
use Shaarli\Bookmark\BookmarkServiceInterface;
use Shaarli\Config\ConfigManager;
*/
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);
}
*/
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);
}
$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);
}
$this->expectException(\Exception::class);
$this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
- UpdaterUtils::write_updates_file('', array('test'));
+ UpdaterUtils::writeUpdatesFile('', array('test'));
}
/**
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;
namespace Shaarli;
+use Shaarli\Helper\ApplicationUtils;
+
/**
* Fake ApplicationUtils class to avoid HTTP requests
*/
<?php
+namespace Shaarli;
+
+use Shaarli\Config\ConfigManager;
+
/**
* Fake ConfigManager
*/
-class FakeConfigManager
+class FakeConfigManager extends ConfigManager
{
protected $values = [];
* @param string $key Key of the value to set
* @param mixed $value Value to set
*/
- public function set($key, $value)
+ public function set($key, $value, $write = false, $isLoggedIn = false)
{
$this->values[$key] = $value;
}
*
* @return mixed The value if set, else the name of the key
*/
- public function get($key)
+ public function get($key, $default = '')
{
if (isset($this->values[$key])) {
return $this->values[$key];
<?php
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
use Shaarli\History;
/**
'This guide extends and expands on PSR-1, the basic coding standard.',
0,
DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121206_152312'),
- ''
+ 'coding-style standards quality assurance'
);
$this->addLink(
</form>
</div>
</div>
+
+<div class="pure-g addlink-batch-show-more-block pure-u-0">
+ <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+ <div class="pure-u-lg-1-3 pure-u-22-24 addlink-batch-show-more">
+ <a href="#">{'BULK CREATION'|t} <i class="fa fa-plus-circle" aria-hidden="true"></i></a>
+ </div>
+</div>
+
+<div class="addlink-batch-form-block">
+ {if="empty($async_metadata)"}
+ <div class="pure-g pure-alert pure-alert-warning pure-alert-closable">
+ <div class="pure-u-2-24"></div>
+ <div class="pure-u-20-24">
+ <p>
+ {'Metadata asynchronous retrieval is disabled.'|t}
+ {'We recommend that you enable the setting <em>general > enable_async_metadata</em> in your configuration file to use bulk link creation.'|t}
+ </p>
+ </div>
+ <div class="pure-u-2-24">
+ <i class="fa fa-times pure-alert-close"></i>
+ </div>
+ </div>
+ {/if}
+
+ <div class="pure-g">
+ <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+ <div id="batch-addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
+ <h2 class="window-title">{"Shaare multiple new links"|t}</h2>
+ <form method="POST" action="{$base_path}/admin/shaare-batch" name="batch-addform" class="batch-addform">
+ <div>
+ <label for="urls">{'Add one URL per line to create multiple bookmarks.'|t}</label>
+ <textarea name="urls" id="urls"></textarea>
+
+ <div>
+ <label for="tags">{'Tags'|t}</label>
+ </div>
+ <div>
+ <input type="text" name="tags" id="tags" class="lf_input"
+ data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off">
+ </div>
+
+ <div>
+ <input type="hidden" name="private" value="0">
+ <input type="checkbox" name="private" {if="$default_private_links"} checked="checked"{/if}>
+ <label for="lf_private">{'Private'|t}</label>
+ </div>
+ </div>
+ <div>
+ <input type="hidden" name="token" value="{$token}">
+ <input type="submit" value="{'Add links'|t}">
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
+
{include="page.footer"}
</body>
</html>
<div><i class="fa fa-info-circle" aria-hidden="true"></i> {'Case sensitive'|t}</div>
<input type="hidden" name="token" value="{$token}">
<div>
- <input type="submit" value="{'Rename'|t}" name="renametag">
- <input type="submit" value="{'Delete'|t}" name="deletetag" class="button button-red confirm-delete">
+ <input type="submit" value="{'Rename tag'|t}" name="renametag">
+ <input type="submit" value="{'Delete tag'|t}" name="deletetag"
+ class="button button-red confirm-delete" data-type="tag">
</div>
</form>
<p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p>
</div>
</div>
+
+<div class="pure-g">
+ <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+ <div class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
+ <h2 class="window-title">{"Change tags separator"|t}</h2>
+ <form method="POST" action="{$base_path}/admin/tags/change-separator" name="changeseparator" id="changeseparator">
+ <p>
+ {'Your current tag separator is'|t} <code>{$tags_separator}</code>{if="!empty($tags_separator_desc)"} ({$tags_separator_desc}){/if}.
+ </p>
+ <div>
+ <input type="text" name="separator" placeholder="{'New separator'|t}"
+ id="separator">
+ </div>
+ <input type="hidden" name="token" value="{$token}">
+ <div>
+ <input type="submit" value="{'Save'|t}" name="saveseparator">
+ </div>
+ <p>
+ {'Note that hashtags won\'t fully work with a non-whitespace separator.'|t}
+ </p>
+ </form>
+ </div>
+</div>
{include="page.footer"}
</body>
</html>
<body>
{include="page.header"}
+<div class="pure-g">
+ <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
+ <a href="{$base_path}/daily?day">{'Daily'|t}</a>
+ <a href="{$base_path}/daily?week">{'Weekly'|t}</a>
+ <a href="{$base_path}/daily?month">{'Monthly'|t}</a>
+ </div>
+</div>
+
+
<div class="pure-g">
<div class="pure-u-lg-1-6 pure-u-1-24"></div>
<div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor" id="daily">
<h2 class="window-title">
- {'The Daily Shaarli'|t}
- <a href="{$base_path}/daily-rss" title="{'1 RSS entry per day'|t}"><i class="fa fa-rss"></i></a>
+ {$localizedType} Shaarli
+ <a href="{$base_path}/daily-rss?{$type}"
+ title="{function="t('1 RSS entry per :type', '', 1, 'shaarli', [':type' => t($type)])"}"
+ >
+ <i class="fa fa-rss"></i>
+ </a>
</h2>
<div id="plugin_zone_start_daily" class="plugin_zone">
<div class="pure-g">
<div class="pure-u-lg-1-3 pure-u-1 center">
{if="$previousday"}
- <a href="{$base_path}/daily?day={$previousday}">
+ <a href="{$base_path}/daily?{$type}={$previousday}">
<i class="fa fa-arrow-left"></i>
- {'Previous day'|t}
+ {function="t('Previous :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
</a>
{/if}
</div>
<div class="daily-desc pure-u-lg-1-3 pure-u-1 center">
- {'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)])"}
</div>
<div class="pure-u-lg-1-3 pure-u-1 center">
{if="$nextday"}
- <a href="{$base_path}/daily?day={$nextday}">
- {'Next day'|t}
+ <a href="{$base_path}/daily?{$type}={$nextday}">
+ {function="t('Next :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
<i class="fa fa-arrow-right"></i>
</a>
{/if}
</div>
<div>
<h3 class="window-subtitle">
- {if="!empty($dayDesc)"}
- {$dayDesc} -
- {/if}
- {function="format_date($dayDate, false)"}
+ {$dayDesc}
</h3>
<div id="plugin_zone_about_daily" class="plugin_zone">
</div>
{if="$thumbnails_enabled && !empty($link.thumbnail)"}
<div class="daily-entry-thumbnail">
- <img data-src="{$link.thumbnail}#" class="b-lazy"
+ <img data-src="{$root_path}/{$link.thumbnail}#" class="b-lazy"
src=""
alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
</div>
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
- <title>Daily - {$title}</title>
+ <title>{$localizedType} - {$title}</title>
<link>{$index_url}</link>
- <description>Daily shaared bookmarks</description>
+ <description>{function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}</description>
<language>{$language}</language>
<copyright>{$index_url}</copyright>
<generator>Shaarli</generator>
{loop="$value.links"}
<h3><a href="{$value.url}">{$value.title}</a></h3>
<small>
- {if="!$hide_timestamps"}{$value.created|format_date} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
+ {if="!$hide_timestamps"}{$value.created|format_date} — {/if}
+ <a href="{$index_url}shaare/{$value.shorturl}">{'Permalink'|t}</a>
+ {if="$value.tags"} — {$value.tags}{/if}
+ <br>
{$value.url}
</small><br>
{if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
{if="$value.description"}{$value.description}{/if}
- <br><br><hr>
+ <br><hr>
{/loop}
]]></description>
</item>
--- /dev/null
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+ {include="includes"}
+</head>
+<body>
+<div class="dark-layer">
+ <div class="screen-center">
+ <div><span class="progressbar-current"></span> / <span class="progressbar-max"></span></div>
+ <div class="progressbar">
+ <div></div>
+ </div>
+ </div>
+</div>
+
+{include="page.header"}
+
+<div class="center">
+ <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
+</div>
+
+{loop="$links"}
+ {include="editlink"}
+{/loop}
+
+<div class="center">
+ <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
+</div>
+
+{include="page.footer"}
+{if="$async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
+<script src="{$asset_path}/js/shaare_batch.min.js?v={$version_hash}#"></script>
+{if="empty($batch_mode)"}
<!DOCTYPE html>
<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
<head>
</head>
<body>
{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}
<div id="editlinkform" class="edit-link-container" class="pure-g">
<div class="pure-u-lg-1-5 pure-u-1-24"></div>
<form method="post"
action="{$base_path}/admin/shaare"
class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
>
+ {$asyncLoadClass=$link_is_new && $async_metadata && empty($link.title) ? 'loading-input' : ''}
+
<h2 class="window-title">
{if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
</h2>
<div>
<label for="lf_title">{'Title'|t}</label>
</div>
- <div>
- <input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input autofocus">
+ <div class="{$asyncLoadClass}">
+ <input type="text" name="lf_title" id="lf_title" value="{$link.title}"
+ class="lf_input {if="!$async_metadata"}autofocus{/if}"
+ >
+ <div class="icon-container">
+ <i class="loader"></i>
+ </div>
</div>
<div>
<label for="lf_description">{'Description'|t}</label>
</div>
- <div>
+ <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
<textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea>
+ <div class="icon-container">
+ <i class="loader"></i>
+ </div>
</div>
<div>
<label for="lf_tags">{'Tags'|t}</label>
</div>
- <div>
+ <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
<input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input autofocus"
data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" >
+ <div class="icon-container">
+ <i class="loader"></i>
+ </div>
</div>
<div>
<input type="checkbox" name="lf_private" id="lf_private"
- {if="($link_is_new && $default_private_links || $link.private == true)"}
+ {if="$link.private === true"}
checked="checked"
{/if}>
<label for="lf_private">{'Private'|t}</label>
<div class="submit-buttons center">
+ {if="!empty($batch_mode)"}
+ <a href="#" class="button button-grey" name="cancel-batch-link"
+ title="{'Remove this bookmark from batch creation/modification.'}"
+ >
+ {'Cancel'|t}
+ </a>
+ {/if}
<input type="submit" name="save_edit" class="" id="button-save-edit"
value="{if="$link_is_new"}{'Save'|t}{else}{'Apply Changes'|t}{/if}">
{if="!$link_is_new"}
{/if}
</form>
</div>
+
+{if="empty($batch_mode)"}
{include="page.footer"}
+ {if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
</body>
</html>
+{/if}
<div id="pageError" class="page-error-container center">
<h2>{$message}</h2>
+ <img src="{$asset_path}/img/sad_star.png#" alt="">
+
+ {if="!empty($text)"}
+ <p>{$text}</p>
+ {/if}
+
{if="!empty($stacktrace)"}
<pre>
{$stacktrace}
</pre>
{/if}
-
- <img src="{$asset_path}/img/sad_star.png#" alt="">
</div>
{include="page.footer"}
</body>
<link href="{$asset_path}/img/favicon.png#" rel="shortcut icon" type="image/png" />
<link href="{$asset_path}/img/apple-touch-icon.png#" rel="apple-touch-icon" sizes="180x180" />
<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css?v={$version_hash}#" />
-{if="$formatter==='markdown'"}
+{if="strpos($formatter, 'markdown') !== false"}
<link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
{/if}
{loop="$plugins_includes.css_files"}
- <link type="text/css" rel="stylesheet" href="{$base_path}/{$value}?v={$version_hash}#"/>
+ <link type="text/css" rel="stylesheet" href="{$root_path}/{$value}?v={$version_hash}#"/>
{/loop}
{if="is_file('data/user.css')"}
- <link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />
+ <link type="text/css" rel="stylesheet" href="{$root_path}/data/user.css#" />
{/if}
<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
title="Shaarli search - {$shaarlititle}" />
</div>
</div>
</form>
+
+<div class="pure-g">
+ <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+ <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-form-complete">
+ <h2 class="window-title">{'Server requirements'|t}</h2>
+
+ {include="server.requirements"}
+ </div>
+</div>
+
{include="page.footer"}
</body>
</html>
{'for'|t} <em><strong>{$search_term}</strong></em>
{/if}
{if="!empty($search_tags)"}
- {$exploded_tags=explode(' ', $search_tags)}
+ {$exploded_tags=tags_str2array($search_tags, $tags_separator)}
{'tagged'|t}
{loop="$exploded_tags"}
<span class="label label-tag" title="{'Remove tag'|t}">
{$strAddTag=t('Add tag')}
{$strToggleSticky=t('Toggle sticky')}
{$strSticky=t('Sticky')}
+ {$strShaarePrivate=t('Share a private link')}
{ignore}End of translations{/ignore}
{loop="links"}
<div class="anchor" id="{$value.shorturl}"></div>
<div class="linklist-item linklist-item{if="$value.class"} {$value.class}{/if}" data-id="{$value.id}">
<div class="linklist-item-title">
- {if="$thumbnails_enabled && !empty($value.thumbnail)"}
- <div class="linklist-item-thumbnail" style="width:{$thumbnails_width}px;height:{$thumbnails_height}px;">
+ {if="$thumbnails_enabled && $value.thumbnail !== false"}
+ <div
+ class="linklist-item-thumbnail {if="$value.thumbnail === null"}hidden{/if}"
+ style="width:{$thumbnails_width}px;height:{$thumbnails_height}px;"
+ {if="$value.thumbnail === null"}data-async-thumbnail="1"{/if}
+ >
<div class="thumbnail">
{ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
<a href="{$value.real_url}" aria-hidden="true" tabindex="-1">
- <img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy"
+ <img data-src="{$root_path}/{$value.thumbnail}#" class="b-lazy"
src=""
alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
</a>
</div>
<h2>
- <a href="{$value.real_url}">
+ <a href="{$value.real_url}" class="linklist-real-url">
{if="strpos($value.url, $value.shorturl) === false"}
<i class="fa fa-external-link" aria-hidden="true"></i>
{else}
<i class="fa fa-sticky-note" aria-hidden="true"></i>
{/if}
- <span class="linklist-link">{$value.title}</span>
+ <span class="linklist-link">{$value.title_html}</span>
</a>
</h2>
</div>
{$tag_counter=count($value.taglist)}
{loop="value.taglist"}
<span class="label label-tag" title="{$strAddTag}">
- <a href="{$base_path}/add-tag/{$value1.urlencoded_taglist.$key2}">{$value}</a>
+ <a href="{$base_path}/add-tag/{$value1.taglist_urlencoded.$key2}">{$value1.taglist_html.$key2}</a>
</span>
{if="$tag_counter - 1 != $counter"}·{/if}
{/loop}
{$strPermalinkLc}
</a>
+ {if="$is_logged_in && $value.private"}
+ <a href="{$base_path}/admin/shaare/private/{$value.shorturl}?token={$token}" title="{$strShaarePrivate}">
+ <i class="fa fa-share-alt"></i>
+ </a>
+ {/if}
+
<div class="pure-u-0 pure-u-lg-visible">
{if="isset($value.link_plugin)"}
·
{ignore}do not add space or line break between these div - Firefox issue{/ignore}
class="linklist-item-infos-url pure-u-lg-5-12 pure-u-1">
<a href="{$value.real_url}" aria-label="{$value.title}" title="{$value.title}">
- <i class="fa fa-link" aria-hidden="true"></i> {$value.url}
+ <i class="fa fa-link" aria-hidden="true"></i> {$value.url_html}
</a>
<div class="linklist-item-buttons pure-u-0 pure-u-lg-visible">
<a href="#" aria-label="{$strFold}" title="{$strFold}" class="fold-button"><i class="fa fa-chevron-up" aria-hidden="true"></i></a>
{include="page.footer"}
<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
+{if="$is_logged_in && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
</body>
</html>
{/if}
·
{'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t} ·
- <a href="{$base_path}/doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
+ <a href="{$root_path}/doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
{loop="$plugins_footer.text"}
{$value}
{/loop}
<div class="pure-u-2-24"></div>
</div>
-<input type="hidden" name="token" value="{$token}" id="token" />
-
{loop="$plugins_footer.endofpage"}
{$value}
{/loop}
{loop="$plugins_footer.js_files"}
- <script src="{$base_path}/{$value}#"></script>
+ <script src="{$root_path}/{$value}#"></script>
{/loop}
-<div id="js-translations" class="hidden">
+<div id="js-translations" class="hidden" aria-hidden="true">
<span id="translation-fold">{'Fold'|t}</span>
<span id="translation-fold-all">{'Fold all'|t}</span>
<span id="translation-expand">{'Expand'|t}</span>
<span id="translation-expand-all">{'Expand all'|t}</span>
<span id="translation-delete-link">{'Are you sure you want to delete this link?'|t}</span>
+ <span id="translation-delete-tag">{'Are you sure you want to delete this tag?'|t}</span>
<span id="translation-shaarli-desc">
{'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t}
</span>
</div>
<input type="hidden" name="js_base_path" value="{$base_path}" />
+<input type="hidden" name="token" value="{$token}" id="token" />
+<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
+
<script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script>
{loop="$linksToDisplay"}
<div class="picwall-pictureframe" role="listitem">
{ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
- <img data-src="{$value.thumbnail}#" class="b-lazy"
+ <img data-src="{$root_path}/{$value.thumbnail}#" class="b-lazy"
src=""
alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
<a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
<div class="center more">
{"More plugins available"|t}
- <a href="doc/html/Community-&-Related-software/#third-party-plugins">{"in the documentation"|t}</a>.
+ <a href="{$root_path}/doc/html/Community-&-Related-software/#third-party-plugins">{"in the documentation"|t}</a>.
</div>
<div class="center">
<input type="submit" value="{'Save'|t}" name="save">
--- /dev/null
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+ {include="includes"}
+</head>
+<body>
+{include="page.header"}
+
+<div class="pure-g">
+ <div class="pure-u-lg-1-4 pure-u-1-24"></div>
+ <div class="pure-u-lg-1-2 pure-u-22-24 page-form server-tables-page">
+ <h2 class="window-title">{'Server administration'|t}</h2>
+
+ <h3 class="window-subtitle">{'General'|t}</h3>
+
+ <div class="pure-g server-row">
+ <div class="pure-u-lg-1-2 pure-u-1 server-label">
+ <p>{'Index URL'|t}</p>
+ </div>
+ <div class="pure-u-lg-1-2 pure-u-1">
+ <p><a href="{$index_url}" title="{$pagetitle}">{$index_url}</a></p>
+ </div>
+ </div>
+ <div class="pure-g server-row">
+ <div class="pure-u-lg-1-2 pure-u-1 server-label">
+ <p>{'Base path'|t}</p>
+ </div>
+ <div class="pure-u-lg-1-2 pure-u-1">
+ <p>{$base_path}</p>
+ </div>
+ </div>
+ <div class="pure-g server-row">
+ <div class="pure-u-lg-1-2 pure-u-1 server-label">
+ <p>{'Client IP'|t}</p>
+ </div>
+ <div class="pure-u-lg-1-2 pure-u-1">
+ <p>{$client_ip}</p>
+ </div>
+ </div>
+ <div class="pure-g server-row">
+ <div class="pure-u-lg-1-2 pure-u-1 server-label">
+ <p>{'Trusted reverse proxies'|t}</p>
+ </div>
+ <div class="pure-u-lg-1-2 pure-u-1">
+ {if="count($trusted_proxies) > 0"}
+ <p>
+ {loop="$trusted_proxies"}
+ {$value}<br>
+ {/loop}
+ </p>
+ {else}
+ <p>{'N/A'|t}</p>
+ {/if}
+ </div>
+ </div>
+
+ {include="server.requirements"}
+
+ <h3 class="window-subtitle">Version</h3>
+
+ <div class="pure-g server-row">
+ <div class="pure-u-lg-1-2 pure-u-1 server-label">
+ <p>Current version</p>
+ </div>
+ <div class="pure-u-lg-1-2 pure-u-1">
+ <p>{$current_version}</p>
+ </div>
+ </div>
+
+ <div class="pure-g server-row">
+ <div class="pure-u-lg-1-2 pure-u-1 server-label">
+ <p>Latest release</p>
+ </div>
+ <div class="pure-u-lg-1-2 pure-u-1">
+ <p>
+ <a href="{$release_url}" title="{'Visit releases page on Github'|t}">
+ {$latest_version}
+ </a>
+ </p>
+ </div>
+ </div>
+
+ <h3 class="window-subtitle">Thumbnails</h3>
+
+ <div class="pure-g server-row">
+ <div class="pure-u-lg-1-2 pure-u-1 server-label">
+ <p>Thumbnails status</p>
+ </div>
+ <div class="pure-u-lg-1-2 pure-u-1">
+ <p>
+ {if="$thumbnails_mode==='all'"}
+ {'All'|t}
+ {elseif="$thumbnails_mode==='common'"}
+ {'Only common media hosts'|t}
+ {else}
+ {'None'|t}
+ {/if}
+ </p>
+ </div>
+ </div>
+
+ {if="$thumbnails_mode!=='none'"}
+ <div class="center tools-item">
+ <a href="{$base_path}/admin/thumbnails" title="{'Synchronize all link thumbnails'|t}">
+ <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
+ </a>
+ </div>
+ {/if}
+
+ <h3 class="window-subtitle">Cache</h3>
+
+ <div class="center tools-item">
+ <a href="{$base_path}/admin/clear-cache?type=main">
+ <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Clear main cache</span>
+ </a>
+ </div>
+
+ <div class="center tools-item">
+ <a href="{$base_path}/admin/clear-cache?type=thumbnails">
+ <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Clear thumbnails cache</span>
+ </a>
+ </div>
+ </div>
+</div>
+
+{include="page.footer"}
+
+</body>
+</html>
--- /dev/null
+<div class="server-tables">
+ <h3 class="window-subtitle">{'Permissions'|t}</h3>
+
+ {if="count($permissions) > 0"}
+ <p class="center">
+ <i class="fa fa-close fa-color-red" aria-hidden="true"></i>
+ {'There are permissions that need to be fixed.'|t}
+ </p>
+
+ <p>
+ {loop="$permissions"}
+ <div class="center">{$value}</div>
+ {/loop}
+ </p>
+ {else}
+ <p class="center">
+ <i class="fa fa-check fa-color-green" aria-hidden="true"></i>
+ {'All read/write permissions are properly set.'|t}
+ </p>
+ {/if}
+
+ <h3 class="window-subtitle">PHP</h3>
+
+ <p class="center">
+ <strong>{'Running PHP'|t} {$php_version}</strong>
+ {if="$php_has_reached_eol"}
+ <i class="fa fa-circle fa-color-orange" aria-label="hidden"></i><br>
+ {'End of life: '|t} {$php_eol}
+ {else}
+ <i class="fa fa-circle fa-color-green" aria-label="hidden"></i><br>
+ {/if}
+ </p>
+
+ <table class="center">
+ <thead>
+ <tr>
+ <th>{'Extension'|t}</th>
+ <th>{'Usage'|t}</th>
+ <th>{'Status'|t}</th>
+ <th>{'Loaded'|t}</th>
+ </tr>
+ </thead>
+ <tbody>
+ {loop="$php_extensions"}
+ <tr>
+ <td>{$value.name}</td>
+ <td>{$value.desc}</td>
+ <td>{$value.required ? t('Required') : t('Optional')}</td>
+ <td>
+ {if="$value.loaded"}
+ {$classLoaded="fa-color-green"}
+ {$strLoaded=t('Loaded')}
+ {else}
+ {$strLoaded=t('Not loaded')}
+ {if="$value.required"}
+ {$classLoaded="fa-color-red"}
+ {else}
+ {$classLoaded="fa-color-orange"}
+ {/if}
+ {/if}
+
+ <i class="fa fa-circle {$classLoaded}" aria-label="{$strLoaded}" title="{$strLoaded}"></i>
+ </td>
+ </tr>
+ {/loop}
+ </tbody>
+ </table>
+</div>
<div id="cloudtag" class="cloudtag-container">
{loop="tags"}
- <a href="{$base_path}/?searchtags={$tags_url.$key1} {$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a
+ <a href="{$base_path}/?searchtags={$tags_url.$key1}{$tags_separator|urlencode}{$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a
><a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
{loop="$value.tag_plugin"}
{$value}
<span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Plugin administration'|t}</span>
</a>
</div>
+ <div class="tools-item">
+ <a href="{$base_path}/admin/server"
+ title="{'Check instance\'s server configuration'|t}">
+ <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Server administration'|t}</span>
+ </a>
+ </div>
{if="!$openshaarli"}
<div class="tools-item">
<a href="{$base_path}/admin/password" title="{'Change your password'|t}">
</a>
</div>
- {if="$thumbnails_enabled"}
- <div class="tools-item">
- <a href="{$base_path}/admin/thumbnails" title="{'Synchronize all link thumbnails'|t}">
- <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
- </a>
- </div>
- {/if}
-
{loop="$tools_plugin"}
<div class="tools-item">
{$value}
<div class="dailyAbout">
All links of one day<br>in a single page.<br>
- {if="$previousday"} <a href="{$base_path}/daily&day={$previousday}"><b><</b>Previous day</a>{else}<b><</b>Previous day{/if}
+ {if="$previousday"} <a href="{$base_path}/daily?day={$previousday}"><b><</b>Previous day</a>{else}<b><</b>Previous day{/if}
-
- {if="$nextday"}<a href="{$base_path}/daily&day={$nextday}">Next day<b>></b></a>{else}Next day<b>></b>{/if}
+ {if="$nextday"}<a href="{$base_path}/daily?day={$nextday}">Next day<b>></b></a>{else}Next day<b>></b>{/if}
<br>
{loop="$daily_about_plugin"}
{$link=$value}
<div class="dailyEntry">
<div class="dailyEntryPermalink">
- <a href="{$base_path}/?{$value.shorturl}">
+ <a href="{$base_path}/shaare/{$value.shorturl}">
<img src="{$asset_path}/img/squiggle.png#" width="25" height="26" title="permalink" alt="permalink">
</a>
</div>
{if="!$hide_timestamps || $is_logged_in"}
<div class="dailyEntryLinkdate">
- <a href="{$base_path}/?{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a>
+ <a href="{$base_path}/shaare/{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a>
</div>
{/if}
{if="$link.tags"}
{if="$link.title==''"}onload="document.linkform.lf_title.focus();"
{elseif="$link.description==''"}onload="document.linkform.lf_description.focus();"
{else}onload="document.linkform.lf_tags.focus();"{/if} >
+{$asyncLoadClass=$link_is_new && $async_metadata && empty($link.title) ? 'loading-input' : ''}
<div id="pageheader">
{include="page.header"}
<div id="shaarli_title"><a href="{$titleLink}">{$shaarlititle}</a></div>
{if="isset($link.id)"}
<input type="hidden" name="lf_id" value="{$link.id}">
{/if}
- <label for="lf_url"><i>URL</i></label><br><input type="text" name="lf_url" id="lf_url" value="{$link.url}" class="lf_input"><br>
- <label for="lf_title"><i>Title</i></label><br><input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input"><br>
- <label for="lf_description"><i>Description</i></label><br><textarea name="lf_description" id="lf_description" rows="4" cols="25">{$link.description}</textarea><br>
- <label for="lf_tags"><i>Tags</i></label><br>
- <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input"
- data-list="{loop="$tags"}{$key}, {/loop}" data-multiple autocomplete="off" ><br>
+ <label for="lf_url"><i>URL</i></label><br><input type="text" name="lf_url" id="lf_url" value="{$link.url}" class="lf_input">
+ <label for="lf_title"><i>Title</i></label>
+ <div class="{$asyncLoadClass}">
+ <input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input">
+ <div class="icon-container">
+ <i class="loader"></i>
+ </div>
+ </div>
+ <label for="lf_description"><i>Description</i></label>
+ <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
+ <textarea name="lf_description" id="lf_description" rows="4" cols="25">{$link.description}</textarea>
+ <div class="icon-container">
+ <i class="loader"></i>
+ </div>
+ </div>
+ <label for="lf_tags"><i>Tags</i></label>
+ <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
+ <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input"
+ data-list="{loop="$tags"}{$key}, {/loop}" data-multiple autocomplete="off" >
+ <div class="icon-container">
+ <i class="loader"></i>
+ </div>
+ </div>
{if="$formatter==='markdown'"}
<div class="md_help">
</div>
</div>
{include="page.footer"}
-</body>
+{if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}</body>
</html>
<meta name="referrer" content="same-origin">
<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" />
<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" />
-<link href="img/favicon.ico" rel="shortcut icon" type="image/x-icon" />
+<link href="{$asset_path}/img/favicon.ico#" rel="shortcut icon" type="image/x-icon" />
<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css#" />
{if="$formatter==='markdown'"}
<link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
{/if}
{loop="$plugins_includes.css_files"}
-<link type="text/css" rel="stylesheet" href="{$base_path}/{$value}#"/>
+<link type="text/css" rel="stylesheet" href="{$root_path}/{$value}#"/>
{/loop}
{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />{/if}
<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
for <em>{$search_term}</em>
{/if}
{if="!empty($search_tags)"}
- {$exploded_tags=explode(' ', $search_tags)}
+ {$exploded_tags=tags_str2array($search_tags, $tags_separator)}
tagged
{loop="$exploded_tags"}
<span class="linktag" title="Remove tag">
{/if}
<ul>
{loop="$links"}
- <li{if="$value.class"} class="{$value.class}"{/if}>
+ <li{if="$value.class"} class="{$value.class}"{/if} data-id="{$value.id}">
<a id="{$value.shorturl}"></a>
- {if="$thumbnails_enabled && !empty($value.thumbnail)"}
- <div class="thumbnail">
+ {if="$thumbnails_enabled && $value.thumbnail !== false"}
+ <div class="thumbnail" {if="$value.thumbnail === null"}data-async-thumbnail="1"{/if}>
<a href="{$value.real_url}">
{ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
<img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy"
{include="page.footer"}
<script src="{$asset_path}/js/thumbnails.min.js#"></script>
+{if="$is_logged_in && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
</body>
</html>
</div>
{/if}
-<script src="{$asset_path}/js/shaarli.min.js#"></script>
-
{if="$is_logged_in"}
<script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script>
{/if}
{/loop}
<input type="hidden" name="js_base_path" value="{$base_path}" />
+<input type="hidden" name="token" value="{$token}" id="token" />
+<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
+
+<script src="{$asset_path}/js/shaarli.min.js#"></script>
</ul>
{/if}
+{if="!empty($global_errors)"}
+ <ul class="errors">
+ {loop="$global_errors"}
+ <li>{$value}</li>
+ {/loop}
+ </ul>
+{/if}
+
+{if="!empty($global_warnings)"}
+ <ul class="warnings">
+ {loop="$global_warnings"}
+ <li>{$value}</li>
+ {/loop}
+ </ul>
+{/if}
+
+{if="!empty($global_successes)"}
+ <ul class="successes">
+ {loop="$global_successes"}
+ <li>{$value}</li>
+ {/loop}
+ </ul>
+{/if}
+
<div class="clear"></div>
{
mode: 'production',
entry: {
+ shaare_batch: './assets/common/js/shaare-batch.js',
thumbnails: './assets/common/js/thumbnails.js',
thumbnails_update: './assets/common/js/thumbnails-update.js',
+ metadata: './assets/common/js/metadata.js',
pluginsadmin: './assets/default/js/plugins-admin.js',
shaarli: [
'./assets/default/js/base.js',
].concat(glob.sync('./assets/vintage/img/*')),
markdown: './assets/common/css/markdown.css',
thumbnails: './assets/common/js/thumbnails.js',
+ metadata: './assets/common/js/metadata.js',
thumbnails_update: './assets/common/js/thumbnails-update.js',
},
output: {
loader: 'file-loader',
options: {
name: '../img/[name].[ext]',
- publicPath: '',
+ // do not add a publicPath here because it's already handled by CSS's publicPath
+ publicPath: '../vintage',
}
}
],
inherits "^2.0.3"
minimalistic-assert "^1.0.1"
+he@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+ integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
hmac-drbg@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"