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
# Stage 4:
# - Shaarli image
-FROM alpine:3.8
+FROM alpine:3.12
LABEL maintainer="Shaarli Community"
RUN apk --update --no-cache add \
php7-openssl \
php7-session \
php7-xml \
+ php7-simplexml \
php7-zlib \
s6
# Stage 1:
# - Copy Shaarli sources
# - Build documentation
-FROM arm32v6/alpine:3.8 as docs
+FROM arm32v6/alpine:3.10 as docs
ADD . /usr/src/app/shaarli
RUN apk --update --no-cache add py2-pip \
&& cd /usr/src/app/shaarli \
# Stage 2:
# - Resolve PHP dependencies with Composer
-FROM arm32v6/alpine:3.8 as composer
+FROM arm32v6/alpine:3.10 as composer
COPY --from=docs /usr/src/app/shaarli /app/shaarli
RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer \
&& cd /app/shaarli \
# Stage 3:
# - Frontend dependencies
-FROM arm32v6/alpine:3.8 as node
+FROM arm32v6/alpine:3.10 as node
COPY --from=composer /app/shaarli /shaarli
RUN apk --update --no-cache add yarn nodejs-current python2 build-base \
&& cd /shaarli \
# Stage 4:
# - Shaarli image
-FROM arm32v6/alpine:3.8
+FROM arm32v6/alpine:3.10
LABEL maintainer="Shaarli Community"
RUN apk --update --no-cache add \
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:
[![](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.12.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0)
+[![](https://img.shields.io/badge/latest-v0.12.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1)
[![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli)
•
[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/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);
'en' => t('English'),
'fr' => t('French'),
'jp' => t('Japanese'),
+ 'ru' => t('Russian'),
];
}
}
*/
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;
*/
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');
<?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|null $input Request Link.
- * @param bool $defaultPrivate Setting defined if a bookmark is private by default.
+ * @param array|null $input Request Link.
+ * @param bool $defaultPrivate Setting defined if a bookmark is private by default.
+ * @param string $tagsSeparator Tags separator loaded from the config file.
*
* @return Bookmark instance.
*/
- public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark
- {
+ public static function buildBookmarkFromRequest(
+ ?array $input,
+ bool $defaultPrivate,
+ string $tagsSeparator
+ ): Bookmark {
$bookmark = new Bookmark();
$url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
if (isset($input['private'])) {
$bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
$bookmark->setUrl($url);
$bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
+
+ // Be permissive with provided tags format
+ if (is_string($input['tags'] ?? null)) {
+ $input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
+ }
+ if (is_array($input['tags'] ?? null) && count($input['tags']) === 1 && is_string($input['tags'][0])) {
+ $input['tags'] = tags_str2array($input['tags'][0], $tagsSeparator);
+ }
+
$bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
$bookmark->setPrivate($private);
<?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 postLink($request, $response)
{
$data = (array) ($request->getParsedBody() ?? []);
- $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
+ $bookmark = ApiUtils::buildBookmarkFromRequest(
+ $data,
+ $this->conf->get('privacy.default_private_links'),
+ $this->conf->get('general.tags_separator', ' ')
+ );
// 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);
}
$index = index_url($this->ci['environment']);
$data = $request->getParsedBody();
- $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
+ $requestBookmark = ApiUtils::buildBookmarkFromRequest(
+ $data,
+ $this->conf->get('privacy.default_private_links'),
+ $this->conf->get('general.tags_separator', ' ')
+ );
// 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() != $id
) {
*/
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()
];
}
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;
/**
* 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(array $data): Bookmark
+ public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
{
$this->id = $data['id'] ?? null;
$this->shortUrl = $data['shorturl'] ?? 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'];
*/
public function validate(): void
{
- if ($this->id === null
+ if (
+ $this->id === null
|| ! is_int($this->id)
|| empty($this->shortUrl)
|| empty($this->created)
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;
*/
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;
}
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 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(): string
+ public function getTagsString(string $separator = ' '): string
{
- return implode(' ', $this->getTags());
+ return tags_array2str($this->getTags(), $separator);
}
/**
* - trailing dash in tags will be removed
*
* @param string|null $tags
+ * @param string $separator Tags separator loaded from the config file.
*
* @return $this
*/
- public function setTagsString(?string $tags): Bookmark
+ 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->tags = $tags;
+ $this->setTags(tags_str2array($tags, $separator));
return $this;
}
*/
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);
}
}
*/
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);
}
*/
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()
*/
public function getByUrl(string $url): ?Bookmark
{
- if (! empty($url)
+ if (
+ ! empty($url)
&& isset($this->urls[$url])
&& isset($this->bookmarks[$this->urls[$url]])
) {
} 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(string $hash): Bookmark
+ 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;
}
$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');
}
$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;
$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)
/**
* @inheritDoc
*/
- public function days(): array
- {
- $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 array_map('strval', $bookmarkDays);
+ return $out;
}
/**
* @inheritDoc
*/
- public function filterDay(string $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;
}
/**
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()
);
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;
}
/**
$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 $this->bookmarks;
}
- $out = array();
+ $out = [];
foreach ($this->bookmarks as $key => $value) {
if ($value->isPrivate() && $visibility === 'private') {
$out[$key] = $value;
*
* @return string generated regex fragment
*/
- private static function tag2regex(string $tag): string
+ 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;
}
*/
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
}
}
- if (empty(trim($link->getTagsString()))) {
+ if (empty($link->getTags())) {
$filtered[$key] = $link;
}
}
*/
protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
{
- $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') .'\\';
+ $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;
$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($link->getTagsString())];
+ $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
return $content;
}
namespace Shaarli\Bookmark;
+use malkusch\lock\exception\LockAcquireException;
use malkusch\lock\mutex\Mutex;
use malkusch\lock\mutex\NoMutex;
use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
}
$content = null;
- $this->mutex->synchronized(function () use (&$content) {
+ $this->synchronized(function () use (&$content) {
$content = file_get_contents($this->datastore);
});
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));
}
- $data = 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) {
+ $this->synchronized(function () use ($data) {
file_put_contents(
$this->datastore,
$data
);
});
}
+
+ /**
+ * Wrapper applying mutex to provided function.
+ * If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
+ *
+ * @see https://github.com/shaarli/Shaarli/issues/1650
+ *
+ * @param callable $function
+ */
+ protected function synchronized(callable $function): void
+ {
+ try {
+ $this->mutex->synchronized($function);
+ } catch (LockAcquireException $exception) {
+ $function();
+ }
+ }
}
* 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
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.
/**
* 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 Bookmark
*
* @throws \Exception
*/
- public function findByHash(string $hash): Bookmark;
+ public function findByHash(string $hash, string $privateKey = null);
/**
* @param $url
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.
*
- * @return array containing days (in format YYYYMMDD).
+ * @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 List of bookmarks matching provided period of time.
*/
- public function days(): array;
+ public function findByDate(
+ \DateTimeInterface $from,
+ \DateTimeInterface $to,
+ ?\DateTimeInterface &$previous,
+ ?\DateTimeInterface &$next
+ ): array;
/**
- * Returns the list of articles for a given day.
- *
- * @param string $request day to filter. Format: YYYYMMDD.
+ * Returns the latest bookmark by creation date.
*
- * @return Bookmark[] list of shaare found.
- *
- * @throws BookmarkNotFoundException
+ * @return Bookmark|null Found Bookmark or null if the datastore is empty.
*/
- public function filterDay(string $request);
+ public function getLatest(): ?Bookmark;
/**
* Creates the default database after a fresh install.
$propertiesKey = ['property', 'name', 'itemprop'];
$properties = implode('|', $propertiesKey);
// 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 image.
- $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#';
+ $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
+ // Support quotes in double quoted content, and the other way around
+ $content = 'content=(["\'])((?:(?!\1).)*)\1';
+ // Try to retrieve OpenGraph tag.
+ $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
// 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 .')=(?:'. $orCondition .').*?>#';
+ $ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $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 PluginManager */
+ protected $pluginManager;
+
+ /** @var LoggerInterface */
+ protected $logger;
+
/** @var string|null */
protected $basePath = null;
ConfigManager $conf,
SessionManager $session,
CookieManager $cookieManager,
- LoginManager $login
+ LoginManager $login,
+ PluginManager $pluginManager,
+ LoggerInterface $logger
) {
$this->conf = $conf;
$this->session = $session;
$this->login = $login;
$this->cookieManager = $cookieManager;
+ $this->pluginManager = $pluginManager;
+ $this->logger = $logger;
}
public function build(): ShaarliContainer
$container['sessionManager'] = $this->session;
$container['cookieManager'] = $this->cookieManager;
$container['loginManager'] = $this->login;
+ $container['pluginManager'] = $this->pluginManager;
+ $container['logger'] = $this->logger;
$container['basePath'] = $this->basePath;
- $container['plugins'] = function (ShaarliContainer $container): PluginManager {
- return new PluginManager($container->conf);
- };
$container['history'] = function (ShaarliContainer $container): History {
return new History($container->conf->get('resource.history'));
);
};
+ $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['pluginManager'] = function (ShaarliContainer $container): PluginManager {
- $pluginManager = new PluginManager($container->conf);
-
- $pluginManager->load($container->conf->get('general.enabled_plugins'));
-
- return $pluginManager;
- };
-
$container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
return new FormatterFactory(
$container->conf,
$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
+declare(strict_types=1);
+
namespace Shaarli\Feed;
+use DatePeriod;
+
/**
* Simple cache system, mainly for the RSS/ATOM feeds
*/
class CachedPage
{
- // Directory containing page caches
- private $cacheDir;
+ /** Directory containing page caches */
+ protected $cacheDir;
+
+ /** Should this URL be cached (boolean)? */
+ protected $shouldBeCached;
- // Should this URL be cached (boolean)?
- private $shouldBeCached;
+ /** Name of the cache file for this URL */
+ protected $filename;
- // Name of the cache file for this URL
- private $filename;
+ /** @var DatePeriod|null Optionally specify a period of time for cache validity */
+ protected $validityPeriod;
/**
* Creates a new CachedPage
*
- * @param string $cacheDir page cache directory
- * @param string $url page URL
- * @param bool $shouldBeCached whether this page needs to be cached
+ * @param string $cacheDir page cache directory
+ * @param string $url page URL
+ * @param bool $shouldBeCached whether this page needs to be cached
+ * @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
*/
- public function __construct($cacheDir, $url, $shouldBeCached)
+ public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
{
// TODO: check write access to the cache directory
$this->cacheDir = $cacheDir;
$this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
$this->shouldBeCached = $shouldBeCached;
+ $this->validityPeriod = $validityPeriod;
}
/**
if (!$this->shouldBeCached) {
return null;
}
- if (is_file($this->filename)) {
- return file_get_contents($this->filename);
+ if (!is_file($this->filename)) {
+ return null;
+ }
+ if ($this->validityPeriod !== null) {
+ $cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
+ if (
+ $cacheDate < $this->validityPeriod->getStartDate()
+ || $cacheDate > $this->validityPeriod->getEndDate()
+ ) {
+ return null;
+ }
}
- return null;
+
+ return file_get_contents($this->filename);
}
/**
<?php
+
namespace Shaarli\Feed;
use DateTime;
$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
{
- const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
- const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
+ protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
+ protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
/**
* @inheritdoc
$bookmark->getDescription() ?? '',
$bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
);
+ $description = format_description(
+ escape($description),
+ $indexUrl,
+ $this->conf->get('formatter_settings.autolink', true)
+ );
- return $this->replaceTokens(format_description(escape($description), $indexUrl));
+ return $this->replaceTokens($description);
}
/**
*/
protected function formatTagListHtml($bookmark)
{
+ $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
return $this->formatTagList($bookmark);
}
$tags = $this->tokenizeSearchHighlightField(
- $bookmark->getTagsString(),
+ $bookmark->getTagsString($tagsSeparator),
$bookmark->getAdditionalContentEntry('search_highlight')['tags']
);
- $tags = $this->filterTagList(explode(' ', $tags));
+ $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
$tags = escape($tags);
$tags = $this->replaceTokensArray($tags);
*/
protected function formatTagString($bookmark)
{
- return implode(' ', $this->formatTagList($bookmark));
+ return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
}
/**
*/
protected function formatTagString($bookmark)
{
- return implode(' ', $this->formatTagList($bookmark));
+ return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($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
*
/**
* 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;
$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')
$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));
+
+ $permissions = array_merge(
+ ApplicationUtils::checkResourcePermissions($this->container->conf),
+ ApplicationUtils::checkDatastoreMutex()
+ );
+
+ $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', $permissions);
+ $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'] = $link['tags'] !== null && 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));
$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));
public function rss(Request $request, Response $response): Response
{
$response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
+ $type = DailyPageHelper::extractRequestedType($request);
+ $cacheDuration = DailyPageHelper::getCacheDatePeriodByType($type);
$pageUrl = page_url($this->container->environment);
- $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
+ $cache = $this->container->pageCacheManager->getCachePage($pageUrl, $cacheDuration);
$cached = $cache->cachedVersion();
if (!empty($cached)) {
}
$days = [];
+ $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, false),
+ '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));
+
+ $permissions = array_merge(
+ ApplicationUtils::checkResourcePermissions($this->container->conf),
+ ApplicationUtils::checkDatastoreMutex()
+ );
+
+ $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', $permissions);
+
+ $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:
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 malkusch\lock\exception\LockAcquireException;
+use malkusch\lock\mutex\FlockMutex;
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 $errors;
}
+ public static function checkDatastoreMutex(): array
+ {
+ $mutex = new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2);
+ try {
+ $mutex->synchronized(function () {
+ return true;
+ });
+ } catch (LockAcquireException $e) {
+ $errors[] = t('Lock can not be acquired on the datastore. You might encounter concurrent access issues.');
+ }
+
+ return $errors ?? [];
+ }
+
/**
* Returns a salted hash representing the current Shaarli version.
*
{
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 DatePeriod;
+use DateTimeImmutable;
+use Exception;
+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)
+ * @param bool $includeRelative Include relative date description (today, yesterday, etc.)
+ *
+ * @return string Localized time period description
+ *
+ * @throws Exception Type not supported.
+ */
+ public static function getDescriptionByType(
+ string $type,
+ \DateTimeImmutable $requested,
+ bool $includeRelative = true
+ ): 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 ($includeRelative && $requested->format('Ymd') === date('Ymd')) {
+ $out = t('Today') . ' - ';
+ } elseif ($includeRelative && $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');
+ }
+ }
+
+ /**
+ * Get the number of items to display in the RSS feed depending on the given type.
+ *
+ * @param string $type month/week/day
+ * @param ?DateTimeImmutable $requested Currently only used for UT
+ *
+ * @return DatePeriod number of elements
+ *
+ * @throws Exception Type not supported.
+ */
+ public static function getCacheDatePeriodByType(string $type, DateTimeImmutable $requested = null): DatePeriod
+ {
+ $requested = $requested ?? new DateTimeImmutable();
+
+ return new DatePeriod(
+ static::getStartDateTimeByType($type, $requested),
+ new \DateInterval('P1D'),
+ static::getEndDateTimeByType($type, $requested)
+ );
+ }
+}
<?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 array_map([$this, 'cleanMetadata'], [
+ 'title' => $title,
+ 'description' => $description,
+ 'tags' => $tags,
+ ]);
+ }
+
+ protected function cleanMetadata($data): ?string
+ {
+ return !is_string($data) || empty(trim($data)) ? null : trim($data);
+ }
+}
*/
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;
use Shaarli\Plugin\Exception\PluginFileNotFoundException;
+use Shaarli\Plugin\Exception\PluginInvalidRouteException;
/**
* Class PluginManager
*
* @var array $loadedPlugins
*/
- private $loadedPlugins = array();
+ private $loadedPlugins = [];
+
+ /** @var array List of registered routes. Contains keys:
+ * - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
+ * - `route` (path): without prefix, e.g. `/up/{variable}`
+ * It will be later prefixed by `/plugin/<plugin name>/`.
+ * - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
+ */
+ protected $registeredRoutes = [];
/**
* @var ConfigManager Configuration Manager instance.
public function __construct(&$conf)
{
$this->conf = $conf;
- $this->errors = array();
+ $this->errors = [];
}
/**
$this->loadPlugin($dirs[$index], $plugin);
} catch (PluginFileNotFoundException $e) {
error_log($e->getMessage());
+ } catch (\Throwable $e) {
+ $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
+ $this->errors = array_unique(array_merge($this->errors, [$error]));
}
}
}
*
* @return void
*/
- public function executeHooks($hook, &$data, $params = array())
+ public function executeHooks($hook, &$data, $params = [])
{
$metadataParameters = [
'target' => '_PAGE_',
}
}
+ $registerRouteFunction = $pluginName . '_register_routes';
+ $routes = null;
+ if (function_exists($registerRouteFunction)) {
+ $routes = call_user_func($registerRouteFunction);
+ }
+
+ if ($routes !== null) {
+ foreach ($routes as $route) {
+ if (static::validateRouteRegistration($route)) {
+ $this->registeredRoutes[$pluginName][] = $route;
+ } else {
+ throw new PluginInvalidRouteException($pluginName);
+ }
+ }
+ }
+
$this->loadedPlugins[] = $pluginName;
}
*/
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;
return $metaData;
}
+ /**
+ * @return array List of registered custom routes by plugins.
+ */
+ public function getRegisteredRoutes(): array
+ {
+ return $this->registeredRoutes;
+ }
+
/**
* Return the list of encountered errors.
*
{
return $this->errors;
}
+
+ /**
+ * Checks whether provided input is valid to register a new route.
+ * It must contain keys `method`, `route`, `callable` (all strings).
+ *
+ * @param string[] $input
+ *
+ * @return bool
+ */
+ protected static function validateRouteRegistration(array $input): bool
+ {
+ if (
+ !array_key_exists('method', $input)
+ || !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
+ ) {
+ return false;
+ }
+
+ if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
+ return false;
+ }
+
+ if (!array_key_exists('callable', $input)) {
+ return false;
+ }
+
+ return true;
+ }
}
<?php
+
namespace Shaarli\Plugin\Exception;
use Exception;
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Plugin\Exception;
+
+use Exception;
+
+/**
+ * Class PluginFileNotFoundException
+ *
+ * Raise when plugin files can't be found.
+ */
+class PluginInvalidRouteException extends Exception
+{
+ /**
+ * Construct exception with plugin name.
+ * Generate message.
+ *
+ * @param string $pluginName name of the plugin not found
+ */
+ public function __construct()
+ {
+ $this->message = 'trying to register invalid route.';
+ }
+}
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);
namespace Shaarli\Render;
+use DatePeriod;
use Shaarli\Feed\CachedPage;
/**
$this->purgeCachedPages();
}
- public function getCachePage(string $pageUrl): CachedPage
+ /**
+ * Get CachedPage instance for provided URL.
+ *
+ * @param string $pageUrl
+ * @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
+ *
+ * @return CachedPage
+ */
+ public function getCachePage(string $pageUrl, DatePeriod $validityPeriod = null): CachedPage
{
return new CachedPage(
$this->pageCacheDir,
$pageUrl,
- false === $this->isLoggedIn
+ false === $this->isLoggedIn,
+ $validityPeriod
);
}
}
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();
/**
* 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-tag').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 {
&.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;
});
})();
"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",
- "shaarli/netscape-bookmark-parser": "^2.1",
+ "shaarli/netscape-bookmark-parser": "^3.0",
"slim/slim": "^3.0"
},
"require-dev": {
"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": "932b191006135ff8be495aa0b4ba7e09",
+ "content-hash": "83852dec81e299a117a81206a5091472",
"packages": [
{
"name": "arthurhoaro/web-thumbnailer",
},
{
"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": "ba5d234b3a1559321b816b64aafc2ce6728799ff"
+ "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ba5d234b3a1559321b816b64aafc2ce6728799ff",
- "reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff",
+ "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",
"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",
"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",
"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",
"type": "tidelift"
}
],
- "time": "2020-10-08T21:02:27+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/)
### Authentication
- All requests to Shaarli's API must include a **JWT token** to verify their authenticity.
-- This token must be included as an HTTP header called `Authentication: Bearer <jwt token>`.
+- This token must be included as an HTTP header called `Authorization: Bearer <jwt token>`.
- JWT tokens are composed by three parts, separated by a dot `.` and encoded in base64:
```
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;
}
-
}
```
"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)
> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
+### Register plugin's routes
+
+Shaarli lets you register custom Slim routes for your plugin.
+
+To register a route, the plugin must include a function called `function <plugin_name>_register_routes(): array`.
+
+This method must return an array of routes, each entry must contain the following keys:
+
+ - `method`: HTTP method, `GET/POST/PUT/PATCH/DELETE`
+ - `route` (path): without prefix, e.g. `/up/{variable}`
+ It will be later prefixed by `/plugin/<plugin name>/`.
+ - `callable` string, function name or FQN class's method to execute, e.g. `demo_plugin_custom_controller`.
+
+Callable functions or methods must have `Slim\Http\Request` and `Slim\Http\Response` parameters
+and return a `Slim\Http\Response`. We recommend creating a dedicated class and extend either
+`ShaarliVisitorController` or `ShaarliAdminController` to use helper functions they provide.
+
+A dedicated plugin template is available for rendering content: `pluginscontent.html` using `content` placeholder.
+
+> **Warning**: plugins are not able to use RainTPL template engine for their content due to technical restrictions.
+> RainTPL does not allow to register multiple template folders, so all HTML rendering must be done within plugin
+> custom controller.
+
+Check out the `demo_plugin` for a live example: `GET <shaarli_url>/plugin/demo_plugin/custom`.
+
### Understanding relative paths
Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
# If releasing a new minor version, create a release branch
$ git checkout -b v0.x
+# Otherwise just use the existing one
+$ git checkout v0.x
+
+# Get the latest changes
+$ git merge master
+
+# Check that everything went fine:
+$ make test
# Bump shaarli_version.php from dev to 0.x.0, **without the v**
$ vim shaarli_version.php
# 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-10-16 20:01+0200\n"
-"PO-Revision-Date: 2020-10-16 20:02+0200\n"
+"POT-Creation-Date: 2020-11-24 13:13+0100\n"
+"PO-Revision-Date: 2020-11-24 13:14+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:181
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:192
msgid "Could not parse history file"
msgstr "Format incorrect pour le fichier d'historique"
-#: application/Languages.php:181
+#: application/Languages.php:184
msgid "Automatic"
msgstr "Automatique"
-#: application/Languages.php:182
+#: application/Languages.php:185
msgid "German"
msgstr "Allemand"
-#: application/Languages.php:183
+#: application/Languages.php:186
msgid "English"
msgstr "Anglais"
-#: application/Languages.php:184
+#: application/Languages.php:187
msgid "French"
msgstr "Français"
-#: application/Languages.php:185
+#: application/Languages.php:188
msgid "Japanese"
msgstr "Japonais"
"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:405
msgid "Setting not set"
msgstr "Paramètre non défini"
-#: application/Utils.php:390
+#: application/Utils.php:412
msgid "Unlimited"
msgstr "Illimité"
-#: application/Utils.php:393
+#: application/Utils.php:415
msgid "B"
msgstr "o"
-#: application/Utils.php:393
+#: application/Utils.php:415
msgid "kiB"
msgstr "ko"
-#: application/Utils.php:393
+#: application/Utils.php:415
msgid "MiB"
msgstr "Mo"
-#: application/Utils.php:393
+#: application/Utils.php:415
msgid "GiB"
msgstr "Go"
-#: application/bookmark/BookmarkFileService.php:180
-#: application/bookmark/BookmarkFileService.php:202
-#: application/bookmark/BookmarkFileService.php:224
-#: application/bookmark/BookmarkFileService.php:238
+#: application/bookmark/BookmarkFileService.php:185
+#: application/bookmark/BookmarkFileService.php:207
+#: application/bookmark/BookmarkFileService.php:229
+#: application/bookmark/BookmarkFileService.php:243
msgid "You're not authorized to alter the datastore"
msgstr "Vous n'êtes pas autorisé à modifier les données"
-#: application/bookmark/BookmarkFileService.php:205
+#: application/bookmark/BookmarkFileService.php:210
msgid "This bookmarks already exists"
-msgstr "Ce marque-page existe déjà."
+msgstr "Ce marque-page existe déjà"
-#: application/bookmark/BookmarkInitializer.php:39
+#: application/bookmark/BookmarkInitializer.php:42
msgid "(private bookmark with thumbnail demo)"
msgstr "(marque page privé avec une miniature)"
-#: application/bookmark/BookmarkInitializer.php:42
+#: application/bookmark/BookmarkInitializer.php:45
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:55
+#: application/bookmark/BookmarkInitializer.php:58
msgid "Note: Shaare descriptions"
msgstr "Note : Description des Shaares"
-#: application/bookmark/BookmarkInitializer.php:57
+#: application/bookmark/BookmarkInitializer.php:60
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:91
+#: application/bookmark/BookmarkInitializer.php:94
#: 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:94
+#: application/bookmark/BookmarkInitializer.php:97
msgid ""
"Welcome to Shaarli!\n"
"\n"
"issues) si vous avez une suggestion ou si vous rencontrez un problème.\n"
" \n"
-#: application/bookmark/exception/BookmarkNotFoundException.php:13
+#: application/bookmark/exception/BookmarkNotFoundException.php:14
msgid "The link you are trying to reach does not exist or has been deleted."
msgstr "Le lien que vous essayez de consulter n'existe pas ou a été supprimé."
-#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:129
+#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:131
msgid ""
"Shaarli could not create the config file. Please make sure Shaarli has the "
"right to write in the folder is it installed in."
"Shaarli n'a pas pu créer le fichier de configuration. Merci de vérifier que "
"Shaarli a les droits d'écriture dans le dossier dans lequel il est installé."
-#: application/config/ConfigManager.php:136
-#: application/config/ConfigManager.php:163
+#: application/config/ConfigManager.php:137
+#: application/config/ConfigManager.php:164
msgid "Invalid setting key parameter. String expected, got: "
msgstr "Clé de paramétrage invalide. Chaîne de caractères obtenue, attendu : "
-#: application/config/exception/MissingFieldConfigException.php:21
+#: application/config/exception/MissingFieldConfigException.php:20
#, php-format
msgid "Configuration value is required for %s"
msgstr "Le paramètre %s est obligatoire"
msgstr ""
"Une erreur s'est produite lors de la sauvegarde de l'ordre des extensions."
-#: application/config/exception/UnauthorizedConfigException.php:16
+#: application/config/exception/UnauthorizedConfigException.php:15
msgid "You are not authorized to alter config."
msgstr "Vous n'êtes pas autorisé à modifier la configuration."
-#: application/exceptions/IOException.php:22
+#: application/exceptions/IOException.php:23
msgid "Error accessing"
msgstr "Une erreur s'est produite en accédant à"
-#: application/feed/FeedBuilder.php:179
+#: application/feed/FeedBuilder.php:180
msgid "Direct link"
msgstr "Liens directs"
-#: application/feed/FeedBuilder.php:181
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
+#: application/feed/FeedBuilder.php:182
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
msgid "Permalink"
msgstr "Permalien"
-#: application/front/controller/admin/ConfigureController.php:54
+#: application/front/controller/admin/ConfigureController.php:56
#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
msgid "Configure"
msgstr "Configurer"
-#: application/front/controller/admin/ConfigureController.php:102
-#: application/legacy/LegacyUpdater.php:537
+#: application/front/controller/admin/ConfigureController.php:106
+#: application/legacy/LegacyUpdater.php:539
msgid "You have enabled or changed thumbnails mode."
msgstr "Vous avez activé ou changé le mode de miniatures."
-#: application/front/controller/admin/ConfigureController.php:103
-#: application/legacy/LegacyUpdater.php:538
+#: application/front/controller/admin/ConfigureController.php:108
+#: application/front/controller/admin/ServerController.php:76
+#: application/legacy/LegacyUpdater.php:540
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/admin/ConfigureController.php:119
+#: application/front/controller/visitor/InstallController.php:149
msgid "Error while writing config file after configuration update."
msgstr ""
"Une erreur s'est produite lors de la sauvegarde du fichier de configuration."
-#: application/front/controller/admin/ConfigureController.php:122
+#: application/front/controller/admin/ConfigureController.php:128
msgid "Configuration was saved."
msgstr "La configuration a été sauvegardée."
"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/ManageTagController.php:30
+msgid "whitespace"
+msgstr "espace"
-#: 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/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"
"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:85
+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:173
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+msgid "Edit"
+msgstr "Modifier"
+
+#: application/front/controller/admin/ShaarePublishController.php:176
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
+msgid "Shaare"
+msgstr "Shaare"
+
+#: application/front/controller/admin/ShaarePublishController.php:208
+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:116
+#: application/front/controller/visitor/BookmarkListController.php:121
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/ErrorNotFoundController.php:25
msgid "Requested page could not be found."
-msgstr ""
+msgstr "La page demandée n'a pas pu être trouvée."
+
+#: application/front/controller/visitor/InstallController.php:65
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
+msgid "Install Shaarli"
+msgstr "Installation de Shaarli"
-#: application/front/controller/visitor/InstallController.php:73
+#: application/front/controller/visitor/InstallController.php:85
#, 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:157
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:171
msgid "Insufficient permissions:"
msgstr "Permissions insuffisantes :"
msgid "Picture wall"
msgstr "Mur d'images"
-#: application/front/controller/visitor/TagCloudController.php:88
+#: application/front/controller/visitor/TagCloudController.php:90
msgid "Tag "
-msgstr "Tag"
+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:165
+#, 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:200
+#: application/helper/ApplicationUtils.php:220
+msgid "directory is not readable"
+msgstr "le répertoire n'est pas accessible en lecture"
+
+#: application/helper/ApplicationUtils.php:223
+msgid "directory is not writable"
+msgstr "le répertoire n'est pas accessible en écriture"
+
+#: application/helper/ApplicationUtils.php:247
+msgid "file is not readable"
+msgstr "le fichier n'est pas accessible en lecture"
+
+#: application/helper/ApplicationUtils.php:250
+msgid "file is not writable"
+msgstr "le fichier n'est pas accessible en écriture"
+
+#: application/helper/ApplicationUtils.php:260
+msgid ""
+"Lock can not be acquired on the datastore. You might encounter concurrent "
+"access issues."
+msgstr ""
+"Le fichier datastore ne peut pas être verrouillé. Vous pourriez rencontrer "
+"des problèmes d'accès concurrents."
+
+#: application/helper/ApplicationUtils.php:293
+msgid "Configuration parsing"
+msgstr "Chargement de la configuration"
+
+#: application/helper/ApplicationUtils.php:294
+msgid "Slim Framework (routing, etc.)"
+msgstr "Slim Framwork (routage, etc.)"
+
+#: application/helper/ApplicationUtils.php:295
+msgid "Multibyte (Unicode) string support"
+msgstr "Support des chaînes de caractère multibytes (Unicode)"
+
+#: application/helper/ApplicationUtils.php:296
+msgid "Required to use thumbnails"
+msgstr "Obligatoire pour utiliser les miniatures"
+
+#: application/helper/ApplicationUtils.php:297
+msgid "Localized text sorting (e.g. e->è->f)"
+msgstr "Tri des textes traduits (ex : e->è->f)"
+
+#: application/helper/ApplicationUtils.php:298
+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:299
+msgid "Use the translation system in gettext mode"
+msgstr "Utiliser le système de traduction en mode gettext"
+
+#: application/helper/ApplicationUtils.php:300
+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."
msgid "Couldn't retrieve updater class methods."
msgstr "Impossible de récupérer les méthodes de la classe Updater."
-#: application/legacy/LegacyUpdater.php:538
+#: application/legacy/LegacyUpdater.php:540
msgid "<a href=\"./admin/thumbnails\">"
msgstr "<a href=\"./admin/thumbnails\">"
"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:124
+#: application/plugin/PluginManager.php:125
msgid " [plugin incompatibility]: "
msgstr " [incompatibilité de l'extension] : "
-#: application/plugin/exception/PluginFileNotFoundException.php:21
+#: application/plugin/exception/PluginFileNotFoundException.php:22
#, php-format
msgid "Plugin \"%s\" files not found."
msgstr "Les fichiers de l'extension \"%s\" sont introuvables."
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:65
+#: index.php:81
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:28
+#: plugins/archiveorg/archiveorg.php:29
msgid "View on archive.org"
msgstr "Voir sur archive.org"
-#: plugins/archiveorg/archiveorg.php:41
+#: plugins/archiveorg/archiveorg.php:42
msgid "For each link, add an Archive.org icon."
msgstr "Pour chaque lien, ajoute une icône pour Archive.org."
msgid "Dark main color (e.g. visited links)"
msgstr "Couleur principale sombre (ex : les liens visités)"
-#: plugins/demo_plugin/demo_plugin.php:477
+#: plugins/demo_plugin/demo_plugin.php:478
msgid ""
"A demo plugin covering all use cases for template designers and plugin "
"developers."
"Une extension de démonstration couvrant tous les cas d'utilisation pour les "
"designers de thèmes et les développeurs d'extensions."
-#: plugins/demo_plugin/demo_plugin.php:478
+#: plugins/demo_plugin/demo_plugin.php:479
msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
msgstr "Ceci est un paramètre dédié au plugin de démo. Il sera suffixé."
-#: plugins/demo_plugin/demo_plugin.php:479
+#: plugins/demo_plugin/demo_plugin.php:480
msgid "Other demo parameter"
msgstr "Un autre paramètre de démo"
msgid "Isso server URL (without 'http://')"
msgstr "URL du serveur Isso (sans 'http://')"
-#: plugins/piwik/piwik.php:23
+#: plugins/piwik/piwik.php:24
msgid ""
"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
"administration page."
"Erreur de l'extension Piwik : Merci de définir les paramètres PIWIK_URL et "
"PIWIK_SITEID dans la page d'administration des extensions."
-#: plugins/piwik/piwik.php:72
+#: plugins/piwik/piwik.php:73
msgid "A plugin that adds Piwik tracking code to Shaarli pages."
msgstr "Ajoute le code de traçage de Piwik sur les pages de Shaarli."
-#: plugins/piwik/piwik.php:73
+#: plugins/piwik/piwik.php:74
msgid "Piwik URL"
msgstr "URL de Piwik"
-#: plugins/piwik/piwik.php:74
+#: plugins/piwik/piwik.php:75
msgid "Piwik site ID"
msgstr "Site ID de Piwik"
-#: plugins/playvideos/playvideos.php:25
+#: plugins/playvideos/playvideos.php:26
msgid "Video player"
msgstr "Lecteur vidéo"
-#: plugins/playvideos/playvideos.php:28
+#: plugins/playvideos/playvideos.php:29
msgid "Play Videos"
msgstr "Jouer les vidéos"
-#: plugins/playvideos/playvideos.php:59
+#: plugins/playvideos/playvideos.php:60
msgid "Add a button in the toolbar allowing to watch all videos."
msgstr ""
"Ajoute un bouton dans la barre de menu pour regarder toutes les vidéos."
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:74 plugins/wallabag/wallabag.php:72
msgid "For each link, add a QRCode icon."
msgstr "Pour chaque lien, ajouter une icône de QRCode."
-#: plugins/wallabag/wallabag.php:21
+#: plugins/wallabag/wallabag.php:22
msgid ""
"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the "
"plugin administration page."
"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:49
msgid "Save to wallabag"
msgstr "Sauvegarder dans Wallabag"
-#: plugins/wallabag/wallabag.php:71
+#: plugins/wallabag/wallabag.php:73
msgid "Wallabag API URL"
msgstr "URL de l'API Wallabag"
-#: plugins/wallabag/wallabag.php:72
+#: plugins/wallabag/wallabag.php:74
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"
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:93
+#: 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
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 ?"
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 "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 "Provided data is invalid"
-#~ msgstr "Les informations fournies ne sont pas valides"
+#~ msgid "Display:"
+#~ msgstr "Afficher :"
-#~ msgid "Rename"
-#~ msgstr "Renommer"
+#~ msgid "The Daily Shaarli"
+#~ msgstr "Le Quotidien Shaarli"
#, fuzzy
#~| msgid "Selection"
--- /dev/null
+msgid ""
+msgstr ""
+"Project-Id-Version: Shaarli\n"
+"POT-Creation-Date: 2020-11-14 07:47+0500\n"
+"PO-Revision-Date: 2020-11-15 06:16+0500\n"
+"Last-Translator: progit <pash.vld@gmail.com>\n"
+"Language-Team: Shaarli\n"
+"Language: ru_RU\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 2.0.1\n"
+"X-Poedit-Basepath: ../../../..\n"
+"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
+"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
+"X-Poedit-SourceCharset: UTF-8\n"
+"X-Poedit-KeywordsList: t:1,2;t\n"
+"X-Poedit-SearchPath-0: application\n"
+"X-Poedit-SearchPath-1: tmp\n"
+"X-Poedit-SearchPath-2: index.php\n"
+"X-Poedit-SearchPath-3: init.php\n"
+"X-Poedit-SearchPath-4: plugins\n"
+
+#: application/History.php:181
+msgid "History file isn't readable or writable"
+msgstr "Файл истории не доступен для чтения или записи"
+
+#: application/History.php:192
+msgid "Could not parse history file"
+msgstr "Не удалось разобрать файл истории"
+
+#: application/Languages.php:184
+msgid "Automatic"
+msgstr "Автоматический"
+
+#: application/Languages.php:185
+msgid "German"
+msgstr "Немецкий"
+
+#: application/Languages.php:186
+msgid "English"
+msgstr "Английский"
+
+#: application/Languages.php:187
+msgid "French"
+msgstr "Французский"
+
+#: application/Languages.php:188
+msgid "Japanese"
+msgstr "Японский"
+
+#: application/Languages.php:189
+msgid "Russian"
+msgstr "Русский"
+
+#: application/Thumbnailer.php:62
+msgid ""
+"php-gd extension must be loaded to use thumbnails. Thumbnails are now "
+"disabled. Please reload the page."
+msgstr ""
+"для использования миниатюр необходимо загрузить расширение php-gd. Миниатюры "
+"сейчас отключены. Перезагрузите страницу."
+
+#: application/Utils.php:405
+msgid "Setting not set"
+msgstr "Настройка не задана"
+
+#: application/Utils.php:412
+msgid "Unlimited"
+msgstr "Неограниченно"
+
+#: application/Utils.php:415
+msgid "B"
+msgstr "Б"
+
+#: application/Utils.php:415
+msgid "kiB"
+msgstr "КБ"
+
+#: application/Utils.php:415
+msgid "MiB"
+msgstr "МБ"
+
+#: application/Utils.php:415
+msgid "GiB"
+msgstr "ГБ"
+
+#: application/bookmark/BookmarkFileService.php:185
+#: application/bookmark/BookmarkFileService.php:207
+#: application/bookmark/BookmarkFileService.php:229
+#: application/bookmark/BookmarkFileService.php:243
+msgid "You're not authorized to alter the datastore"
+msgstr "У вас нет прав на изменение хранилища данных"
+
+#: application/bookmark/BookmarkFileService.php:210
+msgid "This bookmarks already exists"
+msgstr "Эта закладка уже существует"
+
+#: application/bookmark/BookmarkInitializer.php:42
+msgid "(private bookmark with thumbnail demo)"
+msgstr "(личная закладка с показом миниатюр)"
+
+#: application/bookmark/BookmarkInitializer.php:45
+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) или "
+"[документацию](https://shaarli.readthedocs.io/en/master/),чтобы узнать "
+"больше о Shaarli.\n"
+"\n"
+"Теперь вы можете редактировать или удалять шаары по умолчанию.\n"
+
+#: application/bookmark/BookmarkInitializer.php:58
+msgid "Note: Shaare descriptions"
+msgstr "Примечание: описания Шаар"
+
+#: application/bookmark/BookmarkInitializer.php:60
+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 адреса создается текстовая \"заметка"
+"\", такая как эта.\n"
+"Эта заметка является личной, поэтому вы единственный, кто может ее увидеть, "
+"находясь в системе.\n"
+"\n"
+"Вы можете использовать это для хранения заметок, публикации статей, "
+"фрагментов кода и многого другого.\n"
+"\n"
+"Параметр форматирования Markdown позволяет форматировать заметки и описание "
+"закладок:\n"
+"\n"
+"### Заголовок заголовков\n"
+"\n"
+"#### Multiple headings levels\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:94
+#: application/legacy/LegacyLinkDB.php:246
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
+msgid ""
+"The personal, minimalist, super-fast, database free, bookmarking service"
+msgstr "Личный, минималистичный, сверхбыстрый сервис закладок без баз данных"
+
+#: application/bookmark/BookmarkInitializer.php:97
+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"
+"Создайте новую закладку, нажав кнопку `+Поделиться`, или используя любой из "
+"рекомендуемых инструментов (расширение для браузера, мобильное приложение, "
+"букмарклет, REST API и т.д.).\n"
+"\n"
+"Вы можете легко получить свои ссылки, даже если их тысячи, с помощью "
+"внутренней поисковой системы или поиска по тегам (например, эта заметка "
+"помечена тегами `shaarli` and `help`).\n"
+"Также поддерживаются хэштеги, такие как #shaarli #help.\n"
+"Вы можете также фильтровать доступный [RSS канал](/feed/atom) и галерею по "
+"тегу или по поиску текста.\n"
+"\n"
+"Мы надеемся, что вам понравится использовать Shaarli, с ❤️ поддерживаемый "
+"сообществом!\n"
+"Не стесняйтесь открывать [запрос](https://github.com/shaarli/Shaarli/"
+"issues), если у вас есть предложение или возникла проблема.\n"
+
+#: application/bookmark/exception/BookmarkNotFoundException.php:14
+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:131
+msgid ""
+"Shaarli could not create the config file. Please make sure Shaarli has the "
+"right to write in the folder is it installed in."
+msgstr ""
+"Shaarli не удалось создать файл конфигурации. Убедитесь, что у Shaarli есть "
+"право на запись в папку, в которой он установлен."
+
+#: application/config/ConfigManager.php:137
+#: application/config/ConfigManager.php:164
+msgid "Invalid setting key parameter. String expected, got: "
+msgstr "Неверная настройка ключевого параметра. Ожидалась строка, получено: "
+
+#: application/config/exception/MissingFieldConfigException.php:20
+#, php-format
+msgid "Configuration value is required for %s"
+msgstr "Значение конфигурации требуется для %s"
+
+#: application/config/exception/PluginConfigOrderException.php:15
+msgid "An error occurred while trying to save plugins loading order."
+msgstr "Произошла ошибка при попытке сохранить порядок загрузки плагинов."
+
+#: application/config/exception/UnauthorizedConfigException.php:15
+msgid "You are not authorized to alter config."
+msgstr "Вы не авторизованы для изменения конфигурации."
+
+#: application/exceptions/IOException.php:23
+msgid "Error accessing"
+msgstr "Ошибка доступа"
+
+#: application/feed/FeedBuilder.php:180
+msgid "Direct link"
+msgstr "Прямая ссылка"
+
+#: application/feed/FeedBuilder.php:182
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
+msgid "Permalink"
+msgstr "Постоянная ссылка"
+
+#: application/front/controller/admin/ConfigureController.php:56
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+msgid "Configure"
+msgstr "Настройка"
+
+#: application/front/controller/admin/ConfigureController.php:106
+#: application/legacy/LegacyUpdater.php:539
+msgid "You have enabled or changed thumbnails mode."
+msgstr "Вы включили или изменили режим миниатюр."
+
+#: application/front/controller/admin/ConfigureController.php:108
+#: application/front/controller/admin/ServerController.php:76
+#: application/legacy/LegacyUpdater.php:540
+msgid "Please synchronize them."
+msgstr "Пожалуйста, синхронизируйте их."
+
+#: application/front/controller/admin/ConfigureController.php:119
+#: application/front/controller/visitor/InstallController.php:149
+msgid "Error while writing config file after configuration update."
+msgstr "Ошибка при записи файла конфигурации после обновления конфигурации."
+
+#: application/front/controller/admin/ConfigureController.php:128
+msgid "Configuration was saved."
+msgstr "Конфигурация сохранена."
+
+#: application/front/controller/admin/ExportController.php:26
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
+msgid "Export"
+msgstr "Экспорт"
+
+#: application/front/controller/admin/ExportController.php:42
+msgid "Please select an export mode."
+msgstr "Выберите режим экспорта."
+
+#: application/front/controller/admin/ImportController.php:41
+#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+msgid "Import"
+msgstr "Импорт"
+
+#: 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 ""
+"Файл, который вы пытаетесь загрузить, вероятно, больше, чем может принять "
+"этот сервер (%s). Пожалуйста, загружайте небольшими частями."
+
+#: application/front/controller/admin/ManageTagController.php:30
+msgid "whitespace"
+msgstr "пробел"
+
+#: application/front/controller/admin/ManageTagController.php:35
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+msgid "Manage tags"
+msgstr "Управление тегами"
+
+#: application/front/controller/admin/ManageTagController.php:54
+msgid "Invalid tags provided."
+msgstr "Предоставлены недействительные теги."
+
+#: 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] "Тег был удален из %d закладки."
+msgstr[1] "Тег был удален из %d закладок."
+msgstr[2] "Тег был удален из %d закладок."
+
+#: 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] "Тег был переименован в %d закладке."
+msgstr[1] "Тег был переименован в %d закладках."
+msgstr[2] "Тег был переименован в %d закладках."
+
+#: application/front/controller/admin/ManageTagController.php:105
+msgid "Tags separator must be a single character."
+msgstr "Разделитель тегов должен состоять из одного символа."
+
+#: application/front/controller/admin/ManageTagController.php:111
+msgid "These characters are reserved and can't be used as tags separator: "
+msgstr ""
+"Эти символы зарезервированы и не могут использоваться в качестве разделителя "
+"тегов: "
+
+#: application/front/controller/admin/PasswordController.php:28
+#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+msgid "Change password"
+msgstr "Изменить пароль"
+
+#: application/front/controller/admin/PasswordController.php:55
+msgid "You must provide the current and new password to change it."
+msgstr "Вы должны предоставить текущий и новый пароль, чтобы изменить его."
+
+#: application/front/controller/admin/PasswordController.php:71
+msgid "The old password is not correct."
+msgstr "Старый пароль неверен."
+
+#: application/front/controller/admin/PasswordController.php:97
+msgid "Your password has been changed"
+msgstr "Пароль изменен"
+
+#: application/front/controller/admin/PluginsController.php:45
+msgid "Plugin Administration"
+msgstr "Управление плагинами"
+
+#: application/front/controller/admin/PluginsController.php:76
+msgid "Setting successfully saved."
+msgstr "Настройка успешно сохранена."
+
+#: application/front/controller/admin/PluginsController.php:79
+msgid "Error while saving plugin configuration: "
+msgstr "Ошибка при сохранении конфигурации плагина: "
+
+#: application/front/controller/admin/ServerController.php:35
+msgid "Check disabled"
+msgstr "Проверка отключена"
+
+#: application/front/controller/admin/ServerController.php:57
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Server administration"
+msgstr "Администрирование сервера"
+
+#: application/front/controller/admin/ServerController.php:74
+msgid "Thumbnails cache has been cleared."
+msgstr "Кэш миниатюр очищен."
+
+#: application/front/controller/admin/ServerController.php:85
+msgid "Shaarli's cache folder has been cleared!"
+msgstr "Папка с кэшем Shaarli очищена!"
+
+#: application/front/controller/admin/ShaareAddController.php:26
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+msgid "Shaare a new link"
+msgstr "Поделиться новой ссылкой"
+
+#: application/front/controller/admin/ShaareManageController.php:35
+#: application/front/controller/admin/ShaareManageController.php:93
+msgid "Invalid bookmark ID provided."
+msgstr "Указан неверный идентификатор закладки."
+
+#: 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 "Закладка с идентификатором %s не найдена."
+
+#: application/front/controller/admin/ShaareManageController.php:101
+msgid "Invalid visibility provided."
+msgstr "Предоставлена недопустимая видимость."
+
+#: application/front/controller/admin/ShaarePublishController.php:173
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+msgid "Edit"
+msgstr "Редактировать"
+
+#: application/front/controller/admin/ShaarePublishController.php:176
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
+msgid "Shaare"
+msgstr "Поделиться"
+
+#: application/front/controller/admin/ShaarePublishController.php:208
+msgid "Note: "
+msgstr "Заметка: "
+
+#: application/front/controller/admin/ThumbnailsController.php:37
+#: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Thumbnails update"
+msgstr "Обновление миниатюр"
+
+#: application/front/controller/admin/ToolsController.php:31
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:33
+msgid "Tools"
+msgstr "Инструменты"
+
+#: application/front/controller/visitor/BookmarkListController.php:121
+msgid "Search: "
+msgstr "Поиск: "
+
+#: application/front/controller/visitor/DailyController.php:200
+msgid "day"
+msgstr "день"
+
+#: 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 "За день"
+
+#: application/front/controller/visitor/DailyController.php:201
+msgid "week"
+msgstr "неделя"
+
+#: application/front/controller/visitor/DailyController.php:201
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Weekly"
+msgstr "За неделю"
+
+#: application/front/controller/visitor/DailyController.php:202
+msgid "month"
+msgstr "месяц"
+
+#: application/front/controller/visitor/DailyController.php:202
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "Monthly"
+msgstr "За месяц"
+
+#: application/front/controller/visitor/ErrorController.php:30
+msgid "Error: "
+msgstr "Ошибка: "
+
+#: application/front/controller/visitor/ErrorController.php:34
+msgid "Please report it on Github."
+msgstr "Пожалуйста, сообщите об этом на Github."
+
+#: application/front/controller/visitor/ErrorController.php:39
+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:65
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
+msgid "Install Shaarli"
+msgstr "Установить Shaarli"
+
+#: application/front/controller/visitor/InstallController.php:85
+#, php-format
+msgid ""
+"<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>"
+msgstr ""
+"<pre>Сессии на вашем сервере работают некорректно.<br>Убедитесь, что "
+"переменная \"session.save_path\" правильно установлена в вашей конфигурации "
+"PHP и что у вас есть доступ к ней на запись.<br>В настоящее время она "
+"указывает на %s.<br>В некоторых браузерах доступ к вашему серверу через имя "
+"хоста, например localhost или любое другое имя хоста без точки, приводит к "
+"сбою хранилища файлов cookie. Мы рекомендуем получить доступ к вашему "
+"серверу через его IP адрес или полное доменное имя.<br>"
+
+#: application/front/controller/visitor/InstallController.php:157
+msgid ""
+"Shaarli is now configured. Please login and start shaaring your bookmarks!"
+msgstr "Shaarli настроен. Войдите и начните делиться своими закладками!"
+
+#: application/front/controller/visitor/InstallController.php:171
+msgid "Insufficient permissions:"
+msgstr "Недостаточно разрешений:"
+
+#: application/front/controller/visitor/LoginController.php:46
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:77
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:101
+msgid "Login"
+msgstr "Вход"
+
+#: application/front/controller/visitor/LoginController.php:78
+msgid "Wrong login/password."
+msgstr "Неверный логин или пароль."
+
+#: application/front/controller/visitor/PictureWallController.php:29
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:43
+msgid "Picture wall"
+msgstr "Галерея"
+
+#: application/front/controller/visitor/TagCloudController.php:90
+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 "Вы не должны менять пароль на Open 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/helper/ApplicationUtils.php:163
+#, 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 ""
+"Ваша версия PHP устарела! Shaarli требует как минимум PHP %s, и поэтому не "
+"может работать. В вашей версии PHP есть известные уязвимости в системе "
+"безопасности, и ее следует обновить как можно скорее."
+
+#: application/helper/ApplicationUtils.php:198
+#: application/helper/ApplicationUtils.php:218
+msgid "directory is not readable"
+msgstr "папка не доступна для чтения"
+
+#: application/helper/ApplicationUtils.php:221
+msgid "directory is not writable"
+msgstr "папка не доступна для записи"
+
+#: application/helper/ApplicationUtils.php:245
+msgid "file is not readable"
+msgstr "файл не доступен для чтения"
+
+#: application/helper/ApplicationUtils.php:248
+msgid "file is not writable"
+msgstr "файл не доступен для записи"
+
+#: application/helper/ApplicationUtils.php:282
+msgid "Configuration parsing"
+msgstr "Разбор конфигурации"
+
+#: application/helper/ApplicationUtils.php:283
+msgid "Slim Framework (routing, etc.)"
+msgstr "Slim Framework (маршрутизация и т. д.)"
+
+#: application/helper/ApplicationUtils.php:284
+msgid "Multibyte (Unicode) string support"
+msgstr "Поддержка многобайтовых (Unicode) строк"
+
+#: application/helper/ApplicationUtils.php:285
+msgid "Required to use thumbnails"
+msgstr "Обязательно использование миниатюр"
+
+#: application/helper/ApplicationUtils.php:286
+msgid "Localized text sorting (e.g. e->è->f)"
+msgstr "Локализованная сортировка текста (например, e->è->f)"
+
+#: application/helper/ApplicationUtils.php:287
+msgid "Better retrieval of bookmark metadata and thumbnail"
+msgstr "Лучшее получение метаданных закладок и миниатюр"
+
+#: application/helper/ApplicationUtils.php:288
+msgid "Use the translation system in gettext mode"
+msgstr "Используйте систему перевода в режиме gettext"
+
+#: application/helper/ApplicationUtils.php:289
+msgid "Login using LDAP server"
+msgstr "Вход через LDAP сервер"
+
+#: application/helper/DailyPageHelper.php:172
+msgid "Week"
+msgstr "Неделя"
+
+#: application/helper/DailyPageHelper.php:176
+msgid "Today"
+msgstr "Сегодня"
+
+#: application/helper/DailyPageHelper.php:178
+msgid "Yesterday"
+msgstr "Вчера"
+
+#: application/helper/FileUtils.php:100
+msgid "Provided path is not a directory."
+msgstr "Указанный путь не является папкой."
+
+#: application/helper/FileUtils.php:104
+msgid "Trying to delete a folder outside of Shaarli path."
+msgstr "Попытка удалить папку за пределами пути Shaarli."
+
+#: 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 "Внутренняя ошибка: ссылка всегда должна иметь идентификатор и 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 "Смещение массива и идентификатор ссылки должны быть одинаковыми."
+
+#: 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"
+"Вы используете поддерживаемую сообществом версию оригинального проекта "
+"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
+msgid "Couldn't retrieve updater class methods."
+msgstr "Не удалось получить методы класса средства обновления."
+
+#: application/legacy/LegacyUpdater.php:540
+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
+#, php-format
+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:125
+msgid " [plugin incompatibility]: "
+msgstr " [несовместимость плагинов]: "
+
+#: application/plugin/exception/PluginFileNotFoundException.php:22
+#, 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:81
+msgid "Shared bookmarks on "
+msgstr "Общие закладки на "
+
+#: plugins/addlink_toolbar/addlink_toolbar.php:31
+msgid "URI"
+msgstr "URI"
+
+#: plugins/addlink_toolbar/addlink_toolbar.php:35
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+msgid "Add link"
+msgstr "Добавить ссылку"
+
+#: plugins/addlink_toolbar/addlink_toolbar.php:52
+msgid "Adds the addlink input on the linklist page."
+msgstr ""
+"Добавляет на страницу списка ссылок поле для добавления новой закладки."
+
+#: plugins/archiveorg/archiveorg.php:29
+msgid "View on archive.org"
+msgstr "Посмотреть на archive.org"
+
+#: plugins/archiveorg/archiveorg.php:42
+msgid "For each link, add an Archive.org icon."
+msgstr "Для каждой ссылки добавить значок с Archive.org."
+
+#: 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:478
+msgid ""
+"A demo plugin covering all use cases for template designers and plugin "
+"developers."
+msgstr ""
+"Демо плагин, охватывающий все варианты использования для дизайнеров шаблонов "
+"и разработчиков плагинов."
+
+#: plugins/demo_plugin/demo_plugin.php:479
+msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
+msgstr ""
+"Это параметр предназначен для демонстрационного плагина. Это будет суффикс."
+
+#: plugins/demo_plugin/demo_plugin.php:480
+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."
+msgstr ""
+"Ошибка плагина Isso: определите параметр \"ISSO_SERVER\" на странице "
+"настройки плагина."
+
+#: plugins/isso/isso.php:92
+msgid "Let visitor comment your shaares on permalinks with Isso."
+msgstr ""
+"Позволить посетителю комментировать ваши закладки по постоянным ссылкам с "
+"Isso."
+
+#: plugins/isso/isso.php:93
+msgid "Isso server URL (without 'http://')"
+msgstr "URL сервера Isso (без 'http: //')"
+
+#: plugins/piwik/piwik.php:24
+msgid ""
+"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
+"administration page."
+msgstr ""
+"Ошибка плагина Piwik: укажите PIWIK_URL и PIWIK_SITEID на странице настройки "
+"плагина."
+
+#: plugins/piwik/piwik.php:73
+msgid "A plugin that adds Piwik tracking code to Shaarli pages."
+msgstr "Плагин, который добавляет код отслеживания Piwik на страницы Shaarli."
+
+#: plugins/piwik/piwik.php:74
+msgid "Piwik URL"
+msgstr "Piwik URL"
+
+#: plugins/piwik/piwik.php:75
+msgid "Piwik site ID"
+msgstr "Piwik site ID"
+
+#: plugins/playvideos/playvideos.php:26
+msgid "Video player"
+msgstr "Видео плеер"
+
+#: plugins/playvideos/playvideos.php:29
+msgid "Play Videos"
+msgstr "Воспроизвести видео"
+
+#: plugins/playvideos/playvideos.php:60
+msgid "Add a button in the toolbar allowing to watch all videos."
+msgstr ""
+"Добавьте кнопку на панель инструментов, позволяющую смотреть все видео."
+
+#: plugins/playvideos/youtube_playlist.js:214
+msgid "plugins/playvideos/jquery-1.11.2.min.js"
+msgstr "plugins/playvideos/jquery-1.11.2.min.js"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:72
+#, php-format
+msgid "Could not publish to PubSubHubbub: %s"
+msgstr "Не удалось опубликовать в PubSubHubbub: %s"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:99
+#, php-format
+msgid "Could not post to %s"
+msgstr "Не удалось отправить сообщение в %s"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:103
+#, php-format
+msgid "Bad response from the hub %s"
+msgstr "Плохой ответ от хаба %s"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:114
+msgid "Enable PubSubHubbub feed publishing."
+msgstr "Включить публикацию канала PubSubHubbub."
+
+#: plugins/qrcode/qrcode.php:74 plugins/wallabag/wallabag.php:72
+msgid "For each link, add a QRCode icon."
+msgstr "Для каждой ссылки добавить значок QR кода."
+
+#: plugins/wallabag/wallabag.php:22
+msgid ""
+"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the "
+"plugin administration page."
+msgstr ""
+"Ошибка плагина Wallabag: определите параметр \"WALLABAG_URL\" на странице "
+"настройки плагина."
+
+#: plugins/wallabag/wallabag.php:49
+msgid "Save to wallabag"
+msgstr "Сохранить в wallabag"
+
+#: plugins/wallabag/wallabag.php:73
+msgid "Wallabag API URL"
+msgstr "Wallabag API URL"
+
+#: plugins/wallabag/wallabag.php:74
+msgid "Wallabag API version (1 or 2)"
+msgstr "Wallabag версия API (1 или 2)"
+
+#: 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/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+msgid "BULK CREATION"
+msgstr "МАССОВОЕ СОЗДАНИЕ"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+msgid "Metadata asynchronous retrieval is disabled."
+msgstr "Асинхронное получение метаданных отключено."
+
+#: 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 ""
+"Мы рекомендуем включить параметр <em>general > enable_async_metadata</em> в "
+"вашем файле конфигурации, чтобы использовать массовое создание ссылок."
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+msgid "Shaare multiple new links"
+msgstr "Поделиться несколькими новыми ссылками"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59
+msgid "Add one URL per line to create multiple bookmarks."
+msgstr "Добавьте по одному URL в строке, чтобы создать несколько закладок."
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
+msgid "Tags"
+msgstr "Теги"
+
+#: 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 "Личный"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
+msgid "Add links"
+msgstr "Добавить ссылки"
+
+#: 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
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+msgid "Rename tag"
+msgstr "Переименовать тег"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+msgid "Delete tag"
+msgstr "Удалить тег"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+msgid "You can also edit tags in the"
+msgstr "Вы также можете редактировать теги в"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+msgid "tag list"
+msgstr "список тегов"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+msgid "Change tags separator"
+msgstr "Изменить разделитель тегов"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
+msgid "Your current tag separator is"
+msgstr "Текущий разделитель тегов"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
+msgid "New separator"
+msgstr "Новый разделитель"
+
+#: 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 "Сохранить"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61
+msgid "Note that hashtags won't fully work with a non-whitespace separator."
+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:85
+msgid "Description formatter"
+msgstr "Средство форматирования описания"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
+msgid "Language"
+msgstr "Язык"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
+msgid "Timezone"
+msgstr "Часовой пояс"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+msgid "Continent"
+msgstr "Континент"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+msgid "City"
+msgstr "Город"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:191
+msgid "Disable session cookie hijacking protection"
+msgstr "Отключить защиту от перехвата файлов сеанса cookie"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:193
+msgid "Check this if you get disconnected or if your IP address changes often"
+msgstr "Проверьте это, если вы отключаетесь или ваш IP адрес часто меняется"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:210
+msgid "Private links by default"
+msgstr "Приватные ссылки по умолчанию"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:211
+msgid "All new links are private by default"
+msgstr "Все новые ссылки по умолчанию являются приватными"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:226
+msgid "RSS direct links"
+msgstr "RSS прямые ссылки"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:227
+msgid "Check this to use direct URL instead of permalink in feeds"
+msgstr ""
+"Установите этот флажок, чтобы использовать прямой URL вместо постоянной "
+"ссылки в фидах"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:242
+msgid "Hide public links"
+msgstr "Скрыть общедоступные ссылки"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:243
+msgid "Do not show any links if the user is not logged in"
+msgstr "Не показывать ссылки, если пользователь не авторизован"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:258
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:149
+msgid "Check updates"
+msgstr "Проверить обновления"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:259
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
+msgid "Notify me when a new release is ready"
+msgstr "Оповестить, когда будет готов новый выпуск"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274
+msgid "Automatically retrieve description for new bookmarks"
+msgstr "Автоматически получать описание для новых закладок"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:275
+msgid "Shaarli will try to retrieve the description from meta HTML headers"
+msgstr "Shaarli попытается получить описание из мета заголовков HTML"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:290
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
+msgid "Enable REST API"
+msgstr "Включить REST API"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:291
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
+msgid "Allow third party software to use Shaarli such as mobile application"
+msgstr ""
+"Разрешить стороннему программному обеспечению использовать Shaarli, например "
+"мобильное приложение"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:306
+msgid "API secret"
+msgstr "API ключ"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:320
+msgid "Enable thumbnails"
+msgstr "Включить миниатюры"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:324
+msgid "You need to enable the extension <code>php-gd</code> to use thumbnails."
+msgstr ""
+"Вам необходимо включить расширение <code>php-gd</code> для использования "
+"миниатюр."
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
+msgid "Synchronize thumbnails"
+msgstr "Синхронизировать миниатюры"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+msgid "All"
+msgstr "Все"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
+msgid "Only common media hosts"
+msgstr "Только обычные медиа хосты"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
+msgid "None"
+msgstr "Ничего"
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+msgid "1 RSS entry per :type"
+msgid_plural ""
+msgstr[0] "1 RSS запись для каждого :type"
+msgstr[1] "1 RSS запись для каждого :type"
+msgstr[2] "1 RSS запись для каждого :type"
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+msgid "Previous :type"
+msgid_plural ""
+msgstr[0] "Предыдущий :type"
+msgstr[1] "Предыдущих :type"
+msgstr[2] "Предыдущих :type"
+
+#: 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] "Все ссылки одного :type на одной странице."
+msgstr[1] "Все ссылки одного :type на одной странице."
+msgstr[2] "Все ссылки одного :type на одной странице."
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+msgid "Next :type"
+msgid_plural ""
+msgstr[0] "Следующий :type"
+msgstr[1] "Следующие :type"
+msgstr[2] "Следующие :type"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+msgid "Edit Shaare"
+msgstr "Изменить закладку"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+msgid "New Shaare"
+msgstr "Новая закладка"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
+msgid "Created:"
+msgstr "Создано:"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+msgid "URL"
+msgstr "URL"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+msgid "Title"
+msgstr "Заголовок"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
+#: 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:89
+msgid "Description will be rendered with"
+msgstr "Описание будет отображаться с"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
+msgid "Markdown syntax documentation"
+msgstr "Документация по синтаксису Markdown"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+msgid "Markdown syntax"
+msgstr "Синтаксис Markdown"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:115
+msgid "Cancel"
+msgstr "Отменить"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
+msgid "Apply Changes"
+msgstr "Применить изменения"
+
+#: 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 "Удалить"
+
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+msgid "Save all"
+msgstr "Сохранить все"
+
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+msgid "Export Database"
+msgstr "Экспорт базы данных"
+
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+msgid "Selection"
+msgstr "Выбор"
+
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+msgid "Public"
+msgstr "Общедоступно"
+
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
+msgid "Prepend note permalinks with this Shaarli instance's URL"
+msgstr ""
+"Добавить постоянные ссылки на заметку с URL адресом этого экземпляра Shaarli"
+
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
+msgid "Useful to import bookmarks in a web browser"
+msgstr "Useful to import bookmarks in a web browser"
+
+#: 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:25
+msgid "It looks like it's the first time you run Shaarli. Please configure it."
+msgstr "Похоже, вы впервые запускаете Shaarli. Пожалуйста, настройте его."
+
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:167
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:167
+msgid "Username"
+msgstr "Имя пользователя"
+
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:168
+msgid "Password"
+msgstr "Пароль"
+
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:62
+msgid "Shaarli title"
+msgstr "Заголовок Shaarli"
+
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+msgid "My links"
+msgstr "Мои ссылки"
+
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
+msgid "Install"
+msgstr "Установка"
+
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190
+msgid "Server requirements"
+msgstr "Системные требования"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
+msgid "shaare"
+msgid_plural "shaares"
+msgstr[0] "закладка"
+msgstr[1] "закладки"
+msgstr[2] "закладок"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+msgid "private link"
+msgid_plural "private links"
+msgstr[0] "личная ссылка"
+msgstr[1] "личные ссылки"
+msgstr[2] "личных ссылок"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:123
+msgid "Search text"
+msgstr "Поиск текста"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:130
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
+msgid "Filter by tag"
+msgstr "Фильтровать по тегу"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:87
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:139
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
+msgid "Search"
+msgstr "Поиск"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
+msgid "Nothing found."
+msgstr "Ничего не найдено."
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118
+#, php-format
+msgid "%s result"
+msgid_plural "%s results"
+msgstr[0] "%s результат"
+msgstr[1] "%s результатов"
+msgstr[2] "%s результатов"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
+msgid "for"
+msgstr "для"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129
+msgid "tagged"
+msgstr "отмечено"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:133
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
+msgid "Remove tag"
+msgstr "Удалить тег"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+msgid "with status"
+msgstr "со статусом"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
+msgid "without any tag"
+msgstr "без тега"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:41
+msgid "Fold"
+msgstr "Сложить"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177
+msgid "Edited: "
+msgstr "Отредактировано: "
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
+msgid "permalink"
+msgstr "постоянная ссылка"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
+msgid "Add tag"
+msgstr "Добавить тег"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:185
+msgid "Toggle sticky"
+msgstr "Закрепить / Открепить"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:187
+msgid "Sticky"
+msgstr "Закреплено"
+
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+msgid "Share a private link"
+msgstr "Поделиться личной ссылкой"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5
+msgid "Filters"
+msgstr "Фильтры"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:10
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:10
+msgid "Only display private links"
+msgstr "Отображать только личные ссылки"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:13
+msgid "Only display public links"
+msgstr "Отображать только общедоступные ссылки"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18
+msgid "Filter untagged links"
+msgstr "Фильтровать неотмеченные ссылки"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24
+msgid "Select all"
+msgstr "Выбрать все"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+#: 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:42
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
+msgid "Fold all"
+msgstr "Сложить все"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:76
+msgid "Links per page"
+msgstr "Ссылок на страницу"
+
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:171
+msgid "Remember me"
+msgstr "Запомнить меня"
+
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
+msgid "by the Shaarli community"
+msgstr "сообществом Shaarli"
+
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:16
+msgid "Documentation"
+msgstr "Документация"
+
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
+msgid "Expand"
+msgstr "Развернуть"
+
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
+msgid "Expand all"
+msgstr "Развернуть все"
+
+#: 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 "Вы уверены, что хотите удалить эту ссылку?"
+
+#: 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 "Вы уверены, что хотите удалить этот тег?"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:11
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:11
+msgid "Menu"
+msgstr "Меню"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:38
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+msgid "Tag cloud"
+msgstr "Облако тегов"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:67
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:92
+msgid "RSS Feed"
+msgstr "RSS канал"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:72
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:108
+msgid "Logout"
+msgstr "Выйти"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:152
+msgid "Set public"
+msgstr "Сделать общедоступным"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:157
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:157
+msgid "Set private"
+msgstr "Сделать личным"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:189
+msgid "is available"
+msgstr "доступно"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:196
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:196
+msgid "Error"
+msgstr "Ошибка"
+
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "There is no cached thumbnail."
+msgstr "Нет кэшированных миниатюр."
+
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
+msgid "Try to synchronize them."
+msgstr "Попробуйте синхронизировать их."
+
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Picture Wall"
+msgstr "Галерея"
+
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "pics"
+msgstr "изображений"
+
+#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "You need to enable Javascript to change plugin loading order."
+msgstr ""
+"Вам необходимо включить Javascript, чтобы изменить порядок загрузки плагинов."
+
+#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
+msgid "Plugin administration"
+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/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+msgid "General"
+msgstr "Общее"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+msgid "Index URL"
+msgstr "Индексный URL"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Base path"
+msgstr "Базовый путь"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+msgid "Client IP"
+msgstr "IP клиента"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
+msgid "Trusted reverse proxies"
+msgstr "Надежные обратные прокси"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
+msgid "N/A"
+msgstr "Нет данных"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
+msgid "Visit releases page on Github"
+msgstr "Посетить страницу релизов на Github"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
+msgid "Synchronize all link thumbnails"
+msgstr "Синхронизировать все миниатюры ссылок"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:2
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:2
+msgid "Permissions"
+msgstr "Разрешения"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:8
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:8
+msgid "There are permissions that need to be fixed."
+msgstr "Есть разрешения, которые нужно исправить."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:23
+msgid "All read/write permissions are properly set."
+msgstr "Все разрешения на чтение и запись установлены правильно."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:32
+msgid "Running PHP"
+msgstr "Запуск PHP"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:36
+msgid "End of life: "
+msgstr "Конец жизни: "
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:48
+msgid "Extension"
+msgstr "Расширение"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:49
+msgid "Usage"
+msgstr "Применение"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:50
+msgid "Status"
+msgstr "Статус"
+
+#: 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 "Загружено"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Required"
+msgstr "Обязательно"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Optional"
+msgstr "Необязательно"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:70
+msgid "Not loaded"
+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.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+msgid "Tag list"
+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 "Настройки"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+msgid "Change Shaarli settings: title, timezone, etc."
+msgstr "Измените настройки Shaarli: заголовок, часовой пояс и т.д."
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
+msgid "Configure your Shaarli"
+msgstr "Настройка Shaarli"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
+msgid "Enable, disable and configure plugins"
+msgstr "Включить, отключить и настроить плагины"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
+msgid "Check instance's server configuration"
+msgstr "Проверка конфигурации экземпляра сервера"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
+msgid "Change your password"
+msgstr "Изменить пароль"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+msgid "Rename or delete a tag in all links"
+msgstr "Переименовать или удалить тег во всех ссылках"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+msgid ""
+"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
+"delicious...)"
+msgstr ""
+"Импорт закладок Netscape HTML (экспортированные из Firefox, Chrome, Opera, "
+"delicious...)"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+msgid "Import links"
+msgstr "Импорт ссылок"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
+msgid ""
+"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
+"Opera, delicious...)"
+msgstr ""
+"Экспорт закладок Netscape HTML (которые могут быть импортированы в Firefox, "
+"Chrome, Opera, delicious...)"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54
+msgid "Export database"
+msgstr "Экспорт базы данных"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
+msgid ""
+"Drag one of these button to your bookmarks toolbar or right-click it and "
+"\"Bookmark This Link\""
+msgstr ""
+"Перетащите одну из этих кнопок на панель закладок или щелкните по ней правой "
+"кнопкой мыши и выберите \"Добавить ссылку в закладки\""
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
+msgid "then click on the bookmarklet in any page you want to share."
+msgstr ""
+"затем щелкните букмарклет на любой странице, которой хотите поделиться."
+
+#: 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"
+msgstr ""
+"Перетащите эту ссылку на панель закладок или щелкните по ней правой кнопкой "
+"мыши и добавьте эту ссылку в закладки"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+msgid "then click ✚Shaare link button in any page you want to share"
+msgstr ""
+"затем нажмите кнопку ✚Поделиться ссылкой на любой странице, которой хотите "
+"поделиться"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
+msgid "The selected text is too long, it will be truncated."
+msgstr "Выделенный текст слишком длинный, он будет обрезан."
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+msgid "Shaare link"
+msgstr "Поделиться ссылкой"
+
+#: 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 ""
+"Затем в любое время нажмите кнопку ✚Добавить заметку, чтобы начать создавать "
+"личную заметку (текстовое сообщение) в своем Shaarli"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
+msgid "Add Note"
+msgstr "Добавить заметку"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:132
+msgid "3rd party"
+msgstr "Третья сторона"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:140
+msgid "plugin"
+msgstr "плагин"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
+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\Plugin\PluginManager;
+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);
+$pluginManager = new PluginManager($conf);
+$pluginManager->load($conf->get('general.enabled_plugins', []));
+
+$containerBuilder = new ContainerBuilder(
+ $conf,
+ $sessionManager,
+ $cookieManager,
+ $loginManager,
+ $pluginManager,
+ $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');
+$app->group('/plugin', function () use ($pluginManager) {
+ foreach ($pluginManager->getRegisteredRoutes() as $pluginName => $routes) {
+ $this->group('/' . $pluginName, function () use ($routes) {
+ foreach ($routes as $route) {
+ $this->{strtolower($route['method'])}('/' . ltrim($route['route'], '/'), $route['callable']);
+ }
+ });
+ }
+})->add('\Shaarli\Front\ShaarliMiddleware');
// REST API routes
$app->group('/api/v1', function () {
$this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory');
})->add('\Shaarli\Api\ApiMiddleware');
-$response = $app->run(true);
-
-$app->respond($response);
+try {
+ $response = $app->run(true);
+ $app->respond($response);
+} catch (Throwable $e) {
+ die(nl2br(
+ 'An unexpected error happened, and the error template could not be displayed.' . PHP_EOL . PHP_EOL .
+ exception2text($e)
+ ));
+}
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
"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>
+ <exclude-pattern>plugins/*</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.
*
{
$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];
}
}
}
+/**
+ * When plugin parameters are saved, we regenerate the custom CSS file with provided settings.
+ *
+ * @param array $data $_POST array
+ *
+ * @return array Updated $_POST array
+ */
+function hook_default_colors_save_plugin_parameters($data)
+{
+ default_colors_generate_css_file($data);
+
+ return $data;
+}
+
/**
* When linklist is displayed, include default_colors CSS file.
*
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];
}
--- /dev/null
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\DemoPlugin;
+
+use Shaarli\Front\Controller\Admin\ShaarliAdminController;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DemoPluginController extends ShaarliAdminController
+{
+ public function index(Request $request, Response $response): Response
+ {
+ $this->assignView(
+ 'content',
+ '<div class="center">' .
+ 'This is a demo page. I have access to Shaarli container, so I\'m free to do whatever I want here.' .
+ '</div>'
+ );
+
+ return $response->write($this->render('pluginscontent'));
+ }
+}
<?php
+
/**
* Demo Plugin.
*
* Can be used by plugin developers to make their own plugin.
*/
+require_once __DIR__ . '/DemoPluginController.php';
+
/*
* RENDER HEADER, INCLUDES, FOOTER
*
return $errors;
}
+function demo_plugin_register_routes(): array
+{
+ return [
+ [
+ 'method' => 'GET',
+ 'route' => '/custom',
+ 'callable' => 'Shaarli\DemoPlugin\DemoPluginController:index',
+ ],
+ ];
+}
+
/**
* Hook render_header.
* Executed on every page render.
* 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'])) {
function hook_demo_plugin_render_tools($data)
{
// field_plugin
- $data['tools_plugin'][] = 'tools_plugin';
+ $data['tools_plugin'][] = '<div class="tools-item">
+ <a href="' . $data['_BASE_PATH_'] . '/plugin/demo_plugin/custom">
+ <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Demo Plugin Custom Route</span>
+ </a>
+ </div>';
return $data;
}
{
$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="'. $data['_ROOT_PATH_'].'/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.
<?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;
}
$wallabag = sprintf(
$wallabagHtml,
$wallabagInstance->getWallabagUrl(),
- urlencode($value['url']),
+ urlencode(unescape($value['url'])),
$path,
$linkTitle
);
$this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
$this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
}
+
+ /**
+ * Test plugin custom routes - note that there is no check on callable functions
+ */
+ public function testRegisteredRoutes(): void
+ {
+ PluginManager::$PLUGINS_PATH = self::$pluginPath;
+ $this->pluginManager->load([self::$pluginName]);
+
+ $expectedParameters = [
+ [
+ 'method' => 'GET',
+ 'route' => '/test',
+ 'callable' => 'getFunction',
+ ],
+ [
+ 'method' => 'POST',
+ 'route' => '/custom',
+ 'callable' => 'postFunction',
+ ],
+ ];
+ $meta = $this->pluginManager->getRegisteredRoutes();
+ static::assertSame($expectedParameters, $meta[self::$pluginName]);
+ }
+
+ /**
+ * Test plugin custom routes with invalid route
+ */
+ public function testRegisteredRoutesInvalid(): void
+ {
+ $plugin = 'test_route_invalid';
+ $this->pluginManager->load([$plugin]);
+
+ $meta = $this->pluginManager->getRegisteredRoutes();
+ static::assertSame([], $meta);
+
+ $errors = $this->pluginManager->getErrors();
+ static::assertSame(['test_route_invalid [plugin incompatibility]: trying to register invalid route.'], $errors);
+ }
}
}
/**
- * 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);
}
/**
$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']);
$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']);
\DateTime::createFromFormat(\DateTime::ATOM, $data['updated'])
);
}
+
+ /**
+ * Test link creation with a tag string provided
+ */
+ public function testPostLinkWithTagString(): void
+ {
+ $link = [
+ 'tags' => 'one two',
+ ];
+ $env = Environment::mock([
+ 'REQUEST_METHOD' => 'POST',
+ 'CONTENT_TYPE' => 'application/json'
+ ]);
+
+ $request = Request::createFromEnvironment($env);
+ $request = $request->withParsedBody($link);
+ $response = $this->controller->postLink($request, new Response());
+
+ $this->assertEquals(201, $response->getStatusCode());
+ $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(['one', 'two'], $data['tags']);
+ }
+
+ /**
+ * Test link creation with a tag string provided
+ */
+ public function testPostLinkWithTagString2(): void
+ {
+ $link = [
+ 'tags' => ['one two'],
+ ];
+ $env = Environment::mock([
+ 'REQUEST_METHOD' => 'POST',
+ 'CONTENT_TYPE' => 'application/json'
+ ]);
+
+ $request = Request::createFromEnvironment($env);
+ $request = $request->withParsedBody($link);
+ $response = $this->controller->postLink($request, new Response());
+
+ $this->assertEquals(201, $response->getStatusCode());
+ $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(['one', 'two'], $data['tags']);
+ }
}
$this->controller->putLink($request, new Response(), ['id' => -1]);
}
+
+ /**
+ * Test link creation with a tag string provided
+ */
+ public function testPutLinkWithTagString(): void
+ {
+ $link = [
+ 'tags' => 'one two',
+ ];
+ $id = '41';
+ $env = Environment::mock([
+ 'REQUEST_METHOD' => 'PUT',
+ 'CONTENT_TYPE' => 'application/json'
+ ]);
+
+ $request = Request::createFromEnvironment($env);
+ $request = $request->withParsedBody($link);
+ $response = $this->controller->putLink($request, new Response(), ['id' => $id]);
+
+ $this->assertEquals(200, $response->getStatusCode());
+ $data = json_decode((string) $response->getBody(), true);
+ $this->assertEquals(self::NB_FIELDS_LINK, count($data));
+ $this->assertEquals(['one', 'two'], $data['tags']);
+ }
+
+ /**
+ * Test link creation with a tag string provided
+ */
+ public function testPutLinkWithTagString2(): void
+ {
+ $link = [
+ 'tags' => ['one two'],
+ ];
+ $id = '41';
+ $env = Environment::mock([
+ 'REQUEST_METHOD' => 'PUT',
+ 'CONTENT_TYPE' => 'application/json'
+ ]);
+
+ $request = Request::createFromEnvironment($env);
+ $request = $request->withParsedBody($link);
+ $response = $this->controller->putLink($request, new Response(), ['id' => $id]);
+
+ $this->assertEquals(200, $response->getStatusCode());
+ $data = json_decode((string) $response->getBody(), true);
+ $this->assertEquals(self::NB_FIELDS_LINK, count($data));
+ $this->assertEquals(['one', 'two'], $data['tags']);
+ }
}
$this->assertEquals(0, $linkDB->count());
}
- /**
- * List the days for which bookmarks have been posted
- */
- public function testDays()
- {
- $this->assertSame(
- ['20100309', '20100310', '20121206', '20121207', '20130614', '20150310'],
- $this->publicLinkDB->days()
- );
-
- $this->assertSame(
- ['20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'],
- $this->privateLinkDB->days()
- );
- }
-
/**
* The URL corresponds to an existing entry in the DB
*/
$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.
}
/**
- * 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);
}
/**
self::$refDB->write(self::$testDatastore);
$history = new History('sandbox/history.php');
self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true);
- self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks());
+ self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks(), $conf);
}
/**
$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
*/
{
$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());
+ }
}
$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));
+ }
+
/**
* Test html_extract_tag() when the tag <meta name= is not found.
*/
$this->assertFalse(html_extract_tag('description', $html));
}
+ public function testHtmlExtractDescriptionFromGoogleRealCase(): void
+ {
+ $html = 'id="gsr"><meta content="Fêtes de fin d\'année" property="twitter:title"><meta '.
+ 'content="Bonnes fêtes de fin d\'année ! #GoogleDoodle" property="twitter:description">'.
+ '<meta content="Bonnes fêtes de fin d\'année ! #GoogleDoodle" property="og:description">'.
+ '<meta content="summary_large_image" property="twitter:card"><meta co'
+ ;
+ $this->assertSame('Bonnes fêtes de fin d\'année ! #GoogleDoodle', 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.
*
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;
/** @var CookieManager */
protected $cookieManager;
+ /** @var PluginManager */
+ protected $pluginManager;
+
public function setUp(): void
{
$this->conf = new ConfigManager('tests/utils/config/configJson');
$this->sessionManager = $this->createMock(SessionManager::class);
$this->cookieManager = $this->createMock(CookieManager::class);
+ $this->pluginManager = $this->createMock(PluginManager::class);
$this->loginManager = $this->createMock(LoginManager::class);
$this->loginManager->method('isLoggedIn')->willReturn(true);
$this->conf,
$this->sessionManager,
$this->cookieManager,
- $this->loginManager
+ $this->loginManager,
+ $this->pluginManager,
+ $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);
*/
public function testConstruct()
{
- new CachedPage(self::$testCacheDir, '', true);
- new CachedPage(self::$testCacheDir, '', false);
- new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/rss', true);
- new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/atom', false);
+ new CachedPage(self::$testCacheDir, '', true, null);
+ new CachedPage(self::$testCacheDir, '', false, null);
+ new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/rss', true, null);
+ new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/atom', false, null);
$this->addToAssertionCount(1);
}
*/
public function testCache()
{
- $page = new CachedPage(self::$testCacheDir, self::$url, true);
+ $page = new CachedPage(self::$testCacheDir, self::$url, true, null);
$this->assertFileNotExists(self::$filename);
$page->cache('<p>Some content</p>');
*/
public function testShouldNotCache()
{
- $page = new CachedPage(self::$testCacheDir, self::$url, false);
+ $page = new CachedPage(self::$testCacheDir, self::$url, false, null);
$this->assertFileNotExists(self::$filename);
$page->cache('<p>Some content</p>');
*/
public function testCachedVersion()
{
- $page = new CachedPage(self::$testCacheDir, self::$url, true);
+ $page = new CachedPage(self::$testCacheDir, self::$url, true, null);
$this->assertFileNotExists(self::$filename);
$page->cache('<p>Some content</p>');
*/
public function testCachedVersionNoFile()
{
- $page = new CachedPage(self::$testCacheDir, self::$url, true);
+ $page = new CachedPage(self::$testCacheDir, self::$url, true, null);
$this->assertFileNotExists(self::$filename);
$this->assertEquals(
*/
public function testNoCachedVersion()
{
- $page = new CachedPage(self::$testCacheDir, self::$url, false);
+ $page = new CachedPage(self::$testCacheDir, self::$url, false, null);
$this->assertFileNotExists(self::$filename);
$this->assertEquals(
$page->cachedVersion()
);
}
+
+ /**
+ * Return a page's cached content within date period
+ */
+ public function testCachedVersionInDatePeriod()
+ {
+ $period = new \DatePeriod(
+ new \DateTime('yesterday'),
+ new \DateInterval('P1D'),
+ new \DateTime('tomorrow')
+ );
+ $page = new CachedPage(self::$testCacheDir, self::$url, true, $period);
+
+ $this->assertFileNotExists(self::$filename);
+ $page->cache('<p>Some content</p>');
+ $this->assertFileExists(self::$filename);
+ $this->assertEquals(
+ '<p>Some content</p>',
+ $page->cachedVersion()
+ );
+ }
+
+ /**
+ * Return a page's cached content outside of date period
+ */
+ public function testCachedVersionNotInDatePeriod()
+ {
+ $period = new \DatePeriod(
+ new \DateTime('yesterday noon'),
+ new \DateInterval('P1D'),
+ new \DateTime('yesterday midnight')
+ );
+ $page = new CachedPage(self::$testCacheDir, self::$url, true, $period);
+
+ $this->assertFileNotExists(self::$filename);
+ $page->cache('<p>Some content</p>');
+ $this->assertFileExists(self::$filename);
+ $this->assertNull($page->cachedVersion());
+ }
}
$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']
+ );
+ }
}
static::assertSame('privacy.hide_public_links', $assignedVariables['hide_public_links']);
static::assertSame('api.enabled', $assignedVariables['api_enabled']);
static::assertSame('api.secret', $assignedVariables['api_secret']);
- static::assertCount(5, $assignedVariables['languages']);
+ static::assertCount(6, $assignedVariables['languages']);
static::assertArrayHasKey('gd_enabled', $assignedVariables);
static::assertSame('thumbnails.mode', $assignedVariables['thumbnails_mode']);
}
+++ /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'));
}
/**
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);
}
/**
/**
* 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);
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
*/
$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 DateTimeImmutable;
+use DateTimeInterface;
+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);
+ }
+
+ /**
+ * @dataProvider getDescriptionsByTypeNotIncludeRelative
+ */
+ public function testGeDescriptionsByTypeNotIncludeRelative(
+ string $type,
+ \DateTimeImmutable $dateTime,
+ string $expectedDescription
+ ): void {
+ $description = DailyPageHelper::getDescriptionByType($type, $dateTime, false);
+
+ 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');
+ }
+
+ /**
+ * @dataProvider getCacheDatePeriodByType
+ */
+ public function testGetCacheDatePeriodByType(
+ string $type,
+ DateTimeImmutable $requested,
+ DateTimeInterface $start,
+ DateTimeInterface $end
+ ): void {
+ $period = DailyPageHelper::getCacheDatePeriodByType($type, $requested);
+
+ static::assertEquals($start, $period->getStartDate());
+ static::assertEquals($end, $period->getEndDate());
+ }
+
+ public function testGetCacheDatePeriodByTypeExceptionUnknownType(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Unsupported daily format type');
+
+ DailyPageHelper::getCacheDatePeriodByType('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 testGeDescriptionsByTypeNotIncludeRelative() test method.
+ */
+ public function getDescriptionsByTypeNotIncludeRelative(): array
+ {
+ return [
+ [DailyPageHelper::DAY, $date = new \DateTimeImmutable(), $date->format('F j, Y')],
+ [DailyPageHelper::DAY, $date = new \DateTimeImmutable('-1 day'), $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 testGetRssLengthsByType() test method.
+ */
+ public function getRssLengthsByType(): array
+ {
+ return [
+ [DailyPageHelper::DAY],
+ [DailyPageHelper::WEEK],
+ [DailyPageHelper::MONTH],
+ ];
+ }
+
+ /**
+ * Data provider for testGetCacheDatePeriodByType() test method.
+ */
+ public function getCacheDatePeriodByType(): array
+ {
+ return [
+ [
+ DailyPageHelper::DAY,
+ new DateTimeImmutable('2020-10-09 04:05:06'),
+ new \DateTime('2020-10-09 00:00:00'),
+ new \DateTime('2020-10-09 23:59:59'),
+ ],
+ [
+ DailyPageHelper::WEEK,
+ new DateTimeImmutable('2020-10-09 04:05:06'),
+ new \DateTime('2020-10-05 00:00:00'),
+ new \DateTime('2020-10-11 23:59:59'),
+ ],
+ [
+ DailyPageHelper::MONTH,
+ new DateTimeImmutable('2020-10-09 04:05:06'),
+ new \DateTime('2020-10-01 00:00:00'),
+ new \DateTime('2020-10-31 23:59:59'),
+ ],
+ ];
+ }
+}
<?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' => trim($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);
+ }
+}
*/
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;
{
$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
*
$result = default_colors_format_css_rule($data, '');
$this->assertEmpty($result);
}
+
+ /**
+ * Make sure that a new CSS file is generated when save_plugin_parameters hook is triggered.
+ */
+ public function testHookSavePluginParameters(): void
+ {
+ $params = [
+ 'other1' => true,
+ 'DEFAULT_COLORS_BACKGROUND' => 'pink',
+ 'other2' => ['yep'],
+ 'DEFAULT_COLORS_DARK_MAIN' => '',
+ ];
+
+ hook_default_colors_save_plugin_parameters($params);
+ $this->assertFileExists($file = 'sandbox/default_colors/default_colors.css');
+ $content = file_get_contents($file);
+ $expected = ':root {
+ --background-color: pink;
+
+}
+';
+ $this->assertEquals($expected, $content);
+ }
}
$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);
+ }
}
{
new Unknown();
}
+
+function test_register_routes(): array
+{
+ return [
+ [
+ 'method' => 'GET',
+ 'route' => '/test',
+ 'callable' => 'getFunction',
+ ],
+ [
+ 'method' => 'POST',
+ 'route' => '/custom',
+ 'callable' => 'postFunction',
+ ],
+ ];
+}
--- /dev/null
+<?php
+
+function test_route_invalid_register_routes(): array
+{
+ return [
+ [
+ 'method' => 'GET',
+ 'route' => 'not a route',
+ 'callable' => 'getFunction',
+ ],
+ ];
+}
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,
*/
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;
/**
</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>
<input type="hidden" name="token" value="{$token}">
<div>
<input type="submit" value="{'Rename tag'|t}" name="renametag">
- <input type="submit" value="{'Delete tag'|t}" name="deletetag" class="button button-red confirm-delete">
+ <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">
<?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>
</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">
</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}
{$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)"}
·
{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>
<div class="pure-u-2-24"></div>
</div>
-<input type="hidden" name="token" value="{$token}" id="token" />
-
{loop="$plugins_footer.endofpage"}
{$value}
{/loop}
<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 tag?'|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>
--- /dev/null
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+ {include="includes"}
+</head>
+<body>
+ {include="page.header"}
+
+ {$content}
+
+ {include="page.footer"}
+</body>
+</html>
--- /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"
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
ini@^1.3.4, ini@^1.3.5:
- version "1.3.5"
- resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
- integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+ version "1.3.7"
+ resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84"
+ integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==
interpret@^1.4.0:
version "1.4.0"