]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #2 from shaarli/master
authoryude <yudesleepy@gmail.com>
Mon, 4 Jan 2021 09:51:10 +0000 (18:51 +0900)
committerGitHub <noreply@github.com>
Mon, 4 Jan 2021 09:51:10 +0000 (18:51 +0900)
Merge fork source

208 files changed:
.docker/nginx.conf
.dockerignore
.htaccess
.travis.yml
AUTHORS
CHANGELOG.md
Dockerfile
Dockerfile.armhf
Makefile
README.md
application/History.php
application/Languages.php
application/Thumbnailer.php
application/TimeZone.php
application/Utils.php
application/api/ApiMiddleware.php
application/api/ApiUtils.php
application/api/controllers/HistoryController.php
application/api/controllers/Info.php
application/api/controllers/Links.php
application/api/exceptions/ApiAuthorizationException.php
application/api/exceptions/ApiException.php
application/bookmark/Bookmark.php
application/bookmark/BookmarkArray.php
application/bookmark/BookmarkFileService.php
application/bookmark/BookmarkFilter.php
application/bookmark/BookmarkIO.php
application/bookmark/BookmarkInitializer.php
application/bookmark/BookmarkServiceInterface.php
application/bookmark/LinkUtils.php
application/bookmark/exception/BookmarkNotFoundException.php
application/bookmark/exception/EmptyDataStoreException.php
application/bookmark/exception/InvalidBookmarkException.php
application/bookmark/exception/NotWritableDataStoreException.php
application/config/ConfigIO.php
application/config/ConfigJson.php
application/config/ConfigManager.php
application/config/ConfigPhp.php
application/config/ConfigPlugin.php
application/config/exception/MissingFieldConfigException.php
application/config/exception/UnauthorizedConfigException.php
application/container/ContainerBuilder.php
application/container/ShaarliContainer.php
application/exceptions/IOException.php
application/feed/CachedPage.php
application/feed/FeedBuilder.php
application/formatter/BookmarkDefaultFormatter.php
application/formatter/BookmarkFormatter.php
application/formatter/BookmarkMarkdownFormatter.php
application/formatter/BookmarkRawFormatter.php
application/formatter/FormatterFactory.php
application/front/ShaarliMiddleware.php
application/front/controller/admin/ConfigureController.php
application/front/controller/admin/ExportController.php
application/front/controller/admin/ImportController.php
application/front/controller/admin/ManageShaareController.php [deleted file]
application/front/controller/admin/ManageTagController.php
application/front/controller/admin/MetadataController.php [new file with mode: 0644]
application/front/controller/admin/PasswordController.php
application/front/controller/admin/PluginsController.php
application/front/controller/admin/ServerController.php [new file with mode: 0644]
application/front/controller/admin/SessionFilterController.php
application/front/controller/admin/ShaareAddController.php [new file with mode: 0644]
application/front/controller/admin/ShaareManageController.php [new file with mode: 0644]
application/front/controller/admin/ShaarePublishController.php [new file with mode: 0644]
application/front/controller/admin/ThumbnailsController.php
application/front/controller/admin/ToolsController.php
application/front/controller/visitor/BookmarkListController.php
application/front/controller/visitor/DailyController.php
application/front/controller/visitor/ErrorController.php
application/front/controller/visitor/FeedController.php
application/front/controller/visitor/InstallController.php
application/front/controller/visitor/LoginController.php
application/front/controller/visitor/PictureWallController.php
application/front/controller/visitor/ShaarliVisitorController.php
application/front/controller/visitor/TagCloudController.php
application/front/controller/visitor/TagController.php
application/helper/ApplicationUtils.php [moved from application/ApplicationUtils.php with 61% similarity]
application/helper/DailyPageHelper.php [new file with mode: 0644]
application/helper/FileUtils.php [moved from application/FileUtils.php with 57% similarity]
application/http/HttpAccess.php
application/http/HttpUtils.php
application/http/MetadataRetriever.php [new file with mode: 0644]
application/http/Url.php
application/http/UrlUtils.php
application/legacy/LegacyController.php
application/legacy/LegacyLinkDB.php
application/legacy/LegacyLinkFilter.php
application/legacy/LegacyUpdater.php
application/netscape/NetscapeBookmarkUtils.php
application/plugin/PluginManager.php
application/plugin/exception/PluginFileNotFoundException.php
application/plugin/exception/PluginInvalidRouteException.php [new file with mode: 0644]
application/render/PageBuilder.php
application/render/PageCacheManager.php
application/render/TemplatePage.php
application/render/ThemeUtils.php
application/security/BanManager.php
application/security/LoginManager.php
application/security/SessionManager.php
application/updater/Updater.php
application/updater/UpdaterUtils.php
assets/common/js/metadata.js [new file with mode: 0644]
assets/common/js/shaare-batch.js [new file with mode: 0644]
assets/default/js/base.js
assets/default/scss/shaarli.scss
assets/vintage/css/shaarli.css
assets/vintage/js/base.js
composer.json
composer.lock
doc/md/Docker.md
doc/md/REST-API.md
doc/md/Server-configuration.md
doc/md/Shaarli-configuration.md
doc/md/dev/Development.md
doc/md/dev/Plugin-system.md
doc/md/dev/Release-Shaarli.md
docker-compose.yml
inc/languages/fr/LC_MESSAGES/shaarli.po
inc/languages/ru/LC_MESSAGES/shaarli.po [new file with mode: 0644]
index.php
init.php
package.json
phpcs.xml
plugins/addlink_toolbar/addlink_toolbar.php
plugins/archiveorg/archiveorg.php
plugins/default_colors/default_colors.php
plugins/demo_plugin/DemoPluginController.php [new file with mode: 0644]
plugins/demo_plugin/demo_plugin.php
plugins/isso/isso.php
plugins/piwik/piwik.php
plugins/playvideos/playvideos.php
plugins/pubsubhubbub/pubsubhubbub.php
plugins/qrcode/qrcode.php
plugins/wallabag/WallabagInstance.php
plugins/wallabag/wallabag.php
tests/PluginManagerTest.php
tests/UtilsTest.php
tests/api/controllers/links/PostLinkTest.php
tests/api/controllers/links/PutLinkTest.php
tests/bookmark/BookmarkFileServiceTest.php
tests/bookmark/BookmarkFilterTest.php
tests/bookmark/BookmarkTest.php
tests/bookmark/LinkUtilsTest.php
tests/container/ContainerBuilderTest.php
tests/feed/CachedPageTest.php
tests/formatter/BookmarkDefaultFormatterTest.php
tests/front/controller/admin/ConfigureControllerTest.php
tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php [deleted file]
tests/front/controller/admin/ManageTagControllerTest.php
tests/front/controller/admin/ServerControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareAddControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php with 98% similarity]
tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php with 96% similarity]
tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php with 95% similarity]
tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php with 71% similarity]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php with 93% similarity]
tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php with 83% similarity]
tests/front/controller/visitor/BookmarkListControllerTest.php
tests/front/controller/visitor/DailyControllerTest.php
tests/front/controller/visitor/ErrorControllerTest.php
tests/front/controller/visitor/FrontControllerMockHelper.php
tests/front/controller/visitor/InstallControllerTest.php
tests/front/controller/visitor/LoginControllerTest.php
tests/front/controller/visitor/TagCloudControllerTest.php
tests/front/controller/visitor/TagControllerTest.php
tests/helper/ApplicationUtilsTest.php [moved from tests/ApplicationUtilsTest.php with 81% similarity]
tests/helper/DailyPageHelperTest.php [new file with mode: 0644]
tests/helper/FileUtilsTest.php [moved from tests/FileUtilsTest.php with 53% similarity]
tests/http/MetadataRetrieverTest.php [new file with mode: 0644]
tests/legacy/LegacyUpdaterTest.php
tests/netscape/BookmarkImportTest.php
tests/plugins/PluginDefaultColorsTest.php
tests/plugins/PluginWallabagTest.php
tests/plugins/test/test.php
tests/plugins/test_route_invalid/test_route_invalid.php [new file with mode: 0644]
tests/security/BanManagerTest.php
tests/security/LoginManagerTest.php
tests/security/SessionManagerTest.php
tests/updater/UpdaterTest.php
tests/utils/FakeApplicationUtils.php
tests/utils/FakeConfigManager.php
tests/utils/ReferenceHistory.php
tpl/default/addlink.html
tpl/default/changetag.html
tpl/default/daily.html
tpl/default/dailyrss.html
tpl/default/editlink.batch.html [new file with mode: 0644]
tpl/default/editlink.html
tpl/default/error.html
tpl/default/install.html
tpl/default/linklist.html
tpl/default/page.footer.html
tpl/default/pluginscontent.html [new file with mode: 0644]
tpl/default/server.html [new file with mode: 0644]
tpl/default/server.requirements.html [new file with mode: 0644]
tpl/default/tag.cloud.html
tpl/default/tools.html
tpl/vintage/daily.html
tpl/vintage/editlink.html
tpl/vintage/includes.html
tpl/vintage/linklist.html
tpl/vintage/page.footer.html
tpl/vintage/page.header.html
webpack.config.js
yarn.lock

index 07fba33fec11bcbf90741c086586550a0b86c57c..30810a871b1bf2418a9ed73cab62b0bec8680cf3 100644 (file)
@@ -17,27 +17,13 @@ http {
     index index.html index.php;
 
     server {
-        listen       80;
-        root         /var/www/shaarli;
+        listen      80;
+        root        /var/www/shaarli;
 
         access_log  /var/log/nginx/shaarli.access.log;
         error_log   /var/log/nginx/shaarli.error.log;
 
-        location ~ /\. {
-            # deny access to dotfiles
-            access_log off;
-            log_not_found off;
-            deny all;
-        }
-        
-        location ~ ~$ {
-            # deny access to temp editor files, e.g. "script.php~"
-            access_log off;
-            log_not_found off;
-            deny all;
-        }
-
-        location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
+        location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
             # cache static assets
             expires    max;
             add_header Pragma public;
@@ -49,25 +35,25 @@ http {
             alias /var/www/shaarli/images/favicon.ico;
         }
 
+        location /doc/html/ {
+            default_type "text/html";
+            try_files $uri $uri/ $uri.html =404;
+        }
+
         location / {
-            # Slim - rewrite URLs
-            try_files $uri /index.php$is_args$args;
+            # Slim - rewrite URLs & do NOT serve static files through this location
+            try_files _ /index.php$is_args$args;
         }
 
-        location ~ (index)\.php$ {
+        location ~ index\.php$ {
             # Slim - split URL path into (script_filename, path_info)
             try_files $uri =404;
-            fastcgi_split_path_info ^(.+\.php)(/.+)$;
+            fastcgi_split_path_info ^(index.php)(/.+)$;
 
             # filter and proxy PHP requests to PHP-FPM
             fastcgi_pass   unix:/var/run/php-fpm.sock;
             fastcgi_index  index.php;
             include        fastcgi.conf;
         }
-
-        location ~ \.php$ {
-            # deny access to all other PHP scripts
-            deny all;
-        }
     }
 }
index 96fd31c5bf630425977583f46de76fae72d6def9..19fd87a50f4344505083bdfad643080ef9bb1c90 100644 (file)
@@ -2,8 +2,16 @@
 .dev
 .git
 .github
+.gitattributes
+.gitignore
+.travis.yml
 tests
 
+# Docker related resources are not needed inside the container
+.dockerignore
+Dockerfile
+Dockerfile.armhf
+
 # Docker Compose resources
 docker-compose.yml
 
@@ -13,6 +21,9 @@ data/*
 pagecache/*
 tmp/*
 
+# Shaarli's docs are created during the build
+doc/html/
+
 # Eclipse project files
 .settings
 .buildpath
index 25fcfb034ee3e1bf1149eafdfccc5b9d27803fe2..9d1522dfb14544aae9c8e5398c18c33ac8290472 100644 (file)
--- a/.htaccess
+++ b/.htaccess
@@ -13,7 +13,7 @@ RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
 # Alternative (if the 2 lines above don't work)
 # SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
 
-# REST API
+# Slim URL Redirection
 # Ionos Hosting needs RewriteBase /
 # RewriteBase /
 RewriteCond %{REQUEST_FILENAME} !-f
index d7460947383a0e595f472908da81ffcec78ef040..422bf835954aab2158f36bb1d8bf03b8b5ca435a 100644 (file)
@@ -49,6 +49,10 @@ cache:
   directories:
     - $HOME/.composer/cache
 
+before_install:
+  # Disable xdebug: it significantly speed up tests and linter, and we don't use coverage yet
+  - phpenv config-rm xdebug.ini || echo 'No xdebug config.'
+
 install:
   # install/update composer and php dependencies
   - composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION
@@ -60,4 +64,5 @@ before_script:
 script:
   - make clean
   - make check_permissions
+  - make code_sniffer
   - make all_tests
diff --git a/AUTHORS b/AUTHORS
index 0ec52accb570c55861e047eed49290e420115582..be8153643fa0871f54a12d68bad2309daf745ce2 100644 (file)
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,4 +1,4 @@
-   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>
@@ -25,6 +25,7 @@
      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>
@@ -39,6 +40,7 @@
      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>
@@ -65,6 +67,7 @@
      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>
index f1686d67f3564e913b30d483dcaf8eb52cff2b7f..184040490e4fc4af3f71c5965cb40fdbae9311be 100644 (file)
@@ -4,7 +4,55 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
-## [v0.12.1]() - UNRELEASED
+## [v0.12.2]() - UNRELEASED
+
+## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-11-12
+
+> nginx ([#1628](https://github.com/shaarli/Shaarli/pull/1628)) and Apache ([#1630](https://github.com/shaarli/Shaarli/pull/1630)) configurations have been reviewed. It is recommended that you
+> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/).
+> Users using official Docker image will receive updated configuration automatically.
+
+### Added
+- Bulk creation of bookmarks
+- Server administration tool page (and install page requirements)
+- Support any tag separator, not just whitespaces
+- Share a private bookmark using a URL with a token
+- Add a setting to retrieve bookmark metadata asynchronously (enabled by default)
+- Highlight fulltext search results
+- Weekly and monthly view/RSS feed for daily page
+- MarkdownExtra formatter
+- Default formatter: add a setting to disable auto-linkification
+- Add mutex on datastore I/O operations to prevent data loss
+- PHP 8.0 support
+- REST API: allow override of creation and update dates
+- Add strict types for bookmarks management
+
+### Changed
+- Improve regex and performances to extract HTML metadata (title, description, etc.)
+- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`)
+- Improve the "Manage tags" tools page
+- Use PSR-3 logger for login attempts
+- Move utils classes to Shaarli\Helper namespace and folder
+- Include php-simplexml in Docker image
+- Raise 404 error instead of 500 if permalink access is denied
+- Display error details even with dev.debug set to false
+- Reviewed nginx configuration
+- Reviewed Apache configuration
+- Replace vimeo link in demo bookmarks due to IP ban on the demo instance
+- Apply PSR-12 on code base, and add CI check using PHPCS
+
+### Fixed
+- Compatiliby issue on login with PHP 7.1
+- Japanese translations update
+- Redirect to referrer after bookmark deletion
+- Inject ROOT_PATH in plugin instead of regenerating it everywhere
+- Wallabag plugin: minor improvements
+- REST API postLink: change relative path to absolute path
+- Webpack: fix vintage theme images include
+- Docker-compose: fix SSL certificate + add parameter for Docker tag
+
+### Removed
+- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP
 
 ## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13
 
index e2ff71fde5971c7803b6b8d60d498765b4680116..79d3313095b89f8904ba37992dedd30ee74aa229 100644 (file)
@@ -26,7 +26,7 @@ RUN cd shaarli \
 
 # Stage 4:
 # - Shaarli image
-FROM alpine:3.8
+FROM alpine:3.12
 LABEL maintainer="Shaarli Community"
 
 RUN apk --update --no-cache add \
@@ -44,6 +44,7 @@ RUN apk --update --no-cache add \
         php7-openssl \
         php7-session \
         php7-xml \
+        php7-simplexml \
         php7-zlib \
         s6
 
index 5bbf668049d7ed31f203d799b29f64685d538e43..471f239743651420a1afc012c2412b6b9f0b1554 100644 (file)
@@ -1,7 +1,7 @@
 # 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 \
@@ -10,7 +10,7 @@ RUN apk --update --no-cache add py2-pip \
 
 # 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 \
@@ -18,7 +18,7 @@ RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer
 
 # 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 \
@@ -28,7 +28,7 @@ RUN apk --update --no-cache add yarn nodejs-current python2 build-base \
 
 # Stage 4:
 # - Shaarli image
-FROM arm32v6/alpine:3.8
+FROM arm32v6/alpine:3.10
 LABEL maintainer="Shaarli Community"
 
 RUN apk --update --no-cache add \
index 0ff6bd3f7a5ef59ed7900b29a3b093855352b928..181b61c4c476c1f3f16b5bfeb39789b9ea090bcd 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -27,10 +27,6 @@ PHPCS := $(BIN)/phpcs
 code_sniffer:
        @$(PHPCS)
 
-### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
-PHPCS_%:
-       @$(PHPCS) --report-full --report-width=200 --standard=$*
-
 ### - errors by Git author
 code_sniffer_blame:
        @$(PHPCS) --report-gitblame
@@ -175,6 +171,7 @@ translate:
 eslint:
        @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/
        @yarn run eslint -c .dev/.eslintrc.js assets/default/js/
+       @yarn run eslint -c .dev/.eslintrc.js assets/common/js/
 
 ### Run CSSLint check against Shaarli's SCSS files
 sasslint:
index 46dda8d5e3e0fb874419a0f617e65f36826d1229..71198032982cbd9555436a684f650271df6d8375 100644 (file)
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ _It is designed to be personal (single-user), fast and handy._
 [![](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)
 &bull;
-[![](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)
 &bull;
 [![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)
index 4fd2f29444ea8a6122740a25f77fc97847e868d4..d230f39de71bf0286158240341e41d73336125c5 100644 (file)
@@ -1,9 +1,11 @@
 <?php
+
 namespace Shaarli;
 
 use DateTime;
 use Exception;
 use Shaarli\Bookmark\Bookmark;
+use Shaarli\Helper\FileUtils;
 
 /**
  * Class History
@@ -30,27 +32,27 @@ 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.
index d83e0765794af8cfbf0a5f762b5070162181a811..7177db2ccda5fcd6c190d70e3383d091b6bda48a 100644 (file)
@@ -41,7 +41,7 @@ class Languages
     /**
      * Core translations domain
      */
-    const DEFAULT_DOMAIN = 'shaarli';
+    public const DEFAULT_DOMAIN = 'shaarli';
 
     /**
      * @var TranslatorInterface
@@ -76,7 +76,8 @@ class Languages
             $this->language = $confLanguage;
         }
 
-        if (! extension_loaded('gettext')
+        if (
+            ! extension_loaded('gettext')
             || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
         ) {
             $this->initPhpTranslator();
@@ -98,7 +99,7 @@ class Languages
         $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
 
         // Default extension translation from the current theme
-        $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language';
+        $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language';
         if (is_dir($themeTransFolder)) {
             $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
         }
@@ -121,7 +122,9 @@ class Languages
         $translations = new Translations();
         // Core translations
         try {
-            $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po');
+            $translations = $translations->addFromPoFile(
+                'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
+            );
             $translations->setDomain('shaarli');
             $this->translator->loadTranslations($translations);
         } catch (\InvalidArgumentException $e) {
@@ -129,11 +132,11 @@ class Languages
 
         // Default extension translation from the current theme
         $theme = $this->conf->get('theme');
-        $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language';
+        $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
         if (is_dir($themeTransFolder)) {
             try {
                 $translations = Translations::fromPoFile(
-                    $themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po'
+                    $themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
                 );
                 $translations->setDomain($theme);
                 $this->translator->loadTranslations($translations);
@@ -149,7 +152,7 @@ class Languages
 
             try {
                 $extension = Translations::fromPoFile(
-                    $translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po'
+                    $translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
                 );
                 $extension->setDomain($domain);
                 $this->translator->loadTranslations($extension);
@@ -183,6 +186,7 @@ class Languages
             'en' => t('English'),
             'fr' => t('French'),
             'jp' => t('Japanese'),
+            'ru' => t('Russian'),
         ];
     }
 }
index 5aec23c8d7b6bbf59305f3e651a689cd3d781a21..c4ff8d7abac86a87983cd9b58b9a980fae832540 100644 (file)
@@ -13,7 +13,7 @@ use WebThumbnailer\WebThumbnailer;
  */
 class Thumbnailer
 {
-    const COMMON_MEDIA_DOMAINS = [
+    protected const COMMON_MEDIA_DOMAINS = [
         'imgur.com',
         'flickr.com',
         'youtube.com',
@@ -31,9 +31,9 @@ class Thumbnailer
         'deviantart.com',
     ];
 
-    const MODE_ALL = 'all';
-    const MODE_COMMON = 'common';
-    const MODE_NONE = 'none';
+    public const MODE_ALL = 'all';
+    public const MODE_COMMON = 'common';
+    public const MODE_NONE = 'none';
 
     /**
      * @var WebThumbnailer instance.
@@ -60,7 +60,7 @@ class Thumbnailer
             // TODO: create a proper error handling system able to catch exceptions...
             die(t(
                 'php-gd extension must be loaded to use thumbnails. '
-                .'Thumbnails are now disabled. Please reload the page.'
+                . 'Thumbnails are now disabled. Please reload the page.'
             ));
         }
 
@@ -81,7 +81,8 @@ class Thumbnailer
      */
     public function get($url)
     {
-        if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON
+        if (
+            $this->conf->get('thumbnails.mode') === self::MODE_COMMON
             && ! $this->isCommonMediaOrImage($url)
         ) {
             return false;
index c1869ef87e1d0b96b105c792d8dc902c15ca5246..a420eb9674242b48ea1414aaf7438fc9639798c6 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Generates a list of available timezone continents and cities.
  *
@@ -43,7 +44,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
         // 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 = [];
@@ -60,7 +61,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
         }
 
         $continent = substr($tz, 0, $spos);
-        $city = substr($tz, $spos+1);
+        $city = substr($tz, $spos + 1);
         $cities[] = ['continent' => $continent, 'city' => $city];
         $continents[$continent] = true;
     }
@@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
 function isTimeZoneValid($continent, $city)
 {
     return in_array(
-        $continent.'/'.$city,
+        $continent . '/' . $city,
         timezone_identifiers_list()
     );
 }
index bcfda65c9ca14cef75aac304396f0512dba500d5..952378ab8620e02a360f73fd10ad4d1147afda9d 100644 (file)
@@ -1,24 +1,27 @@
 <?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;
 }
 
 /**
@@ -100,7 +103,7 @@ function escape($input)
     }
 
     if (is_array($input)) {
-        $out = array();
+        $out = [];
         foreach ($input as $key => $value) {
             $out[escape($key)] = escape($value);
         }
@@ -161,7 +164,7 @@ function checkDateFormat($format, $string)
  *
  * @return string $referer - final referer.
  */
-function generateLocation($referer, $host, $loopTerms = array())
+function generateLocation($referer, $host, $loopTerms = [])
 {
     $finalReferer = './?';
 
@@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array())
 function autoLocale($headerLocale)
 {
     // Default if browser does not send HTTP_ACCEPT_LANGUAGE
-    $locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8');
+    $locales = ['en_US', 'en_US.utf8', 'en_US.UTF-8'];
     if (! empty($headerLocale)) {
         if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
             $attempts = [];
@@ -324,6 +327,23 @@ function format_date($date, $time = true, $intl = true)
     return $formatter->format($date);
 }
 
+/**
+ * Format the date month according to the locale.
+ *
+ * @param DateTimeInterface $date to format.
+ *
+ * @return bool|string Formatted date, or false if the input is invalid.
+ */
+function format_month(DateTimeInterface $date)
+{
+    if (! $date instanceof DateTimeInterface) {
+        return false;
+    }
+
+    return strftime('%B', $date->getTimestamp());
+}
+
+
 /**
  * Check if the input is an integer, no matter its real type.
  *
@@ -357,13 +377,15 @@ function return_bytes($val)
         return $val;
     }
     $val = trim($val);
-    $last = strtolower($val[strlen($val)-1]);
+    $last = strtolower($val[strlen($val) - 1]);
     $val = intval(substr($val, 0, -1));
     switch ($last) {
         case 'g':
             $val *= 1024;
+        // do no break in order 1024^2 for each unit
         case 'm':
             $val *= 1024;
+        // do no break in order 1024^2 for each unit
         case 'k':
             $val *= 1024;
     }
@@ -452,14 +474,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
  * Wrapper function for translation which match the API
  * of gettext()/_() and ngettext().
  *
- * @param string $text   Text to translate.
- * @param string $nText  The plural message ID.
- * @param int    $nb     The number of items for plural forms.
- * @param string $domain The domain where the translation is stored (default: shaarli).
+ * @param string $text      Text to translate.
+ * @param string $nText     The plural message ID.
+ * @param int    $nb        The number of items for plural forms.
+ * @param string $domain    The domain where the translation is stored (default: shaarli).
+ * @param array  $variables Associative array of variables to replace in translated text.
+ * @param bool   $fixCase   Apply `ucfirst` on the translated string, might be useful for strings with variables.
  *
  * @return string Text translated.
  */
-function t($text, $nText = '', $nb = 1, $domain = 'shaarli')
+function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
+{
+    $postFunction = $fixCase ? 'ucfirst' : function ($input) {
+        return $input;
+    };
+
+    return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
+}
+
+/**
+ * Converts an exception into a printable stack trace string.
+ */
+function exception2text(Throwable $e): string
 {
-    return dn__($domain, $text, $nText, $nb);
+    return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
 }
index adc8b2666306d185f70fb0668fcefdf2b40b7d13..9fb883589d43a61aff1003882d0531ac8fda3979 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Api;
 
 use malkusch\lock\mutex\FlockMutex;
@@ -108,7 +109,8 @@ class ApiMiddleware
      */
     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');
index eb1ca9bc2b6230e944350759c33386acced2770f..9228bb2da768fcb7e8ba187f6494ff6fd8671666 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Api;
 
 use Shaarli\Api\Exceptions\ApiAuthorizationException;
@@ -27,7 +28,7 @@ class ApiUtils
             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');
         }
@@ -42,7 +43,8 @@ class ApiUtils
             throw new ApiAuthorizationException('Invalid JWT payload');
         }
 
-        if (empty($payload->iat)
+        if (
+            empty($payload->iat)
             || $payload->iat > time()
             || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
         ) {
@@ -89,13 +91,17 @@ class ApiUtils
      * If no URL is provided, it will generate a local note URL.
      * If no title is provided, it will use the URL as title.
      *
-     * @param array|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'])) {
@@ -107,6 +113,15 @@ class ApiUtils
         $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);
 
index 505647a9568599c3eb97c8bbc519fb9e9a3837f3..d83a3a25c97af2d26b40ce9df308fc71fdeba667 100644 (file)
@@ -1,6 +1,5 @@
 <?php
 
-
 namespace Shaarli\Api\Controllers;
 
 use Shaarli\Api\Exceptions\ApiBadParametersException;
index 12f6b2f012e4964bd279f0c832042ac243314095..ae7db93e5c07bbbcf1d691884fc67d8f1c6710b0 100644 (file)
@@ -29,13 +29,13 @@ class Info extends ApiController
         $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);
index 73a1b84e1727e1a4567e53ec9db6fe1299ce2b39..b83b2260f4ef670ad091c35d55620968eabd38ad 100644 (file)
@@ -117,9 +117,14 @@ class Links extends ApiController
     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(
@@ -131,7 +136,7 @@ class Links extends ApiController
 
         $this->bookmarkService->add($bookmark);
         $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
-        $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]);
+        $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
         return $response->withAddedHeader('Location', $redirect)
                         ->withJson($out, 201, $this->jsonStyle);
     }
@@ -157,9 +162,14 @@ class Links extends ApiController
         $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
         ) {
index 0e3f47769943b7447b0e3c5864ca1226868e99d5..c77e9eea8eb61cd080aa76ff63a7765df7481bcd 100644 (file)
@@ -28,7 +28,7 @@ class ApiAuthorizationException extends ApiException
      */
     public function setMessage($message)
     {
-        $original = $this->debug === true ? ': '. $this->getMessage() : '';
+        $original = $this->debug === true ? ': ' . $this->getMessage() : '';
         $this->message = $message . $original;
     }
 }
index d6b66323279f86e4dd886c0477f9c41e4f67a9bb..7deafb961fc33af72f9b470c7b082e424a61e40d 100644 (file)
@@ -44,7 +44,7 @@ abstract class ApiException extends \Exception
         }
         return [
             'message' => $this->getMessage(),
-            'stacktrace' => get_class($this) .': '. $this->getTraceAsString()
+            'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
         ];
     }
 
index ea565d1f689d0068b7327211025581546189a4d2..4238ef259a8938eacaba721d6ca92e9cac6d54ae 100644 (file)
@@ -19,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException;
 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;
@@ -60,11 +60,13 @@ class Bookmark
     /**
      * 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;
@@ -77,7 +79,7 @@ class Bookmark
         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'];
@@ -104,7 +106,8 @@ class Bookmark
      */
     public function validate(): void
     {
-        if ($this->id === null
+        if (
+            $this->id === null
             || ! is_int($this->id)
             || empty($this->shortUrl)
             || empty($this->created)
@@ -112,7 +115,7 @@ class Bookmark
             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;
@@ -348,7 +351,12 @@ class Bookmark
      */
     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;
     }
@@ -377,6 +385,24 @@ class Bookmark
         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.
      *
@@ -402,11 +428,13 @@ class Bookmark
     }
 
     /**
-     * @return string Bookmark's tags as a string, separated by a space
+     * @param string $separator Tags separator loaded from the config file.
+     *
+     * @return string Bookmark's tags as a string, separated by a separator
      */
-    public function getTagsString(): string
+    public function getTagsString(string $separator = ' '): string
     {
-        return implode(' ', $this->getTags());
+        return tags_array2str($this->getTags(), $separator);
     }
 
     /**
@@ -426,19 +454,13 @@ class Bookmark
      *   - 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;
     }
@@ -489,7 +511,7 @@ class Bookmark
      */
     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);
         }
     }
@@ -501,7 +523,7 @@ class Bookmark
      */
     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);
         }
index 67bb3b73d55fbcca2f68c3a651f76fb873f140a3..b93281166df4ef676500eff9a4319d48ade1c81c 100644 (file)
@@ -72,7 +72,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
      */
     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()
@@ -222,7 +223,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
      */
     public function getByUrl(string $url): ?Bookmark
     {
-        if (! empty($url)
+        if (
+            ! empty($url)
             && isset($this->urls[$url])
             && isset($this->bookmarks[$this->urls[$url]])
         ) {
index eb7899bf7edc24b85ed4462fbb0f24dd50dedd6c..6666a251c821a9eb83e710ddb48973e14008261d 100644 (file)
@@ -69,7 +69,7 @@ class BookmarkFileService implements BookmarkServiceInterface
         } else {
             try {
                 $this->bookmarks = $this->bookmarksIO->read();
-            } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
+            } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
                 $this->bookmarks = new BookmarkArray();
 
                 if ($this->isLoggedIn) {
@@ -85,25 +85,29 @@ class BookmarkFileService implements BookmarkServiceInterface
             if (! $this->bookmarks instanceof BookmarkArray) {
                 $this->migrate();
                 exit(
-                    'Your data store has been migrated, please reload the page.'. PHP_EOL .
+                    'Your data store has been migrated, please reload the page.' . PHP_EOL .
                     'If this message keeps showing up, please delete data/updates.txt file.'
                 );
             }
         }
 
-        $this->bookmarkFilter = new BookmarkFilter($this->bookmarks);
+        $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
     }
 
     /**
      * @inheritDoc
      */
-    public function findByHash(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;
@@ -162,7 +166,8 @@ class BookmarkFileService implements BookmarkServiceInterface
         }
 
         $bookmark = $this->bookmarks[$id];
-        if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
+        if (
+            ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
             || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
         ) {
             throw new Exception('Unauthorized');
@@ -262,7 +267,8 @@ class BookmarkFileService implements BookmarkServiceInterface
         }
 
         $bookmark = $this->bookmarks[$id];
-        if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
+        if (
+            ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
             || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
         ) {
             return false;
@@ -304,7 +310,8 @@ class BookmarkFileService implements BookmarkServiceInterface
         $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)
@@ -340,26 +347,42 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @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;
     }
 
     /**
@@ -386,14 +409,14 @@ class BookmarkFileService implements BookmarkServiceInterface
             false
         );
         $updater = new LegacyUpdater(
-            UpdaterUtils::read_updates_file($this->conf->get('resource.updates')),
+            UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
             $bookmarkDb,
             $this->conf,
             true
         );
         $newUpdates = $updater->update();
         if (! empty($newUpdates)) {
-            UpdaterUtils::write_updates_file(
+            UpdaterUtils::writeUpdatesFile(
                 $this->conf->get('resource.updates'),
                 $updater->getDoneUpdates()
             );
index c79386ea7ba750db4d1d7d7974ea7564154e943a..db83c51c135e012ce7c693a0ab6eee5f82070208 100644 (file)
@@ -6,6 +6,7 @@ namespace Shaarli\Bookmark;
 
 use Exception;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Config\ConfigManager;
 
 /**
  * Class LinkFilter.
@@ -58,12 +59,16 @@ class BookmarkFilter
      */
     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;
     }
 
     /**
@@ -107,10 +112,14 @@ class BookmarkFilter
                     $filtered = $this->bookmarks;
                 }
                 if (!empty($request[0])) {
-                    $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
+                    $filtered = (new BookmarkFilter($filtered, $this->conf))
+                        ->filterTags($request[0], $casesensitive, $visibility)
+                    ;
                 }
                 if (!empty($request[1])) {
-                    $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility);
+                    $filtered = (new BookmarkFilter($filtered, $this->conf))
+                        ->filterFulltext($request[1], $visibility)
+                    ;
                 }
                 return $filtered;
             case self::$FILTER_TEXT:
@@ -141,7 +150,7 @@ class BookmarkFilter
             return $this->bookmarks;
         }
 
-        $out = array();
+        $out = [];
         foreach ($this->bookmarks as $key => $value) {
             if ($value->isPrivate() && $visibility === 'private') {
                 $out[$key] = $value;
@@ -280,8 +289,9 @@ class BookmarkFilter
      *
      * @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
@@ -295,12 +305,13 @@ class BookmarkFilter
             $i = 0; // start at first character
             $regex = '(?='; // use positive lookahead
         }
-        $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
+        // before tag may only be the separator or the beginning
+        $regex .= '.*(?:^|' . $tagsSeparator . ')';
         // iterate over string, separating it into placeholder and content
         for (; $i < $len; $i++) {
             if ($tag[$i] === '*') {
                 // placeholder found
-                $regex .= '[^ ]*?';
+                $regex .= '[^' . $tagsSeparator . ']*?';
             } else {
                 // regular characters
                 $offset = strpos($tag, '*', $i);
@@ -316,7 +327,8 @@ class BookmarkFilter
                 $i = $offset;
             }
         }
-        $regex .= '(?:$| ))'; // after the tag may only be a space or the end
+        // after the tag may only be the separator or the end
+        $regex .= '(?:$|' . $tagsSeparator . '))';
         return $regex;
     }
 
@@ -334,14 +346,15 @@ class BookmarkFilter
      */
     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);
         }
@@ -358,7 +371,7 @@ class BookmarkFilter
         }
 
         // build regex from all tags
-        $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
+        $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
         if (!$casesensitive) {
             // make regex case insensitive
             $re .= 'i';
@@ -378,10 +391,11 @@ class BookmarkFilter
                     continue;
                 }
             }
-            $search = $link->getTagsString(); // build search string, start with tags of current link
+            // build search string, start with tags of current link
+            $search = $link->getTagsString($tagsSeparator);
             if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
                 // description given and at least one possible tag found
-                $descTags = array();
+                $descTags = [];
                 // find all tags in the form of #tag in the description
                 preg_match_all(
                     '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -390,9 +404,9 @@ class BookmarkFilter
                 );
                 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
@@ -422,7 +436,7 @@ class BookmarkFilter
                 }
             }
 
-            if (empty(trim($link->getTagsString()))) {
+            if (empty($link->getTags())) {
                 $filtered[$key] = $link;
             }
         }
@@ -537,10 +551,11 @@ class BookmarkFilter
      */
     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;
@@ -548,7 +563,7 @@ class BookmarkFilter
         $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;
     }
index f40fa476247ff2fc9fd75c1048c4b7a340516b61..8439d470da21fdff3d0b7da95230de0594e5a341 100644 (file)
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Shaarli\Bookmark;
 
+use malkusch\lock\exception\LockAcquireException;
 use malkusch\lock\mutex\Mutex;
 use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
@@ -80,7 +81,7 @@ class BookmarkIO
         }
 
         $content = null;
-        $this->mutex->synchronized(function () use (&$content) {
+        $this->synchronized(function () use (&$content) {
             $content = file_get_contents($this->datastore);
         });
 
@@ -112,18 +113,35 @@ class BookmarkIO
         if (is_file($this->datastore) && !is_writeable($this->datastore)) {
             // The datastore exists but is not writeable
             throw new NotWritableDataStoreException($this->datastore);
-        } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
+        } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
             // The datastore does not exist and its parent directory is not writeable
             throw new NotWritableDataStoreException(dirname($this->datastore));
         }
 
-        $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();
+        }
+    }
 }
index 04b996f3e6500edc13d8e96353ede55ad250323d..8ab5c441a6eb163f2293f782965ca2ad7f0a3ccc 100644 (file)
@@ -13,6 +13,9 @@ namespace Shaarli\Bookmark;
  * To prevent data corruption, it does not overwrite existing bookmarks,
  * even though there should not be any.
  *
+ * We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
+ * @phpcs:disable Generic.Files.LineLength.TooLong
+ *
  * @package Shaarli\Bookmark
  */
 class BookmarkInitializer
@@ -36,10 +39,10 @@ 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.
@@ -54,7 +57,7 @@ Now you can edit or delete the default shaares.
         $bookmark = new Bookmark();
         $bookmark->setTitle(t('Note: Shaare descriptions'));
         $bookmark->setDescription(t(
-'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
+            'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
 This note is private, so you are the only one able to see it while logged in.
 
 You can use this to keep notes, post articles, code snippets, and much more.
@@ -91,7 +94,7 @@ Markdown also supports tables:
             'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
         );
         $bookmark->setDescription(t(
-'Welcome to Shaarli!
+            'Welcome to Shaarli!
 
 Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
 You can add a description to your bookmarks, such as this one, and tag them.
index 37a54d03ece93be2642974ee53a8b01cf699a217..08cdbb4ed4055cc3f6ef2672b991f9c3b1cfeda7 100644 (file)
@@ -20,13 +20,14 @@ interface BookmarkServiceInterface
     /**
      * 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
@@ -155,22 +156,29 @@ interface BookmarkServiceInterface
     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.
index faf5dbfd4fe24906bf980d8f4cc72e0472b7e008..0ab2d2138c5a01f620744dfac40e091ddd3aadaa 100644 (file)
@@ -67,17 +67,20 @@ function html_extract_tag($tag, $html)
     $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;
@@ -116,7 +119,7 @@ function hashtag_autolink($description, $indexUrl = '')
      * \p{Mn} - any non marking space (accents, umlauts, etc)
      */
     $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
-    $replacement = '$1<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);
 }
 
@@ -138,12 +141,17 @@ function space2nbsp($text)
  *
  * @param string $description shaare's description.
  * @param string $indexUrl    URL to Shaarli's index.
-
+ * @param bool   $autolink    Turn on/off automatic linkifications of URLs and hashtags
+ *
  * @return string formatted description.
  */
-function format_description($description, $indexUrl = '')
+function format_description($description, $indexUrl = '', $autolink = true)
 {
-    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl)));
+    if ($autolink) {
+        $description = hashtag_autolink(text2clickable($description), $indexUrl);
+    }
+
+    return nl2br(space2nbsp($description));
 }
 
 /**
@@ -171,3 +179,49 @@ function is_note($linkUrl)
 {
     return isset($linkUrl[0]) && $linkUrl[0] === '?';
 }
+
+/**
+ * Extract an array of tags from a given tag string, with provided separator.
+ *
+ * @param string|null $tags      String containing a list of tags separated by $separator.
+ * @param string      $separator Shaarli's default: ' ' (whitespace)
+ *
+ * @return array List of tags
+ */
+function tags_str2array(?string $tags, string $separator): array
+{
+    // For whitespaces, we use the special \s regex character
+    $separator = $separator === ' ' ? '\s' : $separator;
+
+    return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
+}
+
+/**
+ * Return a tag string with provided separator from a list of tags.
+ * Note that given array is clean up by tags_filter().
+ *
+ * @param array|null $tags      List of tags
+ * @param string     $separator
+ *
+ * @return string
+ */
+function tags_array2str(?array $tags, string $separator): string
+{
+    return implode($separator, tags_filter($tags, $separator));
+}
+
+/**
+ * Clean an array of tags: trim + remove empty entries
+ *
+ * @param array|null $tags List of tags
+ * @param string     $separator
+ *
+ * @return array
+ */
+function tags_filter(?array $tags, string $separator): array
+{
+    $trimDefault = " \t\n\r\0\x0B";
+    return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
+        return trim($entry, $trimDefault . $separator);
+    }, $tags ?? [])));
+}
index 827a3d358ae98fb0adc15cf482ff06567bbd54ab..a91d1efaa8572859b3e47a97f9d18d476ee86985 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Bookmark\Exception;
 
 use Exception;
index cd48c1e6e517f2242e804112299a6532a33537d1..16a98470a018b2a5ea4635caff6e86a1a5657009 100644 (file)
@@ -1,7 +1,7 @@
 <?php
 
-
 namespace Shaarli\Bookmark\Exception;
 
-
-class EmptyDataStoreException extends \Exception {}
+class EmptyDataStoreException extends \Exception
+{
+}
index 10c84a6d2b66cb7d5a6c7ee2015c96f805d5acf0..fe184f8c1b71595f1ea765893fef26a7d09de2c0 100644 (file)
@@ -16,14 +16,14 @@ class InvalidBookmarkException 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);
         }
     }
index 95f34b505fdfcd028bf90c004a0f72c7ec5f0640..df91f3bce9c25c3101aac855ef99cbdbaa700406 100644 (file)
@@ -1,9 +1,7 @@
 <?php
 
-
 namespace Shaarli\Bookmark\Exception;
 
-
 class NotWritableDataStoreException extends \Exception
 {
     /**
@@ -13,7 +11,7 @@ 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.';
     }
 }
index 3efe5b6fb941b2dafb7107cea11cb59f1bcb59e6..a623bc8ba142a76781345d806310a3d4f536ba58 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Config;
 
 /**
index c0c0dab9ab9df4ce10f0a8217ae28a88dc011483..23b22269540d46f3a03770ea50865738e31c1209 100644 (file)
@@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
         $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(
@@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
      */
     public static function getPhpHeaders()
     {
-        return '<?php /*'. PHP_EOL;
+        return '<?php /*';
     }
 
     /**
@@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
      */
     public static function getPhpSuffix()
     {
-        return PHP_EOL . '*/ ?>';
+        return '*/ ?>';
     }
 }
index 4c98be3051e3fafa7f9aa7b0891392a2c116f7db..717a038f7912fa2bc2b403d2d38a1101f6041238 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Config;
 
 use Shaarli\Config\Exception\MissingFieldConfigException;
@@ -20,7 +21,7 @@ class ConfigManager
      */
     protected static $NOT_FOUND = 'NOT_FOUND';
 
-    public static $DEFAULT_PLUGINS = array('qrcode');
+    public static $DEFAULT_PLUGINS = ['qrcode'];
 
     /**
      * @var string Config folder.
@@ -133,7 +134,7 @@ class ConfigManager
     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.
@@ -160,7 +161,7 @@ class ConfigManager
     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.
@@ -213,7 +214,7 @@ class ConfigManager
     public function write($isLoggedIn)
     {
         // These fields are required in configuration.
-        $mandatoryFields = array(
+        $mandatoryFields = [
             'credentials.login',
             'credentials.hash',
             'credentials.salt',
@@ -222,7 +223,7 @@ class ConfigManager
             'general.title',
             'general.header_link',
             'privacy.default_private_links',
-        );
+        ];
 
         // Only logged in user can alter config.
         if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@@ -366,10 +367,12 @@ class ConfigManager
         $this->setEmpty('general.links_per_page', 20);
         $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
         $this->setEmpty('general.default_note_title', 'Note: ');
-        $this->setEmpty('general.retrieve_description', false);
+        $this->setEmpty('general.retrieve_description', true);
+        $this->setEmpty('general.enable_async_metadata', true);
+        $this->setEmpty('general.tags_separator', ' ');
 
-        $this->setEmpty('updates.check_updates', false);
-        $this->setEmpty('updates.check_updates_branch', 'stable');
+        $this->setEmpty('updates.check_updates', true);
+        $this->setEmpty('updates.check_updates_branch', 'latest');
         $this->setEmpty('updates.check_updates_interval', 86400);
 
         $this->setEmpty('feed.rss_permalinks', true);
@@ -390,7 +393,7 @@ class ConfigManager
         $this->setEmpty('translation.mode', 'php');
         $this->setEmpty('translation.extensions', []);
 
-        $this->setEmpty('plugins', array());
+        $this->setEmpty('plugins', []);
 
         $this->setEmpty('formatter', 'markdown');
     }
index cad3459462b2f396039ddd8c085794fe555114be..53d6a7a357c910ab0f2789fdacac5728f85da817 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Config;
 
 /**
@@ -12,7 +13,7 @@ class ConfigPhp implements ConfigIO
     /**
      * @var array List of config key without group.
      */
-    public static $ROOT_KEYS = array(
+    public static $ROOT_KEYS = [
         'login',
         'hash',
         'salt',
@@ -22,7 +23,7 @@ class ConfigPhp implements ConfigIO
         'redirector',
         'disablesessionprotection',
         'privateLinkByDefault',
-    );
+    ];
 
     /**
      * Map legacy config keys with the new ones.
@@ -31,7 +32,7 @@ class ConfigPhp implements ConfigIO
      *
      * @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',
@@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO
         'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
         'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
         'security.open_shaarli' => 'config.OPEN_SHAARLI',
-    );
+    ];
 
     /**
      * @inheritdoc
@@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO
     public function read($filepath)
     {
         if (! file_exists($filepath) || ! is_readable($filepath)) {
-            return array();
+            return [];
         }
 
         include $filepath;
 
-        $out = array();
+        $out = [];
         foreach (self::$ROOT_KEYS as $key) {
             $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
         }
@@ -95,7 +96,7 @@ class ConfigPhp implements ConfigIO
      */
     public function write($filepath, $conf)
     {
-        $configStr = '<?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;
@@ -106,8 +107,8 @@ class ConfigPhp implements ConfigIO
         foreach ($conf['config'] as $key => $value) {
             $configStr .= '$GLOBALS[\'config\'][\''
                 . $key
-                .'\'] = '
-                .var_export($conf['config'][$key], true).';'
+                . '\'] = '
+                . var_export($conf['config'][$key], true) . ';'
                 . PHP_EOL;
         }
 
@@ -115,18 +116,19 @@ class ConfigPhp implements ConfigIO
             foreach ($conf['plugins'] as $key => $value) {
                 $configStr .= '$GLOBALS[\'plugins\'][\''
                     . $key
-                    .'\'] = '
-                    .var_export($conf['plugins'][$key], true).';'
+                    . '\'] = '
+                    . var_export($conf['plugins'][$key], true) . ';'
                     . PHP_EOL;
             }
         }
 
-        if (!file_put_contents($filepath, $configStr)
+        if (
+            !file_put_contents($filepath, $configStr)
             || strcmp(file_get_contents($filepath), $configStr) != 0
         ) {
             throw new \Shaarli\Exceptions\IOException(
                 $filepath,
-                t('Shaarli could not create the config file. '.
+                t('Shaarli could not create the config file. ' .
                   'Please make sure Shaarli has the right to write in the folder is it installed in.')
             );
         }
index ea8dfbdade4f0f0776eff517545e1b3f96f536f3..6cadef126c3a10d91c24e7f885e1d8da7eefba2b 100644 (file)
@@ -39,8 +39,8 @@ function save_plugin_config($formData)
         throw new PluginConfigOrderException();
     }
 
-    $plugins = array();
-    $newEnabledPlugins = array();
+    $plugins = [];
+    $newEnabledPlugins = [];
     foreach ($formData as $key => $data) {
         if (startsWith($key, 'order')) {
             continue;
@@ -62,7 +62,7 @@ function save_plugin_config($formData)
         throw new PluginConfigOrderException();
     }
 
-    $finalPlugins = array();
+    $finalPlugins = [];
     // Make plugins order continuous.
     foreach ($plugins as $plugin) {
         $finalPlugins[] = $plugin;
@@ -81,7 +81,7 @@ function save_plugin_config($formData)
  */
 function validate_plugin_order($formData)
 {
-    $orders = array();
+    $orders = [];
     foreach ($formData as $key => $value) {
         // No duplicate order allowed.
         if (in_array($value, $orders, true)) {
index 9e0a93594d21f61ee76a8d85e2ea231f848ac83e..a5f4356ae6894d809e5628c1b84cccd7edc93974 100644 (file)
@@ -1,6 +1,5 @@
 <?php
 
-
 namespace Shaarli\Config\Exception;
 
 /**
index 72311faeffc98ee0a76e7fc3bc884e6a69f8ca12..b041c6e3d4cd66d651eb209ffd4b8ec07a34c438 100644 (file)
@@ -1,6 +1,5 @@
 <?php
 
-
 namespace Shaarli\Config\Exception;
 
 /**
index c21d58ddde92f8eb0794d1fc99f24217d11f94b7..6d69a880f4fb0694e762b42a38df432c445bca4a 100644 (file)
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace Shaarli\Container;
 
 use malkusch\lock\mutex\FlockMutex;
+use Psr\Log\LoggerInterface;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
@@ -14,6 +15,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
 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;
@@ -48,6 +50,12 @@ class ContainerBuilder
     /** @var LoginManager */
     protected $login;
 
+    /** @var PluginManager */
+    protected $pluginManager;
+
+    /** @var LoggerInterface */
+    protected $logger;
+
     /** @var string|null */
     protected $basePath = null;
 
@@ -55,12 +63,16 @@ class ContainerBuilder
         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
@@ -71,11 +83,10 @@ class ContainerBuilder
         $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'));
@@ -90,24 +101,21 @@ class ContainerBuilder
             );
         };
 
+        $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,
@@ -145,7 +153,7 @@ class ContainerBuilder
 
         $container['updater'] = function (ShaarliContainer $container): Updater {
             return new Updater(
-                UpdaterUtils::read_updates_file($container->conf->get('resource.updates')),
+                UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
                 $container->bookmarkService,
                 $container->conf,
                 $container->loginManager->isLoggedIn()
index 66e669aaed3fd6760c966d0fbfd1689696c74f20..3e5bd25269e9ca24964c5f45adc0be16fa23e027 100644 (file)
@@ -4,12 +4,14 @@ declare(strict_types=1);
 
 namespace Shaarli\Container;
 
+use Psr\Log\LoggerInterface;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Feed\FeedBuilder;
 use Shaarli\Formatter\FormatterFactory;
 use Shaarli\History;
 use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
 use Shaarli\Netscape\NetscapeBookmarkUtils;
 use Shaarli\Plugin\PluginManager;
 use Shaarli\Render\PageBuilder;
@@ -35,6 +37,8 @@ use Slim\Container;
  * @property History                  $history
  * @property HttpAccess               $httpAccess
  * @property LoginManager             $loginManager
+ * @property LoggerInterface          $logger
+ * @property MetadataRetriever        $metadataRetriever
  * @property NetscapeBookmarkUtils    $netscapeBookmarkUtils
  * @property callable                 $notFoundHandler       Overrides default Slim exception display
  * @property PageBuilder              $pageBuilder
index 2aa25e5c55be709b4dfeb2c8c1451ef437597a40..c1a9ffbe5bd41ac09dba84f3e6eb1132bce75706 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Exceptions;
 
 use Exception;
index d809bdd962ca901c54536309d17068652ff5f3bd..c23c200f3370869b952cc681daf98cfc456c4666 100644 (file)
@@ -1,34 +1,43 @@
 <?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;
     }
 
     /**
@@ -41,10 +50,20 @@ class CachedPage
         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);
     }
 
     /**
index f70fce4fb7fa0589269256c1907ffa5654051d01..ed62af26e1c4b26ae4eb481e37ab32f0a5d9988a 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Feed;
 
 use DateTime;
@@ -107,14 +108,14 @@ class FeedBuilder
         $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
 
         // Can't use array_keys() because $link is a LinkDB instance and not a real array.
-        $keys = array();
+        $keys = [];
         foreach ($linksToDisplay as $key => $value) {
             $keys[] = $key;
         }
 
         $pageaddr = escape(index_url($this->serverInfo));
         $this->formatter->addContextData('index_url', $pageaddr);
-        $linkDisplayed = array();
+        $linkDisplayed = [];
         for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
             $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
         }
@@ -176,9 +177,9 @@ class FeedBuilder
         $data = $this->formatter->format($link);
         $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
         if ($this->usePermalinks === true) {
-            $permalink = '<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>&#8212; ' . $permalink;
 
index d58a5e39dde46ca5f5f0c71ac80e1f60fde8e55b..7e0afafc8a413f375e14992181b03c2c294e2d47 100644 (file)
@@ -12,8 +12,8 @@ namespace Shaarli\Formatter;
  */
 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
@@ -46,8 +46,13 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
             $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);
     }
 
     /**
@@ -63,15 +68,16 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
      */
     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);
 
@@ -83,7 +89,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
      */
     protected function formatTagString($bookmark)
     {
-        return implode(' ', $this->formatTagList($bookmark));
+        return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
     }
 
     /**
index e1b7f705e29b0e87ee8841c9b2e298a807c4cc2c..124ce78bdc8224e07e034de60061a665fa886633 100644 (file)
@@ -267,7 +267,7 @@ abstract class BookmarkFormatter
      */
     protected function formatTagString($bookmark)
     {
-        return implode(' ', $this->formatTagList($bookmark));
+        return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
     }
 
     /**
@@ -351,6 +351,7 @@ abstract class BookmarkFormatter
 
     /**
      * Format tag list, e.g. remove private tags if the user is not logged in.
+     * TODO: this method is called multiple time to format tags, the result should be cached.
      *
      * @param array $tags
      *
index f7714be9ed34df27a70f971aa28aeef8e9c33b48..ee4e8dca4f993b9a6c02894e674150615e182315 100644 (file)
@@ -16,7 +16,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
     /**
      * When this tag is present in a bookmark, its description should not be processed with Markdown
      */
-    const NO_MD_TAG = 'nomarkdown';
+    public const NO_MD_TAG = 'nomarkdown';
 
     /** @var \Parsedown instance */
     protected $parsedown;
@@ -71,7 +71,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
         $processedDescription = $this->replaceTokens($processedDescription);
 
         if (!empty($processedDescription)) {
-            $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
+            $processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
         }
 
         return $processedDescription;
@@ -110,7 +110,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
             function ($match) use ($allowedProtocols, $indexUrl) {
                 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
                 $link .= whitelist_protocols($match[1], $allowedProtocols);
-                return ']('. $link.')';
+                return '](' . $link . ')';
             },
             $description
         );
@@ -137,7 +137,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
          * \p{Mn} - any non marking space (accents, umlauts, etc)
          */
         $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
-        $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
+        $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)';
 
         $descriptionLines = explode(PHP_EOL, $description);
         $descriptionOut = '';
@@ -178,17 +178,17 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
      */
     protected function sanitizeHtml($description)
     {
-        $escapeTags = array(
+        $escapeTags = [
             'script',
             'style',
             'link',
             'iframe',
             'frameset',
             'frame',
-        );
+        ];
         foreach ($escapeTags as $tag) {
             $description = preg_replace_callback(
-                '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is',
+                '#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
                 function ($match) {
                     return escape($match[0]);
                 },
index bc37227373b4d62f0ff5bb26b53fbc2a2f714018..4ff07cdf4019b837025740d44cf8f253b830f4d0 100644 (file)
@@ -10,4 +10,6 @@ namespace Shaarli\Formatter;
  *
  * @package Shaarli\Formatter
  */
-class BookmarkRawFormatter extends BookmarkFormatter {}
+class BookmarkRawFormatter extends BookmarkFormatter
+{
+}
index a029579f6908f5452056d0db8db16a87c57ee5d4..bb865aedfb06023372813b64c9defcf4f49d1d1d 100644 (file)
@@ -41,7 +41,7 @@ class FormatterFactory
     public function getFormatter(string $type = null): BookmarkFormatter
     {
         $type = $type ? $type : $this->conf->get('formatter', 'default');
-        $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter';
+        $className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
         if (!class_exists($className)) {
             $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
         }
index d1aa139989e2689ee61df29f47c2cd08bd2d9999..164217f4f27b83b45754cfcec8d33871a553a26b 100644 (file)
@@ -42,7 +42,8 @@ class ShaarliMiddleware
         $this->initBasePath($request);
 
         try {
-            if (!is_file($this->container->conf->getConfigFileExt())
+            if (
+                !is_file($this->container->conf->getConfigFileExt())
                 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
             ) {
                 return $response->withRedirect($this->container->basePath . '/install');
@@ -86,7 +87,8 @@ class ShaarliMiddleware
      */
     protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
     {
-        if (// if the user isn't logged in
+        if (
+// if the user isn't logged in
             !$this->container->loginManager->isLoggedIn()
             // and Shaarli doesn't have public content...
             && $this->container->conf->get('privacy.hide_public_links')
index 0ed7ad81086d93b6a0c0d39e3a539dd398a8fb62..dc421661c89ea0e2470a768284513a6267210a02 100644 (file)
@@ -51,7 +51,10 @@ class ConfigureController extends ShaarliAdminController
         $this->assignView('languages', Languages::getAvailableLanguages());
         $this->assignView('gd_enabled', extension_loaded('gd'));
         $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
-        $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+        $this->assignView(
+            'pagetitle',
+            t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
+        );
 
         return $response->write($this->render(TemplatePage::CONFIGURE));
     }
@@ -95,12 +98,15 @@ class ConfigureController extends ShaarliAdminController
         }
 
         $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
-        if ($thumbnailsMode !== Thumbnailer::MODE_NONE
+        if (
+            $thumbnailsMode !== Thumbnailer::MODE_NONE
             && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
         ) {
             $this->saveWarningMessage(
                 t('You have enabled or changed thumbnails mode.') .
-                '<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);
index 2be957fae0f4ec8c62e506f7da8589fca1d70244..f01d7e9becb2ef406c831d08ea5d32e29f40cd91 100644 (file)
@@ -23,7 +23,7 @@ class ExportController extends ShaarliAdminController
      */
     public function index(Request $request, Response $response): Response
     {
-        $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+        $this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
 
         return $response->write($this->render(TemplatePage::EXPORT));
     }
@@ -68,7 +68,7 @@ class ExportController extends ShaarliAdminController
         $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
         $response = $response->withHeader(
             'Content-disposition',
-            'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html'
+            'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html'
         );
 
         $this->assignView('date', $now->format(DateTime::RFC822));
index 758d5ef9454a0514316c5beff78181f3146708d8..c2ad6a09f3c107aea84366fda6a0cb42ed4b1ed3 100644 (file)
@@ -38,7 +38,7 @@ class ImportController extends ShaarliAdminController
                 true
             )
         );
-        $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+        $this->assignView('pagetitle', t('Import') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
 
         return $response->write($this->render(TemplatePage::IMPORT));
     }
@@ -64,7 +64,7 @@ class ImportController extends ShaarliAdminController
             $msg = sprintf(
                 t(
                     'The file you are trying to upload is probably bigger than what this webserver can accept'
-                    .' (%s). Please upload in smaller chunks.'
+                    . ' (%s). Please upload in smaller chunks.'
                 ),
                 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
             );
diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
deleted file mode 100644 (file)
index bb08348..0000000
+++ /dev/null
@@ -1,371 +0,0 @@
-<?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));
-    }
-}
index 2065c3e27cbdac21c43d68901c197aee05253805..8675a0c580bec714c4829948f24e04867f06acbe 100644 (file)
@@ -24,9 +24,15 @@ class ManageTagController extends ShaarliAdminController
         $fromTag = $request->getParam('fromtag') ?? '';
 
         $this->assignView('fromtag', escape($fromTag));
+        $separator = escape($this->container->conf->get('general.tags_separator', ' '));
+        if ($separator === ' ') {
+            $separator = '&nbsp;';
+            $this->assignView('tags_separator_desc', t('whitespace'));
+        }
+        $this->assignView('tags_separator', $separator);
         $this->assignView(
             'pagetitle',
-            t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+            t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
         );
 
         return $response->write($this->render(TemplatePage::CHANGE_TAG));
@@ -81,8 +87,35 @@ class ManageTagController extends ShaarliAdminController
 
         $this->saveSuccessMessage($alert);
 
-        $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag);
+        $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
 
         return $this->redirect($response, $redirect);
     }
+
+    /**
+     * POST /admin/tags/change-separator - Change tag separator
+     */
+    public function changeSeparator(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $reservedCharacters = ['-', '.', '*'];
+        $newSeparator = $request->getParam('separator');
+        if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
+            $this->saveErrorMessage(t('Tags separator must be a single character.'));
+        } elseif (in_array($newSeparator, $reservedCharacters, true)) {
+            $reservedCharacters = implode(' ', array_map(function (string $character) {
+                return '<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');
+    }
 }
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644 (file)
index 0000000..ff84594
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Controller used to retrieve/update bookmark's metadata.
+ */
+class MetadataController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
+     */
+    public function ajaxRetrieveTitle(Request $request, Response $response): Response
+    {
+        $url = $request->getParam('url');
+
+        // Only try to extract metadata from URL with HTTP(s) scheme
+        if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
+            return $response->withJson($this->container->metadataRetriever->retrieve($url));
+        }
+
+        return $response->withJson([]);
+    }
+}
index 5ec0d24b2fac824ce8d88e852cf8d62bb6809971..4aaf1f82ce8712a422e3ce77c1efdc3767883785 100644 (file)
@@ -25,7 +25,7 @@ class PasswordController extends ShaarliAdminController
 
         $this->assignView(
             'pagetitle',
-            t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+            t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
         );
     }
 
@@ -78,7 +78,7 @@ class PasswordController extends ShaarliAdminController
 
         // Save new password
         // Salt renders rainbow-tables attacks useless.
-        $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
+        $this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
         $this->container->conf->set(
             'credentials.hash',
             sha1(
index 8e05968199df9e099b61d16c7a3c9494a6e3a672..ae47c1af1b2eb8cc84bea8acbfb995fd4e3f00ca 100644 (file)
@@ -42,7 +42,7 @@ class PluginsController extends ShaarliAdminController
         $this->assignView('disabledPlugins', $disabledPlugins);
         $this->assignView(
             'pagetitle',
-            t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+            t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
         );
 
         return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
@@ -64,7 +64,7 @@ class PluginsController extends ShaarliAdminController
                 unset($parameters['parameters_form']);
                 unset($parameters['token']);
                 foreach ($parameters as $param => $value) {
-                    $this->container->conf->set('plugins.'. $param, escape($value));
+                    $this->container->conf->set('plugins.' . $param, escape($value));
                 }
             } else {
                 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php
new file mode 100644 (file)
index 0000000..4b74f4a
--- /dev/null
@@ -0,0 +1,101 @@
+<?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');
+    }
+}
index d9a7a2e09250d8871521af0c6541ca5df54276ea..0917b6d20fc333c2333932873654b81e6190a0db 100644 (file)
@@ -45,6 +45,4 @@ class SessionFilterController extends ShaarliAdminController
 
         return $this->redirectFromReferer($request, $response, ['visibility']);
     }
-
-
 }
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php
new file mode 100644 (file)
index 0000000..ab8e7f4
--- /dev/null
@@ -0,0 +1,34 @@
+<?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));
+    }
+}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
new file mode 100644 (file)
index 0000000..35837ba
--- /dev/null
@@ -0,0 +1,202 @@
+<?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')
+        );
+    }
+}
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
new file mode 100644 (file)
index 0000000..fb9cacc
--- /dev/null
@@ -0,0 +1,274 @@
+<?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;
+    }
+}
index 4dc09d388c86b932c9fe7cb0618a9aa222f8712a..94d97d4bd3bcbd53b3c89bd06a0df14049fc5988 100644 (file)
@@ -34,7 +34,7 @@ class ThumbnailsController extends ShaarliAdminController
         $this->assignView('ids', $ids);
         $this->assignView(
             'pagetitle',
-            t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+            t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
         );
 
         return $response->write($this->render(TemplatePage::THUMBNAILS));
index a87f20d29f9d18a443cd066a7f434a60fc61e9b3..560e5e3e76deaa0491241e105e8b10584cce1ff5 100644 (file)
@@ -28,7 +28,7 @@ class ToolsController extends ShaarliAdminController
             $this->assignView($key, $value);
         }
 
-        $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+        $this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
 
         return $response->write($this->render(TemplatePage::TOOLS));
     }
index 18368751be156b13b09b72a2b48aac0fddf484ec..fe8231be1574b0f1d07d6f62f2d7df3faa85b71f 100644 (file)
@@ -35,7 +35,8 @@ class BookmarkListController extends ShaarliVisitorController
         $formatter->addContextData('base_path', $this->container->basePath);
 
         $searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
-        $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));;
+        $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));
+        ;
 
         // Filter bookmarks according search parameters.
         $visibility = $this->container->sessionManager->getSessionParameter('visibility');
@@ -95,6 +96,10 @@ class BookmarkListController extends ShaarliVisitorController
             $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
         }
 
+        $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
+        $searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
+        $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
+
         // Fill all template fields.
         $data = array_merge(
             $this->initializeTemplateVars(),
@@ -106,7 +111,7 @@ class BookmarkListController extends ShaarliVisitorController
                 'result_count' => count($linksToDisplay),
                 'search_term' => escape($searchTerm),
                 'search_tags' => escape($searchTags),
-                'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)),
+                'search_tags_url' => $searchTagsUrlEncoded,
                 'visibility' => $visibility,
                 'links' => $linkDisp,
             ]
@@ -119,8 +124,9 @@ class BookmarkListController extends ShaarliVisitorController
                 return '[' . $tag . ']';
             };
             $data['pagetitle'] .= ! empty($searchTags)
-                ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' '
-                : '';
+                ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
+                : ''
+            ;
             $data['pagetitle'] .= '- ';
         }
 
@@ -137,8 +143,10 @@ class BookmarkListController extends ShaarliVisitorController
      */
     public function permalink(Request $request, Response $response, array $args): Response
     {
+        $privateKey = $request->getParam('key');
+
         try {
-            $bookmark = $this->container->bookmarkService->findByHash($args['hash']);
+            $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
         } catch (BookmarkNotFoundException $e) {
             $this->assignView('error_message', $e->getMessage());
 
@@ -153,7 +161,7 @@ class BookmarkListController extends ShaarliVisitorController
         $data = array_merge(
             $this->initializeTemplateVars(),
             [
-                'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'),
+                'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
                 'links' => [$formatter->format($bookmark)],
             ]
         );
@@ -169,19 +177,25 @@ class BookmarkListController extends ShaarliVisitorController
      */
     protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
     {
-        // Logged in, thumbnails enabled, not a note, is HTTP
-        // and (never retrieved yet or no valid cache file)
-        if ($this->container->loginManager->isLoggedIn()
-            && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
-            && false !== $bookmark->getThumbnail()
-            && !$bookmark->isNote()
-            && (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail()))
-            && startsWith(strtolower($bookmark->getUrl()), 'http')
-        ) {
-            $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
-            $this->container->bookmarkService->set($bookmark, $writeDatastore);
-
-            return true;
+        if (false === $this->container->loginManager->isLoggedIn()) {
+            return false;
+        }
+
+        // If thumbnail should be updated, we reset it to null
+        if ($bookmark->shouldUpdateThumbnail()) {
+            $bookmark->setThumbnail(null);
+
+            // Requires an update, not async retrieval, thumbnails enabled
+            if (
+                $bookmark->shouldUpdateThumbnail()
+                && true !== $this->container->conf->get('general.enable_async_metadata', true)
+                && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
+            ) {
+                $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
+                $this->container->bookmarkService->set($bookmark, $writeDatastore);
+
+                return true;
+            }
         }
 
         return false;
@@ -198,6 +212,7 @@ class BookmarkListController extends ShaarliVisitorController
             'page_max' => '',
             'search_tags' => '',
             'result_count' => '',
+            'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
         ];
     }
 
index 07617cf11fdfe49b047c24d0641477ad6b66e8d4..29492a5f3c1f3acd50d65547d9d3543dbf7e7a8a 100644 (file)
@@ -5,8 +5,8 @@ declare(strict_types=1);
 namespace Shaarli\Front\Controller\Visitor;
 
 use DateTime;
-use DateTimeImmutable;
 use Shaarli\Bookmark\Bookmark;
+use Shaarli\Helper\DailyPageHelper;
 use Shaarli\Render\TemplatePage;
 use Slim\Http\Request;
 use Slim\Http\Response;
@@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
      */
     public function index(Request $request, Response $response): Response
     {
-        $day = $request->getQueryParam('day') ?? date('Ymd');
-
-        $availableDates = $this->container->bookmarkService->days();
-        $nbAvailableDates = count($availableDates);
-        $index = array_search($day, $availableDates);
-
-        if ($index === false) {
-            // no bookmarks for day, but at least one day with bookmarks
-            $day = $availableDates[$nbAvailableDates - 1] ?? $day;
-            $previousDay = $availableDates[$nbAvailableDates - 2] ?? '';
-        } else {
-            $previousDay = $availableDates[$index - 1] ?? '';
-            $nextDay = $availableDates[$index + 1] ?? '';
-        }
-
-        if ($day === date('Ymd')) {
-            $this->assignView('dayDesc', t('Today'));
-        } elseif ($day === date('Ymd', strtotime('-1 days'))) {
-            $this->assignView('dayDesc', t('Yesterday'));
-        }
-
-        try {
-            $linksToDisplay = $this->container->bookmarkService->filterDay($day);
-        } catch (\Exception $exc) {
-            $linksToDisplay = [];
-        }
+        $type = DailyPageHelper::extractRequestedType($request);
+        $format = DailyPageHelper::getFormatByType($type);
+        $latestBookmark = $this->container->bookmarkService->getLatest();
+        $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
+        $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
+        $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
+        $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
+
+        $linksToDisplay = $this->container->bookmarkService->findByDate(
+            $start,
+            $end,
+            $previousDay,
+            $nextDay
+        );
 
         $formatter = $this->container->formatterFactory->getFormatter();
         $formatter->addContextData('base_path', $this->container->basePath);
@@ -63,13 +51,15 @@ class DailyController extends ShaarliVisitorController
             $linksToDisplay[$key]['description'] = $bookmark->getDescription();
         }
 
-        $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
         $data = [
             'linksToDisplay' => $linksToDisplay,
-            'day' => $dayDate->getTimestamp(),
-            'dayDate' => $dayDate,
-            'previousday' => $previousDay ?? '',
-            'nextday' => $nextDay ?? '',
+            'dayDate' => $start,
+            'day' => $start->getTimestamp(),
+            'previousday' => $previousDay ? $previousDay->format($format) : '',
+            'nextday' => $nextDay ? $nextDay->format($format) : '',
+            'dayDesc' => $dailyDesc,
+            'type' => $type,
+            'localizedType' => $this->translateType($type),
         ];
 
         // Hooks are called before column construction so that plugins don't have to deal with columns.
@@ -82,7 +72,7 @@ class DailyController extends ShaarliVisitorController
         $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
         $this->assignView(
             'pagetitle',
-            t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle
+            $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
         );
 
         return $response->write($this->render(TemplatePage::DAILY));
@@ -96,9 +86,11 @@ class DailyController extends ShaarliVisitorController
     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)) {
@@ -106,11 +98,13 @@ class DailyController extends ShaarliVisitorController
         }
 
         $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;
             }
 
@@ -127,12 +121,19 @@ class DailyController extends ShaarliVisitorController
 
         /** @var Bookmark[] $bookmarks */
         foreach ($days as $day => $bookmarks) {
-            $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
+            $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
+            $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
+
+            // We only want the RSS entry to be published when the period is over.
+            if (new DateTime() < $endDateTime) {
+                continue;
+            }
+
             $dataPerDay[$day] = [
-                'date' => $dayDatetime,
-                'date_rss' => $dayDatetime->format(DateTime::RSS),
-                'date_human' => format_date($dayDatetime, false, true),
-                'absolute_url' => $indexUrl . 'daily?day=' . $day,
+                'date' => $endDateTime,
+                'date_rss' => $endDateTime->format(DateTime::RSS),
+                'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime, false),
+                'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
                 'links' => [],
             ];
 
@@ -141,16 +142,20 @@ class DailyController extends ShaarliVisitorController
 
                 // Make permalink URL absolute
                 if ($bookmark->isNote()) {
-                    $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl();
+                    $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
                 }
             }
         }
 
-        $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
-        $this->assignView('index_url', $indexUrl);
-        $this->assignView('page_url', $pageUrl);
-        $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false));
-        $this->assignView('days', $dataPerDay);
+        $this->assignAllView([
+            'title' => $this->container->conf->get('general.title', 'Shaarli'),
+            'index_url' => $indexUrl,
+            'page_url' => $pageUrl,
+            'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
+            'days' => $dataPerDay,
+            'type' => $type,
+            'localizedType' => $this->translateType($type),
+        ]);
 
         $rssContent = $this->render(TemplatePage::DAILY_RSS);
 
@@ -189,4 +194,13 @@ class DailyController extends ShaarliVisitorController
 
         return $columns;
     }
+
+    protected function translateType($type): string
+    {
+        return [
+            t('day') => t('Daily'),
+            t('week') => t('Weekly'),
+            t('month') => t('Monthly'),
+        ][t($type)] ?? t('Daily');
+    }
 }
index 10aa84c806ea444b85a692e1d6d9d188eed4cba4..428e82542df2b56695c05db30e3e68269fe09437 100644 (file)
@@ -26,12 +26,15 @@ class ErrorController extends ShaarliVisitorController
             $response = $response->withStatus($throwable->getCode());
         } else {
             // Internal error (any other Throwable)
-            if ($this->container->conf->get('dev.debug', false)) {
-                $this->assignView('message', $throwable->getMessage());
+            if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
+                $this->assignView('message', t('Error: ') . $throwable->getMessage());
                 $this->assignView(
-                    'stacktrace',
-                    nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString())
+                    'text',
+                    '<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.'));
             }
@@ -39,7 +42,6 @@ class ErrorController extends ShaarliVisitorController
             $response = $response->withStatus(500);
         }
 
-
         return $response->write($this->render('error'));
     }
 }
index 8d8b546aad35cc58573e0083b3ed9d283c0b71a7..edc7ef43a63122db2ea2e1f9a156c1f98bd74b3e 100644 (file)
@@ -27,7 +27,7 @@ class FeedController extends ShaarliVisitorController
 
     protected function processRequest(string $feedType, Request $request, Response $response): Response
     {
-        $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8');
+        $response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8');
 
         $pageUrl = page_url($this->container->environment);
         $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
index 7cb3277794fbe8c9b2929766b68cc9bcfad21c5b..418d4a49cdfffbb3a44e0712f4d08d9460677f74 100644 (file)
@@ -4,10 +4,10 @@ declare(strict_types=1);
 
 namespace Shaarli\Front\Controller\Visitor;
 
-use Shaarli\ApplicationUtils;
 use Shaarli\Container\ShaarliContainer;
 use Shaarli\Front\Exception\AlreadyInstalledException;
 use Shaarli\Front\Exception\ResourcePermissionException;
+use Shaarli\Helper\ApplicationUtils;
 use Shaarli\Languages;
 use Shaarli\Security\SessionManager;
 use Slim\Http\Request;
@@ -39,7 +39,8 @@ class InstallController extends ShaarliVisitorController
         // Before installation, we'll make sure that permissions are set properly, and sessions are working.
         $this->checkPermissions();
 
-        if (static::SESSION_TEST_VALUE
+        if (
+            static::SESSION_TEST_VALUE
             !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
         ) {
             $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
@@ -53,6 +54,21 @@ class InstallController extends ShaarliVisitorController
         $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'));
     }
 
@@ -65,17 +81,18 @@ class InstallController extends ShaarliVisitorController
         // This part makes sure sessions works correctly.
         // (Because on some hosts, session.save_path may not be set correctly,
         // or we may not have write access to it.)
-        if (static::SESSION_TEST_VALUE
+        if (
+            static::SESSION_TEST_VALUE
             !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
         ) {
             // Step 2: Check if data in session is correct.
             $msg = t(
-                '<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());
@@ -94,7 +111,8 @@ class InstallController extends ShaarliVisitorController
     public function save(Request $request, Response $response): Response
     {
         $timezone = 'UTC';
-        if (!empty($request->getParam('continent'))
+        if (
+            !empty($request->getParam('continent'))
             && !empty($request->getParam('city'))
             && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
         ) {
@@ -104,7 +122,7 @@ class InstallController extends ShaarliVisitorController
 
         $login = $request->getParam('setlogin');
         $this->container->conf->set('credentials.login', $login);
-        $salt = sha1(uniqid('', true) .'_'. mt_rand());
+        $salt = sha1(uniqid('', true) . '_' . mt_rand());
         $this->container->conf->set('credentials.salt', $salt);
         $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
 
@@ -113,7 +131,7 @@ class InstallController extends ShaarliVisitorController
         } else {
             $this->container->conf->set(
                 'general.title',
-                'Shared bookmarks on '.escape(index_url($this->container->environment))
+                'Shared bookmarks on ' . escape(index_url($this->container->environment))
             );
         }
 
@@ -150,7 +168,7 @@ class InstallController extends ShaarliVisitorController
     protected function checkPermissions(): bool
     {
         // Ensure Shaarli has proper access to its resources
-        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf);
+        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
         if (empty($errors)) {
             return true;
         }
index 121ba40be8f7e55e50c7b74ccf2b7c91e94702c1..4b881535c4174e30bb20f7a3587fd1401e0a97f8 100644 (file)
@@ -43,7 +43,7 @@ class LoginController extends ShaarliVisitorController
         $this
             ->assignView('returnurl', escape($returnUrl))
             ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
-            ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli'))
+            ->assignView('pagetitle', t('Login') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'))
         ;
 
         return $response->write($this->render(TemplatePage::LOGIN));
@@ -64,8 +64,8 @@ class LoginController extends ShaarliVisitorController
             return $this->redirect($response, '/');
         }
 
-        if (!$this->container->loginManager->checkCredentials(
-                $this->container->environment['REMOTE_ADDR'],
+        if (
+            !$this->container->loginManager->checkCredentials(
                 client_ip_id($this->container->environment),
                 $request->getParam('login'),
                 $request->getParam('password')
@@ -102,7 +102,8 @@ class LoginController extends ShaarliVisitorController
      */
     protected function checkLoginState(): bool
     {
-        if ($this->container->loginManager->isLoggedIn()
+        if (
+            $this->container->loginManager->isLoggedIn()
             || $this->container->conf->get('security.open_shaarli', false)
         ) {
             throw new CantLoginException();
index 3c57f8dd61869787565703ad492fb764dd0b5a67..23553ee63105d3f212332a8174ee9f927710c7e0 100644 (file)
@@ -26,7 +26,7 @@ class PictureWallController extends ShaarliVisitorController
 
         $this->assignView(
             'pagetitle',
-            t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+            t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
         );
 
         // Optionally filter the results:
index 54f9fe03fc5bd4c506b04c7cfaae02b8afea0ee5..ae946c592240bcee977fce824b3073d368064ee6 100644 (file)
@@ -144,7 +144,8 @@ abstract class ShaarliVisitorController
         if (null !== $referer) {
             $currentUrl = parse_url($referer);
             // If the referer is not related to Shaarli instance, redirect to default
-            if (isset($currentUrl['host'])
+            if (
+                isset($currentUrl['host'])
                 && strpos(index_url($this->container->environment), $currentUrl['host']) === false
             ) {
                 return $response->withRedirect($defaultPath);
@@ -173,7 +174,7 @@ abstract class ShaarliVisitorController
             }
         }
 
-        $queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
+        $queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
         $anchor = $anchor ? '#' . $anchor : '';
 
         return $response->withRedirect($path . $queryString . $anchor);
index 76ed76900da0f1c75afa1b2dd942cd98a6f6ecda..46d62779dc1a5eed946e9b7e3e3c504fa0a0bec9 100644 (file)
@@ -47,13 +47,14 @@ class TagCloudController extends ShaarliVisitorController
      */
     protected function processRequest(string $type, Request $request, Response $response): Response
     {
+        $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
         if ($this->container->loginManager->isLoggedIn() === true) {
             $visibility = $this->container->sessionManager->getSessionParameter('visibility');
         }
 
         $sort = $request->getQueryParam('sort');
         $searchTags = $request->getQueryParam('searchtags');
-        $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : [];
+        $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
 
         $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
 
@@ -71,8 +72,9 @@ class TagCloudController extends ShaarliVisitorController
             $tagsUrl[escape($tag)] = urlencode((string) $tag);
         }
 
-        $searchTags = implode(' ', escape($filteringTags));
-        $searchTagsUrl = urlencode(implode(' ', $filteringTags));
+        $searchTags = tags_array2str($filteringTags, $tagsSeparator);
+        $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
+        $searchTagsUrl = urlencode($searchTags);
         $data = [
             'search_tags' => escape($searchTags),
             'search_tags_url' => $searchTagsUrl,
@@ -82,10 +84,10 @@ class TagCloudController extends ShaarliVisitorController
         $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
         $this->assignAllView($data);
 
-        $searchTags = !empty($searchTags) ? $searchTags .' - ' : '';
+        $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) . ' - ' : '';
         $this->assignView(
             'pagetitle',
-            $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli')
+            $searchTags . t('Tag ' . $type) . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
         );
 
         return $response->write($this->render('tag.' . $type));
index de4e7ea28861daabb8c742aeeddd7725930cb95f..3aa58542bb0b702acaf44f1b4d552d91c84e3ab9 100644 (file)
@@ -27,7 +27,7 @@ class TagController extends ShaarliVisitorController
         // In case browser does not send HTTP_REFERER, we search a single tag
         if (null === $referer) {
             if (null !== $newTag) {
-                return $this->redirect($response, '/?searchtags='. urlencode($newTag));
+                return $this->redirect($response, '/?searchtags=' . urlencode($newTag));
             }
 
             return $this->redirect($response, '/');
@@ -37,7 +37,7 @@ class TagController extends ShaarliVisitorController
         parse_str($currentUrl['query'] ?? '', $params);
 
         if (null === $newTag) {
-            return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
+            return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
         }
 
         // Prevent redirection loop
@@ -45,9 +45,10 @@ class TagController extends ShaarliVisitorController
             unset($params['addtag']);
         }
 
+        $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
         // Check if this tag is already in the search query and ignore it if it is.
         // Each tag is always separated by a space
-        $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : [];
+        $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
 
         $addtag = true;
         foreach ($currentTags as $value) {
@@ -62,12 +63,12 @@ class TagController extends ShaarliVisitorController
             $currentTags[] = trim($newTag);
         }
 
-        $params['searchtags'] = trim(implode(' ', $currentTags));
+        $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
 
         // We also remove page (keeping the same page has no sense, since the results are different)
         unset($params['page']);
 
-        return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
+        return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
     }
 
     /**
@@ -89,7 +90,7 @@ class TagController extends ShaarliVisitorController
         parse_str($currentUrl['query'] ?? '', $params);
 
         if (null === $tagToRemove) {
-            return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
+            return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
         }
 
         // Prevent redirection loop
@@ -98,10 +99,11 @@ class TagController extends ShaarliVisitorController
         }
 
         if (isset($params['searchtags'])) {
-            $tags = explode(' ', $params['searchtags']);
+            $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
+            $tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
             // Remove value from array $tags.
             $tags = array_diff($tags, [$tagToRemove]);
-            $params['searchtags'] = implode(' ', $tags);
+            $params['searchtags'] = tags_array2str($tags, $tagsSeparator);
 
             if (empty($params['searchtags'])) {
                 unset($params['searchtags']);
similarity index 61%
rename from application/ApplicationUtils.php
rename to application/helper/ApplicationUtils.php
index 3aa218295c634e3d0d02b3e5e9fa00ff534d7804..a6c03aaeae899daebb46bb37884dc3422694b6d0 100644 (file)
@@ -1,7 +1,10 @@
 <?php
-namespace Shaarli;
+
+namespace Shaarli\Helper;
 
 use Exception;
+use malkusch\lock\exception\LockAcquireException;
+use malkusch\lock\mutex\FlockMutex;
 use Shaarli\Config\ConfigManager;
 
 /**
@@ -14,8 +17,9 @@ class ApplicationUtils
      */
     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 = ' */ ?>';
 
@@ -63,8 +67,8 @@ class ApplicationUtils
         }
 
         return str_replace(
-            array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
-            array('', '', ''),
+            [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
+            ['', '', ''],
             $data
         );
     }
@@ -125,7 +129,7 @@ class ApplicationUtils
         // Late Static Binding allows overriding within tests
         // See http://php.net/manual/en/language.oop5.late-static-bindings.php
         $latestVersion = static::getVersion(
-            self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
+            self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
         );
 
         if (!$latestVersion) {
@@ -171,35 +175,47 @@ class ApplicationUtils
     /**
      * Checks Shaarli has the proper access permissions to its resources
      *
-     * @param ConfigManager $conf Configuration Manager instance.
+     * @param ConfigManager $conf        Configuration Manager instance.
+     * @param bool          $minimalMode In minimal mode we only check permissions to be able to display a template.
+     *                                   Currently we only need to be able to read the theme and write in raintpl cache.
      *
      * @return array A list of the detected configuration issues
      */
-    public static function checkResourcePermissions($conf)
+    public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
     {
-        $errors = array();
+        $errors = [];
         $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
 
         // Check script and template directories are readable
-        foreach (array(
-                     'application',
-                     'inc',
-                     'plugins',
-                     $rainTplDir,
-                     $rainTplDir . '/' . $conf->get('resource.theme'),
-                 ) as $path) {
+        foreach (
+            [
+            'application',
+            'inc',
+            'plugins',
+            $rainTplDir,
+            $rainTplDir . '/' . $conf->get('resource.theme'),
+            ] as $path
+        ) {
             if (!is_readable(realpath($path))) {
                 $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
         }
 
         // Check cache and data directories are readable and writable
-        foreach (array(
-                     $conf->get('resource.thumbnails_cache'),
-                     $conf->get('resource.data_dir'),
-                     $conf->get('resource.page_cache'),
-                     $conf->get('resource.raintpl_tmp'),
-                 ) as $path) {
+        if ($minimalMode) {
+            $folders = [
+                $conf->get('resource.raintpl_tmp'),
+            ];
+        } else {
+            $folders = [
+            $conf->get('resource.thumbnails_cache'),
+            $conf->get('resource.data_dir'),
+            $conf->get('resource.page_cache'),
+            $conf->get('resource.raintpl_tmp'),
+            ];
+        }
+
+        foreach ($folders as $path) {
             if (!is_readable(realpath($path))) {
                 $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
@@ -208,14 +224,20 @@ class ApplicationUtils
             }
         }
 
+        if ($minimalMode) {
+            return $errors;
+        }
+
         // Check configuration files are readable and writable
-        foreach (array(
-                     $conf->getConfigFileExt(),
-                     $conf->get('resource.datastore'),
-                     $conf->get('resource.ban_file'),
-                     $conf->get('resource.log'),
-                     $conf->get('resource.update_check'),
-                 ) as $path) {
+        foreach (
+            [
+                 $conf->getConfigFileExt(),
+                 $conf->get('resource.datastore'),
+                 $conf->get('resource.ban_file'),
+                 $conf->get('resource.log'),
+                 $conf->get('resource.update_check'),
+             ] as $path
+        ) {
             if (!is_file(realpath($path))) {
                 # the file may not exist yet
                 continue;
@@ -232,6 +254,20 @@ class ApplicationUtils
         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.
      *
@@ -246,4 +282,54 @@ class ApplicationUtils
     {
         return hash_hmac('sha256', $currentVersion, $salt);
     }
+
+    /**
+     * Get a list of PHP extensions used by Shaarli.
+     *
+     * @return array[] List of extension with following keys:
+     *                   - name: extension name
+     *                   - required: whether the extension is required to use Shaarli
+     *                   - desc: short description of extension usage in Shaarli
+     *                   - loaded: whether the extension is properly loaded or not
+     */
+    public static function getPhpExtensionsRequirement(): array
+    {
+        $extensions = [
+            ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
+            ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
+            ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
+            ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
+            ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
+            ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
+            ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
+            ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
+        ];
+
+        foreach ($extensions as &$extension) {
+            $extension['loaded'] = extension_loaded($extension['name']);
+        }
+
+        return $extensions;
+    }
+
+    /**
+     * Return the EOL date of given PHP version. If the version is unknown,
+     * we return today + 2 years.
+     *
+     * @param string $fullVersion PHP version, e.g. 7.4.7
+     *
+     * @return string Date format: YYYY-MM-DD
+     */
+    public static function getPhpEol(string $fullVersion): string
+    {
+        preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
+
+        return [
+            '7.1' => '2019-12-01',
+            '7.2' => '2020-11-30',
+            '7.3' => '2021-12-06',
+            '7.4' => '2022-11-28',
+            '8.0' => '2023-12-01',
+        ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
+    }
 }
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php
new file mode 100644 (file)
index 0000000..05f9581
--- /dev/null
@@ -0,0 +1,236 @@
+<?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)
+        );
+    }
+}
similarity index 57%
rename from application/FileUtils.php
rename to application/helper/FileUtils.php
index 30560bfc3a929a272a7932c893a7212f5d598da1..e8a2168cca98be947e8aba2da18b499543084fa0 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Shaarli;
+namespace Shaarli\Helper;
 
 use Shaarli\Exceptions\IOException;
 
@@ -81,4 +81,60 @@ class FileUtils
             )
         );
     }
+
+    /**
+     * 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;
+    }
 }
index 81d9e0762862f5265c65de6058b6fa911a4d7033..e80e0c014be5450be0ca42f9fee53b0d070c6fac 100644 (file)
@@ -14,9 +14,14 @@ namespace Shaarli\Http;
  */
 class HttpAccess
 {
-    public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
-    {
-        return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction);
+    public function getHttpResponse(
+        $url,
+        $timeout = 30,
+        $maxBytes = 4194304,
+        $curlHeaderFunction = null,
+        $curlWriteFunction = null
+    ) {
+        return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
     }
 
     public function getCurlDownloadCallback(
@@ -25,7 +30,7 @@ class HttpAccess
         &$description,
         &$keywords,
         $retrieveDescription,
-        $curlGetInfo = 'curl_getinfo'
+        $tagsSeparator
     ) {
         return get_curl_download_callback(
             $charset,
@@ -33,7 +38,12 @@ class HttpAccess
             $description,
             $keywords,
             $retrieveDescription,
-            $curlGetInfo
+            $tagsSeparator
         );
     }
+
+    public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
+    {
+        return get_curl_header_callback($charset, $curlGetInfo);
+    }
 }
index 9f4140735a695c4ab8e08b2c10a8e49eaa3527bb..4bde1d5b8c4b33c97dc91a40f847b96422174f6c 100644 (file)
@@ -6,12 +6,14 @@ use Shaarli\Http\Url;
  * GET an HTTP URL to retrieve its content
  * Uses the cURL library or a fallback method
  *
- * @param string          $url               URL to get (http://...)
- * @param int             $timeout           network timeout (in seconds)
- * @param int             $maxBytes          maximum downloaded bytes (default: 4 MiB)
- * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
- *                                           Can be used to add download conditions on the
- *                                           headers (response code, content type, etc.).
+ * @param string          $url                URL to get (http://...)
+ * @param int             $timeout            network timeout (in seconds)
+ * @param int             $maxBytes           maximum downloaded bytes (default: 4 MiB)
+ * @param callable|string $curlHeaderFunction Optional callback called during the download of headers
+ *                                            (CURLOPT_HEADERFUNCTION)
+ * @param callable|string $curlWriteFunction  Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
+ *                                            Can be used to add download conditions on the
+ *                                            headers (response code, content type, etc.).
  *
  * @return array HTTP response headers, downloaded content
  *
@@ -35,13 +37,18 @@ use Shaarli\Http\Url;
  * @see http://stackoverflow.com/q/9183178
  * @see http://stackoverflow.com/q/1462720
  */
-function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
-{
+function get_http_response(
+    $url,
+    $timeout = 30,
+    $maxBytes = 4194304,
+    $curlHeaderFunction = null,
+    $curlWriteFunction = null
+) {
     $urlObj = new Url($url);
     $cleanUrl = $urlObj->idnToAscii();
 
     if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
-        return array(array(0 => 'Invalid HTTP UrlUtils'), false);
+        return [[0 => 'Invalid HTTP UrlUtils'], false];
     }
 
     $userAgent =
@@ -64,42 +71,39 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
 
     $ch = curl_init($cleanUrl);
     if ($ch === false) {
-        return array(array(0 => 'curl_init() error'), false);
+        return [[0 => 'curl_init() error'], false];
     }
 
     // General cURL settings
     curl_setopt($ch, CURLOPT_AUTOREFERER, true);
     curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
-    curl_setopt($ch, CURLOPT_HEADER, true);
+    // Default header download if the $curlHeaderFunction is not defined
+    curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction));
     curl_setopt(
         $ch,
         CURLOPT_HTTPHEADER,
-        array('Accept-Language: ' . $acceptLanguage)
+        ['Accept-Language: ' . $acceptLanguage]
     );
     curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
     curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
     curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
     curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
 
+    // Max download size management
+    curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16);
+    curl_setopt($ch, CURLOPT_NOPROGRESS, false);
+    if (is_callable($curlHeaderFunction)) {
+        curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
+    }
     if (is_callable($curlWriteFunction)) {
         curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
     }
-
-    // Max download size management
-    curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
-    curl_setopt($ch, CURLOPT_NOPROGRESS, false);
     curl_setopt(
         $ch,
         CURLOPT_PROGRESSFUNCTION,
-        function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) {
-            if (version_compare(phpversion(), '5.5', '<')) {
-                // PHP version lower than 5.5
-                // Callback has 4 arguments
-                $downloaded = $arg1;
-            } else {
-                // Callback has 5 arguments
-                $downloaded = $arg2;
-            }
+        function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
+            $downloaded = $arg2;
+
             // Non-zero return stops downloading
             return ($downloaded > $maxBytes) ? 1 : 0;
         }
@@ -118,9 +122,9 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
              * Removing this would require updating
              * GetHttpUrlTest::testGetInvalidRemoteUrl()
              */
-            return array(false, false);
+            return [false, false];
         }
-        return array(array(0 => 'curl_exec() error: ' . $errorStr), false);
+        return [[0 => 'curl_exec() error: ' . $errorStr], false];
     }
 
     // Formatting output like the fallback method
@@ -131,7 +135,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
     $rawHeadersLastRedir = end($rawHeadersArrayRedirs);
 
     $content = substr($response, $headSize);
-    $headers = array();
+    $headers = [];
     foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
         if (empty($line) || ctype_space($line)) {
             continue;
@@ -142,7 +146,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
             $value = $splitLine[1];
             if (array_key_exists($key, $headers)) {
                 if (!is_array($headers[$key])) {
-                    $headers[$key] = array(0 => $headers[$key]);
+                    $headers[$key] = [0 => $headers[$key]];
                 }
                 $headers[$key][] = $value;
             } else {
@@ -153,7 +157,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
         }
     }
 
-    return array($headers, $content);
+    return [$headers, $content];
 }
 
 /**
@@ -184,15 +188,15 @@ function get_http_response_fallback(
     $acceptLanguage,
     $maxRedr
 ) {
-    $options = array(
-        'http' => array(
+    $options = [
+        'http' => [
             'method' => 'GET',
             'timeout' => $timeout,
             'user_agent' => $userAgent,
             'header' => "Accept: */*\r\n"
                 . 'Accept-Language: ' . $acceptLanguage
-        )
-    );
+        ]
+    ];
 
     stream_context_set_default($options);
     list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
@@ -203,7 +207,7 @@ function get_http_response_fallback(
     }
 
     if (! $headers) {
-        return array($headers, false);
+        return [$headers, false];
     }
 
     try {
@@ -211,10 +215,10 @@ function get_http_response_fallback(
         $context = stream_context_create($options);
         $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
     } catch (Exception $exc) {
-        return array(array(0 => 'HTTP Error'), $exc->getMessage());
+        return [[0 => 'HTTP Error'], $exc->getMessage()];
     }
 
-    return array($headers, $content);
+    return [$headers, $content];
 }
 
 /**
@@ -233,10 +237,12 @@ function get_redirected_headers($url, $redirectionLimit = 3)
     }
 
     // Headers found, redirection found, and limit not reached.
-    if ($redirectionLimit-- > 0
+    if (
+        $redirectionLimit-- > 0
         && !empty($headers)
         && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
-        && !empty($headers['Location'])) {
+        && !empty($headers['Location'])
+    ) {
         $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
         if ($redirection != $url) {
             $redirection = getAbsoluteUrl($url, $redirection);
@@ -244,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
         }
     }
 
-    return array($headers, $url);
+    return [$headers, $url];
 }
 
 /**
@@ -266,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl)
     }
 
     $parts = parse_url($originalUrl);
-    $final = $parts['scheme'] .'://'. $parts['host'];
+    $final = $parts['scheme'] . '://' . $parts['host'];
     $final .= (!empty($parts['port'])) ? $parts['port'] : '';
     $final .= '/';
     if ($newUrl[0] != '/') {
@@ -319,7 +325,8 @@ function server_url($server)
                 $scheme = 'https';
             }
 
-            if (($scheme == 'http' && $port != '80')
+            if (
+                ($scheme == 'http' && $port != '80')
                 || ($scheme == 'https' && $port != '443')
             ) {
                 $port = ':' . $port;
@@ -340,22 +347,26 @@ function server_url($server)
             $host = $server['SERVER_NAME'];
         }
 
-        return $scheme.'://'.$host.$port;
+        return $scheme . '://' . $host . $port;
     }
 
     // SSL detection
-    if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
-        || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) {
+    if (
+        (! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
+        || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')
+    ) {
         $scheme = 'https';
     }
 
     // Do not append standard port values
-    if (($scheme == 'http' && $server['SERVER_PORT'] != '80')
-        || ($scheme == 'https' && $server['SERVER_PORT'] != '443')) {
-        $port = ':'.$server['SERVER_PORT'];
+    if (
+        ($scheme == 'http' && $server['SERVER_PORT'] != '80')
+        || ($scheme == 'https' && $server['SERVER_PORT'] != '443')
+    ) {
+        $port = ':' . $server['SERVER_PORT'];
     }
 
-    return $scheme.'://'.$server['SERVER_NAME'].$port;
+    return $scheme . '://' . $server['SERVER_NAME'] . $port;
 }
 
 /**
@@ -489,6 +500,46 @@ function is_https($server)
     return ! empty($server['HTTPS']);
 }
 
+/**
+ * Get cURL callback function for CURLOPT_WRITEFUNCTION
+ *
+ * @param string $charset     to extract from the downloaded page (reference)
+ * @param string $curlGetInfo Optionally overrides curl_getinfo function
+ *
+ * @return Closure
+ */
+function get_curl_header_callback(
+    &$charset,
+    $curlGetInfo = 'curl_getinfo'
+) {
+    $isRedirected = false;
+
+    return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
+        $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
+        $chunkLength = strlen($data);
+        if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
+            $isRedirected = true;
+            return $chunkLength;
+        }
+        if (!empty($responseCode) && $responseCode !== 200) {
+            return false;
+        }
+        // After a redirection, the content type will keep the previous request value
+        // until it finds the next content-type header.
+        if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
+            $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
+        }
+        if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
+            return false;
+        }
+        if (!empty($contentType) && empty($charset)) {
+            $charset = header_extract_charset($contentType);
+        }
+
+        return $chunkLength;
+    };
+}
+
 /**
  * Get cURL callback function for CURLOPT_WRITEFUNCTION
  *
@@ -507,9 +558,8 @@ function get_curl_download_callback(
     &$description,
     &$keywords,
     $retrieveDescription,
-    $curlGetInfo = 'curl_getinfo'
+    $tagsSeparator
 ) {
-    $isRedirected = false;
     $currentChunk = 0;
     $foundChunk = null;
 
@@ -524,37 +574,22 @@ function get_curl_download_callback(
      *
      * @return int|bool length of $data or false if we need to stop the download
      */
-    return function (&$ch, $data) use (
+    return function (
+        $ch,
+        $data
+    ) use (
         $retrieveDescription,
-        $curlGetInfo,
+        $tagsSeparator,
         &$charset,
         &$title,
         &$description,
         &$keywords,
-        &$isRedirected,
         &$currentChunk,
         &$foundChunk
     ) {
+        $chunkLength = strlen($data);
         $currentChunk++;
-        $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
-        if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
-            $isRedirected = true;
-            return strlen($data);
-        }
-        if (!empty($responseCode) && $responseCode !== 200) {
-            return false;
-        }
-        // After a redirection, the content type will keep the previous request value
-        // until it finds the next content-type header.
-        if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
-            $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
-        }
-        if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
-            return false;
-        }
-        if (!empty($contentType) && empty($charset)) {
-            $charset = header_extract_charset($contentType);
-        }
+
         if (empty($charset)) {
             $charset = html_extract_charset($data);
         }
@@ -562,6 +597,10 @@ function get_curl_download_callback(
             $title = html_extract_title($data);
             $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
         }
+        if (empty($title)) {
+            $title = html_extract_tag('title', $data);
+            $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
+        }
         if ($retrieveDescription && empty($description)) {
             $description = html_extract_tag('description', $data);
             $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
@@ -571,10 +610,10 @@ function get_curl_download_callback(
             if (! empty($keywords)) {
                 $foundChunk = $currentChunk;
                 // Keywords use the format tag1, tag2 multiple words, tag
-                // So we format them to match Shaarli's separator and glue multiple words with '-'
-                $keywords = implode(' ', array_map(function($keyword) {
-                    return implode('-', preg_split('/\s+/', trim($keyword)));
-                }, explode(',', $keywords)));
+                // So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
+                $keywords = tags_array2str(array_map(function (string $keyword) use ($tagsSeparator): string {
+                    return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
+                }, tags_str2array($keywords, ',')), $tagsSeparator);
             }
         }
 
@@ -582,7 +621,8 @@ function get_curl_download_callback(
         // If we already found either the title, description or keywords,
         // it's highly unlikely that we'll found the other metas further than
         // in the same chunk of data or the next one. So we also stop the download after that.
-        if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
+        if (
+            (!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
             && (! $retrieveDescription
                 || $foundChunk < $currentChunk
                 || (!empty($title) && !empty($description) && !empty($keywords))
@@ -591,6 +631,6 @@ function get_curl_download_callback(
             return false;
         }
 
-        return strlen($data);
+        return $chunkLength;
     };
 }
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644 (file)
index 0000000..cfc7258
--- /dev/null
@@ -0,0 +1,74 @@
+<?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);
+    }
+}
index 90444a2f4beaf0ad15df3be06ff36d4431d61811..fe87088f28afee2a5ab4c541d2e95884846f1c9c 100644 (file)
@@ -17,7 +17,7 @@ namespace Shaarli\Http;
  */
 class Url
 {
-    private static $annoyingQueryParams = array(
+    private static $annoyingQueryParams = [
         // Facebook
         'action_object_map=',
         'action_ref_map=',
@@ -37,15 +37,15 @@ class Url
 
         // Other
         'campaign_'
-    );
+    ];
 
-    private static $annoyingFragments = array(
+    private static $annoyingFragments = [
         // ATInternet
         'xtor=RSS-',
 
         // Misc.
         'tk.rss_all'
-    );
+    ];
 
     /*
      * URL parts represented as an array
@@ -120,7 +120,7 @@ class Url
         foreach (self::$annoyingQueryParams as $annoying) {
             foreach ($queryParams as $param) {
                 if (startsWith($param, $annoying)) {
-                    $queryParams = array_diff($queryParams, array($param));
+                    $queryParams = array_diff($queryParams, [$param]);
                     continue;
                 }
             }
index e8d1a283fca632ecce9af242f8676c7f824015a4..de5b7db16b8c99aa29962978674e26bfe755e8ca 100644 (file)
@@ -1,4 +1,5 @@
 <?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";
 }
index 826604e77204f3726862890d0478ad5b448f7199..1fed418b7c16612e875b9be185e07dd730100f4c 100644 (file)
@@ -51,7 +51,7 @@ class LegacyController extends ShaarliVisitorController
 
         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);
index 7bf76fd471087fe0477b935b4cb1bf771ae1ab46..d3beafe0dc874b7b8aad069257f2623c13fa7e9a 100644 (file)
@@ -8,7 +8,7 @@ use DateTime;
 use Iterator;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Exceptions\IOException;
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
 use Shaarli\Render\PageCacheManager;
 
 /**
@@ -62,7 +62,7 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
     private $datastore;
 
     // Link date storage format
-    const LINK_DATE_FORMAT = 'Ymd_His';
+    public const LINK_DATE_FORMAT = 'Ymd_His';
 
     // List of bookmarks (associative array)
     //  - key:   link date (e.g. "20110823_124546"),
@@ -240,8 +240,8 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
         }
 
         // Create a dummy database for example
-        $this->links = array();
-        $link = array(
+        $this->links = [];
+        $link = [
             'id' => 1,
             'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
             'url' => 'https://shaarli.readthedocs.io',
@@ -257,11 +257,11 @@ You use the community supported version of the original Shaarli project, by Seba
             'created' => new DateTime(),
             'tags' => 'opensource software',
             'sticky' => false,
-        );
+        ];
         $link['shorturl'] = link_small_hash($link['created'], $link['id']);
         $this->links[1] = $link;
 
-        $link = array(
+        $link = [
             'id' => 0,
             'title' => t('My secret stuff... - Pastebin.com'),
             'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
@@ -270,7 +270,7 @@ You use the community supported version of the original Shaarli project, by Seba
             'created' => new DateTime('1 minute ago'),
             'tags' => 'secretstuff',
             'sticky' => false,
-        );
+        ];
         $link['shorturl'] = link_small_hash($link['created'], $link['id']);
         $this->links[0] = $link;
 
@@ -285,7 +285,7 @@ You use the community supported version of the original Shaarli project, by Seba
     {
         // Public bookmarks are hidden and user not logged in => nothing to show
         if ($this->hidePublicLinks && !$this->loggedIn) {
-            $this->links = array();
+            $this->links = [];
             return;
         }
 
@@ -293,7 +293,7 @@ You use the community supported version of the original Shaarli project, by Seba
         $this->ids = [];
         $this->links = FileUtils::readFlatDB($this->datastore, []);
 
-        $toremove = array();
+        $toremove = [];
         foreach ($this->links as $key => &$link) {
             if (!$this->loggedIn && $link['private'] != 0) {
                 // Transition for not upgraded databases.
@@ -414,7 +414,7 @@ You use the community supported version of the original Shaarli project, by Seba
      * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
      */
     public function filterSearch(
-        $filterRequest = array(),
+        $filterRequest = [],
         $casesensitive = false,
         $visibility = 'all',
         $untaggedonly = false
@@ -512,7 +512,7 @@ You use the community supported version of the original Shaarli project, by Seba
      */
     public function days()
     {
-        $linkDays = array();
+        $linkDays = [];
         foreach ($this->links as $link) {
             $linkDays[$link['created']->format('Ymd')] = 0;
         }
index 7cf93d60ca3ae2a05d0f61f45062bad4ded672b2..e6d186c444828abc11f1567a88c64d6276975972 100644 (file)
@@ -120,7 +120,7 @@ class LegacyLinkFilter
             return $this->links;
         }
 
-        $out = array();
+        $out = [];
         foreach ($this->links as $key => $value) {
             if ($value['private'] && $visibility === 'private') {
                 $out[$key] = $value;
@@ -143,7 +143,7 @@ class LegacyLinkFilter
      */
     private function filterSmallHash($smallHash)
     {
-        $filtered = array();
+        $filtered = [];
         foreach ($this->links as $key => $l) {
             if ($smallHash == $l['shorturl']) {
                 // Yes, this is ugly and slow
@@ -186,7 +186,7 @@ class LegacyLinkFilter
             return $this->noFilter($visibility);
         }
 
-        $filtered = array();
+        $filtered = [];
         $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
         $exactRegex = '/"([^"]+)"/';
         // Retrieve exact search terms.
@@ -198,8 +198,8 @@ class LegacyLinkFilter
         $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
 
         // Filter excluding terms and update andSearch.
-        $excludeSearch = array();
-        $andSearch = array();
+        $excludeSearch = [];
+        $andSearch = [];
         foreach ($explodedSearchAnd as $needle) {
             if ($needle[0] == '-' && strlen($needle) > 1) {
                 $excludeSearch[] = substr($needle, 1);
@@ -208,7 +208,7 @@ class LegacyLinkFilter
             }
         }
 
-        $keys = array('title', 'description', 'url', 'tags');
+        $keys = ['title', 'description', 'url', 'tags'];
 
         // Iterate over every stored link.
         foreach ($this->links as $id => $link) {
@@ -336,7 +336,7 @@ class LegacyLinkFilter
         }
 
         // create resulting array
-        $filtered = array();
+        $filtered = [];
 
         // iterate over each link
         foreach ($this->links as $key => $link) {
@@ -352,7 +352,7 @@ class LegacyLinkFilter
             $search = $link['tags']; // build search string, start with tags of current link
             if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) {
                 // description given and at least one possible tag found
-                $descTags = array();
+                $descTags = [];
                 // find all tags in the form of #tag in the description
                 preg_match_all(
                     '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -419,7 +419,7 @@ class LegacyLinkFilter
             throw new Exception('Invalid date format');
         }
 
-        $filtered = array();
+        $filtered = [];
         foreach ($this->links as $key => $l) {
             if ($l['created']->format('Ymd') == $day) {
                 $filtered[$key] = $l;
index 0ab3a55bd572898b07e51099fc8bd7ae23f5d7fe..9bda54b8dd87ee9e6b0ef202ae7f136440568857 100644 (file)
@@ -7,7 +7,6 @@ use RainTPL;
 use ReflectionClass;
 use ReflectionException;
 use ReflectionMethod;
-use Shaarli\ApplicationUtils;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkArray;
 use Shaarli\Bookmark\BookmarkFilter;
@@ -17,6 +16,7 @@ use Shaarli\Config\ConfigJson;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Config\ConfigPhp;
 use Shaarli\Exceptions\IOException;
+use Shaarli\Helper\ApplicationUtils;
 use Shaarli\Thumbnailer;
 use Shaarli\Updater\Exception\UpdaterException;
 
@@ -93,7 +93,7 @@ class LegacyUpdater
      */
     public function update()
     {
-        $updatesRan = array();
+        $updatesRan = [];
 
         // If the user isn't logged in, exit without updating.
         if ($this->isLoggedIn !== true) {
@@ -106,7 +106,8 @@ class LegacyUpdater
 
         foreach ($this->methods as $method) {
             // Not an update method or already done, pass.
-            if (!startsWith($method->getName(), 'updateMethod')
+            if (
+                !startsWith($method->getName(), 'updateMethod')
                 || in_array($method->getName(), $this->doneUpdates)
             ) {
                 continue;
@@ -189,7 +190,7 @@ class LegacyUpdater
         }
 
         // Set sub config keys (config and plugins)
-        $subConfig = array('config', 'plugins');
+        $subConfig = ['config', 'plugins'];
         foreach ($subConfig as $sub) {
             foreach ($oldConfig[$sub] as $key => $value) {
                 if (isset($legacyMap[$sub . '.' . $key])) {
@@ -259,7 +260,7 @@ class LegacyUpdater
         $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
         copy($this->conf->get('resource.datastore'), $save);
 
-        $links = array();
+        $links = [];
         foreach ($this->linkDB as $offset => $value) {
             $links[] = $value;
             unset($this->linkDB[$offset]);
@@ -498,7 +499,8 @@ class LegacyUpdater
      */
     public function updateMethodDownloadSizeAndTimeoutConf()
     {
-        if ($this->conf->exists('general.download_max_size')
+        if (
+            $this->conf->exists('general.download_max_size')
             && $this->conf->exists('general.download_timeout')
         ) {
             return true;
@@ -585,7 +587,7 @@ class LegacyUpdater
 
         $linksArray = new BookmarkArray();
         foreach ($this->linkDB as $key => $link) {
-            $linksArray[$key] = (new Bookmark())->fromArray($link);
+            $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' '));
         }
         $linksIo = new BookmarkIO($this->conf);
         $linksIo->write($linksArray);
index b83f16f8eb8e49895bddaac648d1046c25a083c7..2d97b4c85dbb89a3a1b20a582e0da35b97759a3c 100644 (file)
@@ -59,11 +59,11 @@ class NetscapeBookmarkUtils
         $indexUrl
     ) {
         // see tpl/export.html for possible values
-        if (!in_array($selection, array('all', 'public', 'private'))) {
+        if (!in_array($selection, ['all', 'public', 'private'])) {
             throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
         }
 
-        $bookmarkLinks = array();
+        $bookmarkLinks = [];
         foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
             $link = $formatter->format($bookmark);
             $link['taglist'] = implode(',', $bookmark->getTags());
@@ -101,11 +101,11 @@ class NetscapeBookmarkUtils
 
         // Add tags to all imported bookmarks?
         if (empty($post['default_tags'])) {
-            $defaultTags = array();
+            $defaultTags = [];
         } else {
-            $defaultTags = preg_split(
-                '/[\s,]+/',
-                escape($post['default_tags'])
+            $defaultTags = tags_str2array(
+                escape($post['default_tags']),
+                $this->conf->get('general.tags_separator', ' ')
             );
         }
 
@@ -171,7 +171,7 @@ class NetscapeBookmarkUtils
             $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
             $link->setDescription($bkm['note']);
             $link->setPrivate($private);
-            $link->setTagsString($bkm['tags']);
+            $link->setTags($bkm['tags']);
 
             $this->bookmarkService->addOrSet($link, false);
             $importCount++;
index da66dea3952ad3cb0086a4594858f392f3270ba0..7fc0cb047db70d1ca33d8f72529b57ff598b579e 100644 (file)
@@ -1,8 +1,10 @@
 <?php
+
 namespace Shaarli\Plugin;
 
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\Exception\PluginFileNotFoundException;
+use Shaarli\Plugin\Exception\PluginInvalidRouteException;
 
 /**
  * Class PluginManager
@@ -23,7 +25,15 @@ 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.
@@ -57,7 +67,7 @@ class PluginManager
     public function __construct(&$conf)
     {
         $this->conf = $conf;
-        $this->errors = array();
+        $this->errors = [];
     }
 
     /**
@@ -85,6 +95,9 @@ class PluginManager
                 $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]));
             }
         }
     }
@@ -98,7 +111,7 @@ class PluginManager
      *
      * @return void
      */
-    public function executeHooks($hook, &$data, $params = array())
+    public function executeHooks($hook, &$data, $params = [])
     {
         $metadataParameters = [
             'target' => '_PAGE_',
@@ -165,6 +178,22 @@ class PluginManager
             }
         }
 
+        $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;
     }
 
@@ -196,7 +225,7 @@ class PluginManager
      */
     public function getPluginsMeta()
     {
-        $metaData = array();
+        $metaData = [];
         $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
 
         // Browse all plugin directories.
@@ -217,9 +246,9 @@ class PluginManager
             if (isset($metaData[$plugin]['parameters'])) {
                 $params = explode(';', $metaData[$plugin]['parameters']);
             } else {
-                $params = array();
+                $params = [];
             }
-            $metaData[$plugin]['parameters'] = array();
+            $metaData[$plugin]['parameters'] = [];
             foreach ($params as $param) {
                 if (empty($param)) {
                     continue;
@@ -236,6 +265,14 @@ class PluginManager
         return $metaData;
     }
 
+    /**
+     * @return array List of registered custom routes by plugins.
+     */
+    public function getRegisteredRoutes(): array
+    {
+        return $this->registeredRoutes;
+    }
+
     /**
      * Return the list of encountered errors.
      *
@@ -245,4 +282,32 @@ class PluginManager
     {
         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;
+    }
 }
index e5386f02605989de373e21e4b4295c3785ac058a..21ac6604f77a644bab417553d2c79d3d60dd77bc 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Plugin\Exception;
 
 use Exception;
diff --git a/application/plugin/exception/PluginInvalidRouteException.php b/application/plugin/exception/PluginInvalidRouteException.php
new file mode 100644 (file)
index 0000000..6ba9bc4
--- /dev/null
@@ -0,0 +1,26 @@
+<?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.';
+    }
+}
index 2d6d2dbed983d4fe2d8f0d2db00003f118e55141..bf0ae3263db50f2ebcb39c228441f058521e0334 100644 (file)
@@ -3,11 +3,11 @@
 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;
 
@@ -35,6 +35,9 @@ class PageBuilder
      */
     protected $session;
 
+    /** @var LoggerInterface */
+    protected $logger;
+
     /**
      * @var BookmarkServiceInterface $bookmarkService instance.
      */
@@ -54,17 +57,25 @@ class PageBuilder
      * 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;
@@ -98,7 +109,7 @@ class PageBuilder
             $this->tpl->assign('newVersion', escape($version));
             $this->tpl->assign('versionError', '');
         } catch (Exception $exc) {
-            logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage());
+            $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER)));
             $this->tpl->assign('newVersion', '');
             $this->tpl->assign('versionError', escape($exc->getMessage()));
         }
@@ -149,7 +160,8 @@ class PageBuilder
 
         $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
 
-        $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']);
+        $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
+        $this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' '));
 
         // To be removed with a proper theme configuration.
         $this->tpl->assign('conf', $this->conf);
index 97805c3524605bae07b30cb06b4c935517c8126c..fe74bf271bb08448f3f4a605f701fb3b8af0ec24 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Render;
 
+use DatePeriod;
 use Shaarli\Feed\CachedPage;
 
 /**
@@ -49,12 +50,21 @@ class PageCacheManager
         $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
         );
     }
 }
index 8af8228a01b42182575581d2de69a98f342f6e7f..03b424f3c5fc0d272c7e6da111759c392582fe6d 100644 (file)
@@ -14,6 +14,7 @@ interface TemplatePage
     public const DAILY = 'daily';
     public const DAILY_RSS = 'dailyrss';
     public const EDIT_LINK = 'editlink';
+    public const EDIT_LINK_BATCH = 'editlink.batch';
     public const ERROR = 'error';
     public const EXPORT = 'export';
     public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
index 86096c64476bf655626633efb692b0a9b352eacc..18471f0a21eb9f5624d80887f0c39844aee146a1 100644 (file)
@@ -23,10 +23,10 @@ class ThemeUtils
     public static function getThemes($tplDir)
     {
         $tplDir = rtrim($tplDir, '/');
-        $allTheme = glob($tplDir.'/*', GLOB_ONLYDIR);
+        $allTheme = glob($tplDir . '/*', GLOB_ONLYDIR);
         $themes = [];
         foreach ($allTheme as $value) {
-            $themes[] = str_replace($tplDir.'/', '', $value);
+            $themes[] = str_replace($tplDir . '/', '', $value);
         }
 
         return $themes;
index 68190c54ffd6da311382b24f5cd78af9229a9f67..7077af5b5f339576ffc5b2f54f220dc5e56665c3 100644 (file)
@@ -1,9 +1,9 @@
 <?php
 
-
 namespace Shaarli\Security;
 
-use Shaarli\FileUtils;
+use Psr\Log\LoggerInterface;
+use Shaarli\Helper\FileUtils;
 
 /**
  * Class BanManager
@@ -28,8 +28,8 @@ 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 = [];
@@ -40,18 +40,20 @@ class BanManager
     /**
      * 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();
     }
 
@@ -78,11 +80,7 @@ class BanManager
 
         if ($this->failures[$ip] >= $this->nbAttempts) {
             $this->bans[$ip] = time() + $this->banDuration;
-            logm(
-                $this->logFile,
-                $server['REMOTE_ADDR'],
-                'IP address banned from login: '. $ip
-            );
+            $this->logger->info(format_log('IP address banned from login: ' . $ip, $ip));
         }
         $this->writeBanFile();
     }
@@ -138,7 +136,7 @@ class BanManager
             unset($this->failures[$ip]);
         }
         unset($this->bans[$ip]);
-        logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip);
+        $this->logger->info(format_log('Ban lifted for: ' . $ip, $ip));
 
         $this->writeBanFile();
         return false;
index 65048f10668cb1217070acebe1a5f981e7dfa346..b795b80e74d33021a58844c7698108eafd5c0796 100644 (file)
@@ -1,7 +1,9 @@
 <?php
+
 namespace Shaarli\Security;
 
 use Exception;
+use Psr\Log\LoggerInterface;
 use Shaarli\Config\ConfigManager;
 
 /**
@@ -31,26 +33,30 @@ class LoginManager
     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;
@@ -101,7 +107,8 @@ class LoginManager
             // The user client has a valid stay-signed-in cookie
             // Session information is updated with the current client information
             $this->sessionManager->storeLoginInfo($clientIpId);
-        } elseif ($this->sessionManager->hasSessionExpired()
+        } elseif (
+            $this->sessionManager->hasSessionExpired()
             || $this->sessionManager->hasClientIpChanged($clientIpId)
         ) {
             $this->sessionManager->logout();
@@ -129,48 +136,35 @@ class LoginManager
     /**
      * Check user credentials are valid
      *
-     * @param string $remoteIp   Remote client IP address
      * @param string $clientIpId Client IP address identifier
      * @param string $login      Username
      * @param string $password   Password
      *
      * @return bool true if the provided credentials are valid, false otherwise
      */
-    public function checkCredentials($remoteIp, $clientIpId, $login, $password)
+    public function checkCredentials($clientIpId, $login, $password)
     {
-        // Check login matches config
-        if ($login !== $this->configManager->get('credentials.login')) {
-            return false;
-        }
-
         // Check credentials
         try {
             $useLdapLogin = !empty($this->configManager->get('ldap.host'));
-            if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
-                || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
+            if (
+                $login === $this->configManager->get('credentials.login')
+                && (
+                    (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
+                    || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
+                )
             ) {
-                    $this->sessionManager->storeLoginInfo($clientIpId);
-                    logm(
-                        $this->configManager->get('resource.log'),
-                        $remoteIp,
-                        'Login successful'
-                    );
-                    return true;
+                $this->sessionManager->storeLoginInfo($clientIpId);
+                $this->logger->info(format_log('Login successful', $clientIpId));
+
+                return true;
             }
-        }
-        catch(Exception $exception) {
-            logm(
-                $this->configManager->get('resource.log'),
-                $remoteIp,
-                'Exception while checking credentials: ' . $exception
-            );
+        } catch (Exception $exception) {
+            $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId));
         }
 
-        logm(
-            $this->configManager->get('resource.log'),
-            $remoteIp,
-            'Login failed for user ' . $login
-        );
+        $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId));
+
         return false;
     }
 
@@ -183,7 +177,8 @@ class LoginManager
      *
      * @return bool true if the provided credentials are valid, false otherwise
      */
-    public function checkCredentialsFromLocalConfig($login, $password) {
+    public function checkCredentialsFromLocalConfig($login, $password)
+    {
         $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
 
         return $login == $this->configManager->get('credentials.login')
@@ -202,14 +197,14 @@ class LoginManager
      */
     public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null)
     {
-        $connect = $connect ?? function($host) {
+        $connect = $connect ?? function ($host) {
             $resource = ldap_connect($host);
 
             ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3);
 
             return $resource;
         };
-        $bind = $bind ?? function($handle, $dn, $password) {
+        $bind = $bind ?? function ($handle, $dn, $password) {
             return ldap_bind($handle, $dn, $password);
         };
 
index 36df8c1c9bc823b369f7422a76c63e1f3dd6676b..f957b91a06db98a4d351d206d3804041057b7208 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Security;
 
 use Shaarli\Config\ConfigManager;
@@ -79,7 +80,7 @@ class SessionManager
      */
     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;
     }
@@ -293,9 +294,12 @@ class SessionManager
         return session_start();
     }
 
-    public function cookieParameters(int $lifeTime, string $path, string $domain): bool
+    /**
+     * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
+     */
+    public function cookieParameters(int $lifeTime, string $path, string $domain): void
     {
-        return session_set_cookie_params($lifeTime, $path, $domain);
+        session_set_cookie_params($lifeTime, $path, $domain);
     }
 
     public function regenerateId(bool $deleteOldSession = false): bool
index 88a7bc7b27337a0c572647f0d2ac1ef3027939db..4f557d0f58fabca7429684eadbf3dfcc82bb1f7c 100644 (file)
@@ -88,7 +88,8 @@ class Updater
 
         foreach ($this->methods as $method) {
             // Not an update method or already done, pass.
-            if (! startsWith($method->getName(), 'updateMethod')
+            if (
+                ! startsWith($method->getName(), 'updateMethod')
                 || in_array($method->getName(), $this->doneUpdates)
             ) {
                 continue;
@@ -121,12 +122,12 @@ class Updater
 
     public function readUpdates(string $updatesFilepath): array
     {
-        return UpdaterUtils::read_updates_file($updatesFilepath);
+        return UpdaterUtils::readUpdatesFile($updatesFilepath);
     }
 
     public function writeUpdates(string $updatesFilepath, array $updates): void
     {
-        UpdaterUtils::write_updates_file($updatesFilepath, $updates);
+        UpdaterUtils::writeUpdatesFile($updatesFilepath, $updates);
     }
 
     /**
@@ -152,7 +153,8 @@ class Updater
         $updated = false;
 
         foreach ($this->bookmarkService->search() as $bookmark) {
-            if ($bookmark->isNote()
+            if (
+                $bookmark->isNote()
                 && startsWith($bookmark->getUrl(), '?')
                 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
             ) {
index 828a49fc02ae5909e6ab4c5c3502421af581ec81..206f826eda34b5719a5605c0c4b8106206b519b1 100644 (file)
@@ -11,7 +11,7 @@ class UpdaterUtils
      *
      * @return array Already done update methods.
      */
-    public static function read_updates_file($updatesFilepath)
+    public static function readUpdatesFile($updatesFilepath)
     {
         if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
             $content = file_get_contents($updatesFilepath);
@@ -19,7 +19,7 @@ class UpdaterUtils
                 return explode(';', $content);
             }
         }
-        return array();
+        return [];
     }
 
     /**
@@ -30,7 +30,7 @@ class UpdaterUtils
      *
      * @throws \Exception Couldn't write version number.
      */
-    public static function write_updates_file($updatesFilepath, $updates)
+    public static function writeUpdatesFile($updatesFilepath, $updates)
     {
         if (empty($updatesFilepath)) {
             throw new \Exception('Updates file path is not set, can\'t write updates.');
@@ -38,7 +38,7 @@ class UpdaterUtils
 
         $res = file_put_contents($updatesFilepath, implode(';', $updates));
         if ($res === false) {
-            throw new \Exception('Unable to write updates in '. $updatesFilepath . '.');
+            throw new \Exception('Unable to write updates in ' . $updatesFilepath . '.');
         }
     }
 }
diff --git a/assets/common/js/metadata.js b/assets/common/js/metadata.js
new file mode 100644 (file)
index 0000000..d5a28a3
--- /dev/null
@@ -0,0 +1,107 @@
+import he from 'he';
+
+/**
+ * This script is used to retrieve bookmarks metadata asynchronously:
+ *    - title, description and keywords while creating a new bookmark
+ *    - thumbnails while visiting the bookmark list
+ *
+ * Note: it should only be included if the user is logged in
+ *       and the setting general.enable_async_metadata is enabled.
+ */
+
+/**
+ * Removes given input loaders - used in edit link template.
+ *
+ * @param {object} loaders List of input DOM element that need to be cleared
+ */
+function clearLoaders(loaders) {
+  if (loaders != null && loaders.length > 0) {
+    [...loaders].forEach((loader) => {
+      loader.classList.remove('loading-input');
+    });
+  }
+}
+
+/**
+ * AJAX request to update the thumbnail of a bookmark with the provided ID.
+ * If a thumbnail is retrieved, it updates the divElement with the image src, and displays it.
+ *
+ * @param {string} basePath   Shaarli subfolder for XHR requests
+ * @param {object} divElement Main <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);
+    });
+  }
+})();
diff --git a/assets/common/js/shaare-batch.js b/assets/common/js/shaare-batch.js
new file mode 100644 (file)
index 0000000..557325e
--- /dev/null
@@ -0,0 +1,121 @@
+const sendBookmarkForm = (basePath, formElement) => {
+  const inputs = formElement
+    .querySelectorAll('input[type="text"], textarea, input[type="checkbox"], input[type="hidden"]');
+
+  const formData = new FormData();
+  [...inputs].forEach((input) => {
+    formData.append(input.getAttribute('name'), input.value);
+  });
+
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open('POST', `${basePath}/admin/shaare`);
+    xhr.onload = () => {
+      if (xhr.status !== 200) {
+        alert(`An error occurred. Return code: ${xhr.status}`);
+        reject();
+      } else {
+        formElement.closest('.edit-link-container').remove();
+        resolve();
+      }
+    };
+    xhr.send(formData);
+  });
+};
+
+const sendBookmarkDelete = (buttonElement, formElement) => (
+  new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open('GET', buttonElement.href);
+    xhr.onload = () => {
+      if (xhr.status !== 200) {
+        alert(`An error occurred. Return code: ${xhr.status}`);
+        reject();
+      } else {
+        formElement.closest('.edit-link-container').remove();
+        resolve();
+      }
+    };
+    xhr.send();
+  })
+);
+
+const redirectIfEmptyBatch = (basePath, formElements, path) => {
+  if (formElements == null || formElements.length === 0) {
+    window.location.href = `${basePath}${path}`;
+  }
+};
+
+(() => {
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
+  const getForms = () => document.querySelectorAll('form[name="linkform"]');
+
+  const cancelButtons = document.querySelectorAll('[name="cancel-batch-link"]');
+  if (cancelButtons != null) {
+    [...cancelButtons].forEach((cancelButton) => {
+      cancelButton.addEventListener('click', (e) => {
+        e.preventDefault();
+        e.target.closest('form[name="linkform"]').remove();
+        redirectIfEmptyBatch(basePath, getForms(), '/admin/add-shaare');
+      });
+    });
+  }
+
+  const saveButtons = document.querySelectorAll('[name="save_edit"]');
+  if (saveButtons != null) {
+    [...saveButtons].forEach((saveButton) => {
+      saveButton.addEventListener('click', (e) => {
+        e.preventDefault();
+
+        const formElement = e.target.closest('form[name="linkform"]');
+        sendBookmarkForm(basePath, formElement)
+          .then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
+      });
+    });
+  }
+
+  const saveAllButtons = document.querySelectorAll('[name="save_edit_batch"]');
+  if (saveAllButtons != null) {
+    [...saveAllButtons].forEach((saveAllButton) => {
+      saveAllButton.addEventListener('click', (e) => {
+        e.preventDefault();
+
+        const forms = [...getForms()];
+        const nbForm = forms.length;
+        let current = 0;
+        const progressBar = document.querySelector('.progressbar > div');
+        const progressBarCurrent = document.querySelector('.progressbar-current');
+
+        document.querySelector('.dark-layer').style.display = 'block';
+        document.querySelector('.progressbar-max').innerHTML = nbForm;
+        progressBarCurrent.innerHTML = current;
+
+        const promises = [];
+        forms.forEach((formElement) => {
+          promises.push(sendBookmarkForm(basePath, formElement).then(() => {
+            current += 1;
+            progressBar.style.width = `${(current * 100) / nbForm}%`;
+            progressBarCurrent.innerHTML = current;
+          }));
+        });
+
+        Promise.all(promises).then(() => {
+          window.location.href = basePath || '/';
+        });
+      });
+    });
+  }
+
+  const deleteButtons = document.querySelectorAll('[name="delete_link"]');
+  if (deleteButtons != null) {
+    [...deleteButtons].forEach((deleteButton) => {
+      deleteButton.addEventListener('click', (e) => {
+        e.preventDefault();
+
+        const formElement = e.target.closest('form[name="linkform"]');
+        sendBookmarkDelete(e.target, formElement)
+          .then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
+      });
+    });
+  }
+})();
index aadffc13b7487b7ce78ccdca3b4fe94d2ec68b6a..dd532bb71bb40bbd4de82afe0b2255ab69c40826 100644 (file)
@@ -1,4 +1,5 @@
 import Awesomplete from 'awesomplete';
+import he from 'he';
 
 /**
  * Find a parent element according to its tag and its attributes
@@ -41,19 +42,21 @@ function refreshToken(basePath, callback) {
   xhr.send();
 }
 
-function createAwesompleteInstance(element, tags = []) {
+function createAwesompleteInstance(element, separator, tags = []) {
   const awesome = new Awesomplete(Awesomplete.$(element));
-  // Tags are separated by a space
-  awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]);
+
+  // Tags are separated by separator
+  awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
   // Insert new selected tag in the input
   awesome.replace = (text) => {
-    const before = awesome.input.value.match(/^.+ \s*|/)[0];
-    awesome.input.value = `${before}${text} `;
+    const before = awesome.input.value.match(new RegExp(`^.+${separator}+|`))[0];
+    awesome.input.value = `${before}${text}${separator}`;
   };
   // Highlight found items
-  awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]);
+  awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
   // Don't display already selected items
-  const reg = /(\w+) /g;
+  // WARNING: pseudo classes does not seem to work with string litterals...
+  const reg = new RegExp(`([^${separator}]+)${separator}`, 'g');
   let match;
   awesome.data = (item, input) => {
     while ((match = reg.exec(input))) {
@@ -77,13 +80,14 @@ function createAwesompleteInstance(element, tags = []) {
  * @param selector  CSS selector
  * @param tags      Array of tags
  * @param instances List of existing awesomplete instances
+ * @param separator Tags separator character
  */
-function updateAwesompleteList(selector, tags, instances) {
+function updateAwesompleteList(selector, tags, instances, separator) {
   if (instances.length === 0) {
     // First load: create Awesomplete instances
     const elements = document.querySelectorAll(selector);
     [...elements].forEach((element) => {
-      instances.push(createAwesompleteInstance(element, tags));
+      instances.push(createAwesompleteInstance(element, separator, tags));
     });
   } else {
     // Update awesomplete tag list
@@ -95,15 +99,6 @@ function updateAwesompleteList(selector, tags, instances) {
   return instances;
 }
 
-/**
- * html_entities in JS
- *
- * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
- */
-function htmlEntities(str) {
-  return str.replace(/[\u00A0-\u9999<>&]/gim, (i) => `&#${i.charCodeAt(0)};`);
-}
-
 /**
  * Add the class 'hidden' to city options not attached to the current selected continent.
  *
@@ -222,6 +217,8 @@ function init(description) {
 
 (() => {
   const basePath = document.querySelector('input[name="js_base_path"]').value;
+  const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
+  const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
 
   /**
    * Handle responsive menu.
@@ -302,7 +299,8 @@ function init(description) {
   const deleteLinks = document.querySelectorAll('.confirm-delete');
   [...deleteLinks].forEach((deleteLink) => {
     deleteLink.addEventListener('click', (event) => {
-      if (!confirm(document.getElementById('translation-delete-tag').innerHTML)) {
+      const type = event.currentTarget.getAttribute('data-type') || 'link';
+      if (!confirm(document.getElementById(`translation-delete-${type}`).innerHTML)) {
         event.preventDefault();
       }
     });
@@ -569,7 +567,7 @@ function init(description) {
           input.setAttribute('name', totag);
           input.setAttribute('value', totag);
           findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
-          block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
+          block.querySelector('a.tag-link').innerHTML = he.encode(totag);
           block
             .querySelector('a.tag-link')
             .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
@@ -582,7 +580,7 @@ function init(description) {
 
           // Refresh awesomplete values
           existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
-          awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+          awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
         }
       };
       xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
@@ -622,14 +620,14 @@ function init(description) {
         refreshToken(basePath);
 
         existingTags = existingTags.filter((tagItem) => tagItem !== tag);
-        awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+        awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
       }
     });
   });
 
   const autocompleteFields = document.querySelectorAll('input[data-multiple]');
   [...autocompleteFields].forEach((autocompleteField) => {
-    awesomepletes.push(createAwesompleteInstance(autocompleteField));
+    awesomepletes.push(createAwesompleteInstance(autocompleteField, tagsSeparator));
   });
 
   const exportForm = document.querySelector('#exportform');
@@ -642,4 +640,33 @@ function init(description) {
       });
     });
   }
+
+  const bulkCreationButton = document.querySelector('.addlink-batch-show-more-block');
+  if (bulkCreationButton != null) {
+    const toggleBulkCreationVisibility = (showMoreBlockElement, formElement) => {
+      if (bulkCreationButton.classList.contains('pure-u-0')) {
+        showMoreBlockElement.classList.remove('pure-u-0');
+        formElement.classList.add('pure-u-0');
+      } else {
+        showMoreBlockElement.classList.add('pure-u-0');
+        formElement.classList.remove('pure-u-0');
+      }
+    };
+
+    const bulkCreationForm = document.querySelector('.addlink-batch-form-block');
+
+    toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    bulkCreationButton.querySelector('a').addEventListener('click', (e) => {
+      e.preventDefault();
+      toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    });
+
+    // Force to send falsy value if the checkbox is not checked.
+    const privateButton = bulkCreationForm.querySelector('input[type="checkbox"][name="private"]');
+    const privateHiddenButton = bulkCreationForm.querySelector('input[type="hidden"][name="private"]');
+    privateButton.addEventListener('click', () => {
+      privateHiddenButton.disabled = !privateHiddenButton.disabled;
+    });
+    privateHiddenButton.disabled = privateButton.checked;
+  }
 })();
index 2f49bbd21d50690d0e070553b6b3b9e131d5e74c..cc8ccc1e0d4811de5b526e65d886f0b820058606 100644 (file)
@@ -139,6 +139,16 @@ body,
   }
 }
 
+.page-form,
+.pure-alert {
+  code {
+    display: inline-block;
+    padding: 0 2px;
+    color: $dark-grey;
+    background-color: var(--background-color);
+  }
+}
+
 // Make pure-extras alert closable.
 .pure-alert-closable {
   .fa-times {
@@ -1023,6 +1033,10 @@ body,
     &.button-red {
       background: $red;
     }
+
+    &.button-grey {
+      background: $light-grey;
+    }
   }
 
   .submit-buttons {
@@ -1047,7 +1061,7 @@ body,
   }
 
   table {
-    margin: auto;
+    margin: 10px auto 25px auto;
     width: 90%;
 
     .order {
@@ -1083,6 +1097,11 @@ body,
           position: absolute;
           right: 5%;
         }
+
+        &.button-grey {
+          position: absolute;
+          left: 5%;
+        }
       }
     }
   }
@@ -1257,11 +1276,15 @@ form {
     margin: 70px 0 25px;
   }
 
+  a {
+    color: var(--main-color);
+  }
+
   pre {
     margin: 0 20%;
     padding: 20px 0;
     text-align: left;
-    line-height: .7em;
+    line-height: 1em;
   }
 }
 
@@ -1273,6 +1296,57 @@ form {
   }
 }
 
+.loading-input {
+  position: relative;
+
+  @keyframes around {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+
+  .icon-container {
+    position: absolute;
+    right: 60px;
+    top: calc(50% - 10px);
+  }
+
+  .loader {
+    position: relative;
+    height: 20px;
+    width: 20px;
+    display: inline-block;
+    animation: around 5.4s infinite;
+
+    &::after,
+    &::before {
+      content: "";
+      background: $form-input-background;
+      position: absolute;
+      display: inline-block;
+      width: 100%;
+      height: 100%;
+      border-width: 2px;
+      border-color: #333 #333 transparent transparent;
+      border-style: solid;
+      border-radius: 20px;
+      box-sizing: border-box;
+      top: 0;
+      left: 0;
+      animation: around 0.7s ease-in-out infinite;
+    }
+
+    &::after {
+      animation: around 0.7s ease-in-out 0.1s infinite;
+      background: transparent;
+    }
+  }
+}
+
 // LOGIN
 .login-form-container {
   .remember-me {
@@ -1645,6 +1719,123 @@ form {
   }
 }
 
+// SERVER PAGE
+
+.server-tables-page,
+.server-tables {
+  .window-subtitle {
+    &::before {
+      display: block;
+      margin: 8px auto;
+      background: linear-gradient(to right, var(--background-color), $dark-grey, var(--background-color));
+      width: 50%;
+      height: 1px;
+      content: '';
+    }
+  }
+
+  .server-row {
+    p {
+      height: 25px;
+      padding: 0 10px;
+    }
+  }
+
+  .server-label {
+    text-align: right;
+    font-weight: bold;
+  }
+
+  i {
+    &.fa-color-green {
+      color: $main-green;
+    }
+
+    &.fa-color-orange {
+      color: $orange;
+    }
+
+    &.fa-color-red {
+      color: $red;
+    }
+  }
+
+  @media screen and (max-width: 64em) {
+    .server-label {
+      text-align: center;
+    }
+
+    .server-row {
+      p {
+        text-align: center;
+      }
+    }
+  }
+}
+
+// Batch creation
+input[name='save_edit_batch'] {
+  @extend %page-form-button;
+}
+
+.addlink-batch-show-more {
+  display: flex;
+  align-items: center;
+  margin: 20px 0 8px;
+
+  a {
+    color: var(--main-color);
+    text-decoration: none;
+  }
+
+  &::before,
+  &::after {
+    content: "";
+    flex-grow: 1;
+    background: rgba(0, 0, 0, 0.35);
+    height: 1px;
+    font-size: 0;
+    line-height: 0;
+  }
+
+  &::before {
+    margin: 0 16px 0 0;
+  }
+
+  &::after {
+    margin: 0 0 0 16px;
+  }
+}
+
+.dark-layer {
+  display: none;
+  position: fixed;
+  height: 100%;
+  width: 100%;
+  z-index: 998;
+  background-color: rgba(0, 0, 0, .75);
+  color: #fff;
+
+  .screen-center {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    text-align: center;
+    min-height: 100vh;
+  }
+
+  .progressbar {
+    width: 33%;
+  }
+}
+
+.addlink-batch-form-block {
+  .pure-alert {
+    margin: 25px 0 0 0;
+  }
+}
+
 // Print rules
 @media print {
   .shaarli-menu {
index 1688dce07217a781c8edf6c58368d47b639a9df0..33e178afeb07a24d2d8a8c6f327b1f52a2ea556e 100644 (file)
@@ -1122,6 +1122,16 @@ ul.errors {
     float: left;
 }
 
+ul.warnings {
+    color: orange;
+    float: left;
+}
+
+ul.successes {
+    color: green;
+    float: left;
+}
+
 #pluginsadmin {
     width: 80%;
     padding: 20px 0 0 20px;
@@ -1248,3 +1258,54 @@ ul.errors {
     width: 0%;
     height: 10px;
 }
+
+.loading-input {
+    position: relative;
+}
+
+@keyframes around {
+    0% {
+        transform: rotate(0deg);
+    }
+
+    100% {
+        transform: rotate(360deg);
+    }
+}
+
+.loading-input .icon-container {
+    position: absolute;
+    right: 60px;
+    top: calc(50% - 10px);
+}
+
+.loading-input .loader {
+    position: relative;
+    height: 20px;
+    width: 20px;
+    display: inline-block;
+    animation: around 5.4s infinite;
+}
+
+.loading-input .loader::after,
+.loading-input .loader::before {
+     content: "";
+     background: #eee;
+     position: absolute;
+     display: inline-block;
+     width: 100%;
+     height: 100%;
+     border-width: 2px;
+     border-color: #333 #333 transparent transparent;
+     border-style: solid;
+     border-radius: 20px;
+     box-sizing: border-box;
+     top: 0;
+     left: 0;
+     animation: around 0.7s ease-in-out infinite;
+}
+
+.loading-input .loader::after {
+     animation: around 0.7s ease-in-out 0.1s infinite;
+     background: transparent;
+}
index 66830b59dd7e90e1c6c6dcbf03869a12284156e1..55f1c37dfb6811e181c07287619c8f4633bd27a3 100644 (file)
@@ -2,29 +2,38 @@ import Awesomplete from 'awesomplete';
 import 'awesomplete/awesomplete.css';
 
 (() => {
-  const awp = Awesomplete.$;
   const autocompleteFields = document.querySelectorAll('input[data-multiple]');
+  const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
+  const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
+
   [...autocompleteFields].forEach((autocompleteField) => {
-    const awesomplete = new Awesomplete(awp(autocompleteField));
-    awesomplete.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]);
-    awesomplete.replace = (text) => {
-      const before = awesomplete.input.value.match(/^.+ \s*|/)[0];
-      awesomplete.input.value = `${before}${text} `;
+    const awesome = new Awesomplete(Awesomplete.$(autocompleteField));
+
+    // Tags are separated by separator
+    awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(
+      text,
+      input.match(new RegExp(`[^${tagsSeparator}]*$`))[0],
+    );
+    // Insert new selected tag in the input
+    awesome.replace = (text) => {
+      const before = awesome.input.value.match(new RegExp(`^.+${tagsSeparator}+|`))[0];
+      awesome.input.value = `${before}${text}${tagsSeparator}`;
     };
-    awesomplete.minChars = 1;
+    // Highlight found items
+    awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${tagsSeparator}]*$`))[0]);
 
-    autocompleteField.addEventListener('input', () => {
-      const proposedTags = autocompleteField.getAttribute('data-list').replace(/,/g, '').split(' ');
-      const reg = /(\w+) /g;
-      let match;
-      while ((match = reg.exec(autocompleteField.value)) !== null) {
-        const id = proposedTags.indexOf(match[1]);
-        if (id !== -1) {
-          proposedTags.splice(id, 1);
+    // Don't display already selected items
+    // WARNING: pseudo classes does not seem to work with string litterals...
+    const reg = new RegExp(`([^${tagsSeparator}]+)${tagsSeparator}`, 'g');
+    let match;
+    awesome.data = (item, input) => {
+      while ((match = reg.exec(input))) {
+        if (item === match[1]) {
+          return '';
         }
       }
-
-      awesomplete.list = proposedTags;
-    });
+      return item;
+    };
+    awesome.minChars = 1;
   });
 })();
index c0855e4743a443abf4487f7a7eee60b803e84fe5..138319cabd36ca3cfd3576b014192f7bf76c33f9 100644 (file)
         "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": {
@@ -58,6 +59,7 @@
             "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
             "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
             "Shaarli\\Front\\Exception\\": "application/front/exceptions",
+            "Shaarli\\Helper\\": "application/helper",
             "Shaarli\\Http\\": "application/http",
             "Shaarli\\Legacy\\": "application/legacy",
             "Shaarli\\Netscape\\": "application/netscape",
index c379d8e770bcde9c325b373b6f8315e5ea79a8f8..0023df8806dfcd3e53694349ab1e08ad8f2774db 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "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",
index c152fe92377ffb36fdf93adbf518b4ee93dec3e7..fc406c00d9c299d8ccc879d4948e51c648be2b03 100644 (file)
@@ -1,3 +1,4 @@
+
 # Docker
 
 [Docker](https://docs.docker.com/get-started/overview/) is an open platform for developing, shipping, and running applications
@@ -113,9 +114,11 @@ $ mkdir shaarli && cd shaarli
 # Download the latest version of Shaarli's docker-compose.yml
 $ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/latest/docker-compose.yml -o docker-compose.yml
 # Create the .env file and fill in your VPS and domain information
-# (replace <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!
@@ -224,4 +227,4 @@ $ docker system prune
 - [docker pull](https://docs.docker.com/engine/reference/commandline/pull/)
 - [docker run](https://docs.docker.com/engine/reference/commandline/run/)
 - [docker-compose logs](https://docs.docker.com/compose/reference/logs/)
-- Træfik: [Getting Started](https://docs.traefik.io/), [Docker backend](https://docs.traefik.io/configuration/backends/docker/), [Let's Encrypt](https://docs.traefik.io/user-guide/docker-and-lets-encrypt/), [Docker image](https://hub.docker.com/_/traefik/)
\ No newline at end of file
+- Træfik: [Getting Started](https://docs.traefik.io/), [Docker backend](https://docs.traefik.io/configuration/backends/docker/), [Let's Encrypt](https://docs.traefik.io/user-guide/docker-and-lets-encrypt/), [Docker image](https://hub.docker.com/_/traefik/)
index 01071d8e550775d7836d99a6129ca5871a7a3d27..2a36ea29d1c99ea334029d520d388816f4d9ec84 100644 (file)
@@ -73,7 +73,7 @@ var_dump(getInfo($baseUrl, $secret));
 ### 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:
 
 ```
index 8cb39934603c35aaa4b4b8ebb42fb6305052fab1..a49b60334c11526bd289ffbd982e04bfbce5ab9d 100644 (file)
@@ -193,19 +193,24 @@ sudo nano /etc/apache2/sites-available/shaarli.mydomain.org.conf
         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>
 ```
 
@@ -296,7 +301,7 @@ server {
     location / {
         # default index file when no file URI is requested
         index index.php;
-        try_files $uri /index.php$is_args$args;
+        try_files _ /index.php$is_args$args;
     }
 
     location ~ (index)\.php$ {
@@ -309,20 +314,9 @@ server {
         include        fastcgi.conf;
     }
 
-    location ~ \.php$ {
-        # deny access to all other PHP scripts
-        # disable this if you host other PHP applications on the same virtualhost
-        deny all;
-    }
-
-    location ~ /\. {
-        # deny access to dotfiles
-        deny all;
-    }
-
-    location ~ ~$ {
-        # deny access to temp editor files, e.g. "script.php~"
-        deny all;
+    location ~ /doc/html/ {
+        default_type "text/html";
+        try_files $uri $uri/ $uri.html =404;
     }
 
     location = /favicon.ico {
@@ -331,13 +325,12 @@ server {
     }
 
     # allow client-side caching of static files
-    location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
+    location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
         expires    max;
         add_header Cache-Control "public, must-revalidate, proxy-revalidate";
         # HTTP 1.0 compatibility
         add_header Pragma public;
     }
-
 }
 ```
 
index 263fb7616014ebba6e2a4384d9e1741c21a93106..b1326ccee9b8bf1c84d27332829aeadaa8943f6a 100644 (file)
@@ -74,6 +74,7 @@ Some settings can be configured directly from a web browser by accesing the `Too
         "timezone": "Europe\/Paris",
         "title": "My Shaarli",
         "header_link": "?"
+        "tags_separator": " "
     },
     "dev": {
         "debug": false,
@@ -150,8 +151,10 @@ _These settings should not be edited_
 - **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
 - **enabled_plugins**: List of enabled plugins.
 - **default_note_title**: Default title of a new note.
+- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
 - **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags.
 - **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
+- **tags_separator**: Defines your tags separator (default: whitespace).
 
 ### Security
 
@@ -163,6 +166,22 @@ _These settings should not be edited_
 - **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy.
 - **allowed_protocols**: List of allowed protocols in shaare URLs or markdown-rendered descriptions. Useful if you want to store `javascript:` links (bookmarklets) in Shaarli (default: `["ftp", "ftps", "magnet"]`).
 
+### Formatter
+
+Single string value. Default available:
+
+  - `default`: supports line breaks, URL and hashtag auto-links.
+  - `markdown`: supports [Markdown](https://daringfireball.net/projects/markdown/syntax).
+  - `markdownExtra`: adds [extra](https://michelf.ca/projects/php-markdown/extra/) flavor to Markdown.
+
+### Formatter Settings
+
+Additional settings applied to formatters.
+
+#### default
+
+  - **autolink**: boolean to enable or disable automatic linkification of URL and hashtags.
+
 ### Resources
 
 - **data_dir**: Data directory.
index 5c085e039a1d15cfa6bfa95596ef006888a04585..c42e8ffefe083a0a01eee2be35246cee522800f9 100644 (file)
@@ -6,7 +6,7 @@ Please read [Contributing to Shaarli](https://github.com/shaarli/Shaarli/tree/ma
 
 
 - [Unit tests](Unit-tests)
-- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). 
+- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript).
 Run `make eslint` to check JS style.
 - [GnuPG signature](GnuPG-signature) for tags/releases
 
@@ -51,12 +51,12 @@ PHP (managed through [`composer.json`](https://github.com/shaarli/Shaarli/blob/m
 
 ## Link structure
 
-Every link available through the `LinkDB` object is represented as an array 
+Every link available through the `LinkDB` object is represented as an array
 containing the following fields:
 
   * `id` (integer): Unique identifier.
   * `title` (string): Title of the link.
-  * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.).  
+  * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.).
            Can be absolute or relative for Notes.
   * `real_url` (string): Real destination URL, can be redirected, encoded, etc.
   * `shorturl` (string): Permalink small hash.
@@ -66,7 +66,7 @@ containing the following fields:
   * `thumbnail` (string|boolean): relative path of the thumbnail cache file, or false if there isn't any.
   * `created` (DateTime): link creation date time.
   * `updated` (DateTime): last modification date time.
-  
+
 Small hashes are used to make a link to an entry in Shaarli. They are unique: the date of the item (eg. `20110923_150523`) is hashed with CRC32, then converted to base64 and some characters are replaced. They are always 6 characters longs and use only `A-Z a-z 0-9 - _` and `@`.
 
 
@@ -163,11 +163,13 @@ See [`.travis.yml`](https://github.com/shaarli/Shaarli/blob/master/.travis.yml).
 
 ## Static analysis
 
-Patches should try to stick to the [PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), especially:
+Patches should try to stick to the [PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), and must follow:
 
 - [PSR-1](http://www.php-fig.org/psr/psr-1/) - Basic Coding Standard
 - [PSR-2](http://www.php-fig.org/psr/psr-2/) - Coding Style Guide
+- [PSR-12](http://www.php-fig.org/psr/psr-12/) - Extended Coding Style  Guide
 
+These are enforced on pull requests using our Continuous Integration tools.
 
 **Work in progress:** Static analysis is currently being discussed here: in [#95 - Fix coding style (static analysis)](https://github.com/shaarli/Shaarli/issues/95), [#130 - Continuous Integration tools & features](https://github.com/shaarli/Shaarli/issues/130)
 
index f09fadc2925db027873cd2d788ac6a239a6ffa68..79654011b49f910aeb380a805736f812fd6e2490 100644 (file)
@@ -139,6 +139,31 @@ Each file contain two keys:
 
 > 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.
index 2c77240678329448dd22e3fdfddccd3be857cc3a..d79be9ce67d39bde0ffba2da36b51a3642d72af2 100644 (file)
@@ -64,6 +64,14 @@ git pull upstream master
 
 # 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
index a3de4b1c42424a2fcd25fccc17513b11e8556a5d..4ebae447ead6ce6aa072bb716e02f233a9df7374 100644 (file)
@@ -2,12 +2,13 @@
 # Shaarli - Docker Compose example configuration
 #
 # See:
-# - https://shaarli.readthedocs.io/en/master/docker/shaarli-images/
-# - https://shaarli.readthedocs.io/en/master/guides/install-shaarli-with-debian9-and-docker/
+# - https://shaarli.readthedocs.io/en/master/Docker/#docker-compose
 #
 # Environment variables:
 # - SHAARLI_VIRTUAL_HOST      Fully Qualified Domain Name for the Shaarli instance
 # - SHAARLI_LETSENCRYPT_EMAIL Contact email for certificate renewal
+# - SHAARLI_DOCKER_TAG        Shaarli docker tag to use
+#                             See: https://hub.docker.com/r/shaarli/shaarli/tags
 version: '3'
 
 networks:
@@ -20,7 +21,7 @@ volumes:
 
 services:
   shaarli:
-    image: shaarli/shaarli:master
+    image: shaarli/shaarli:${SHAARLI_DOCKER_TAG}
     build: ./
     networks:
       - http-proxy
@@ -40,7 +41,7 @@ services:
       - "--entrypoints=Name:https Address::443 TLS"
       - "--retry"
       - "--docker"
-      - "--docker.domain=docker.localhost"
+      - "--docker.domain=${SHAARLI_VIRTUAL_HOST}"
       - "--docker.exposedbydefault=true"
       - "--docker.watch=true"
       - "--acme"
index f7baedfb4c8cfac01728e8ed357eed95f9028253..01492af4640e7e1a6ce34255d7235edc019e14c8 100644 (file)
@@ -1,8 +1,8 @@
 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"
@@ -20,58 +20,31 @@ msgstr ""
 "X-Poedit-SearchPath-3: init.php\n"
 "X-Poedit-SearchPath-4: plugins\n"
 
-#: application/ApplicationUtils.php:161
-#, php-format
-msgid ""
-"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
-"cannot run. Your PHP version has known security vulnerabilities and should "
-"be updated as soon as possible."
-msgstr ""
-"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
-"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
-"connues et devrait être mise à jour au plus tôt."
-
-#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204
-msgid "directory is not readable"
-msgstr "le répertoire n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:207
-msgid "directory is not writable"
-msgstr "le répertoire n'est pas accessible en écriture"
-
-#: application/ApplicationUtils.php:225
-msgid "file is not readable"
-msgstr "le fichier n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:228
-msgid "file is not writable"
-msgstr "le fichier n'est pas accessible en écriture"
-
-#: application/History.php:179
+#: application/History.php: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"
 
@@ -83,46 +56,46 @@ msgstr ""
 "l'extension php-gd doit être chargée pour utiliser les miniatures. Les "
 "miniatures sont désormais désactivées. Rechargez la page."
 
-#: application/Utils.php:383
+#: application/Utils.php: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"
@@ -145,11 +118,11 @@ msgstr ""
 "\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"
@@ -213,19 +186,19 @@ msgstr ""
 "| 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"
@@ -274,11 +247,11 @@ msgstr ""
 "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."
@@ -286,12 +259,12 @@ msgstr ""
 "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"
@@ -301,46 +274,48 @@ msgid "An error occurred while trying to save plugins loading order."
 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."
 
@@ -372,70 +347,47 @@ msgstr ""
 "le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
 "légères."
 
-#: application/front/controller/admin/ManageShaareController.php:29
-#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-msgid "Shaare a new link"
-msgstr "Partager un nouveau lien"
+#: application/front/controller/admin/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"
 
@@ -467,6 +419,61 @@ msgstr ""
 "Une erreur s'est produite lors de la sauvegarde de la configuration des "
 "plugins : "
 
+#: application/front/controller/admin/ServerController.php:35
+msgid "Check disabled"
+msgstr "Vérification désactivée"
+
+#: application/front/controller/admin/ServerController.php:57
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Server administration"
+msgstr "Administration serveur"
+
+#: application/front/controller/admin/ServerController.php:74
+msgid "Thumbnails cache has been cleared."
+msgstr "Le cache des miniatures a été vidé."
+
+#: application/front/controller/admin/ServerController.php: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"
@@ -478,33 +485,62 @@ msgstr "Mise à jour des miniatures"
 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 "
@@ -523,14 +559,14 @@ msgstr ""
 "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 :"
 
@@ -554,9 +590,9 @@ msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
 msgid "Picture wall"
 msgstr "Mur d'images"
 
-#: application/front/controller/visitor/TagCloudController.php: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."
@@ -584,6 +620,94 @@ msgstr ""
 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."
@@ -634,7 +758,7 @@ msgstr ""
 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\">"
 
@@ -660,11 +784,11 @@ msgstr ""
 "a été importé avec succès en %d secondes : %d liens importés, %d liens "
 "écrasés, %d liens ignorés."
 
-#: application/plugin/PluginManager.php: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."
@@ -678,7 +802,7 @@ msgstr "Impossible de purger %s : le répertoire n'existe pas"
 msgid "An error occurred while running the update "
 msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour "
 
-#: index.php:65
+#: index.php:81
 msgid "Shared bookmarks on "
 msgstr "Liens partagés sur "
 
@@ -695,11 +819,11 @@ msgstr "Shaare"
 msgid "Adds the addlink input on the linklist page."
 msgstr "Ajoute le formulaire d'ajout de liens sur la page principale."
 
-#: plugins/archiveorg/archiveorg.php: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."
 
@@ -729,7 +853,7 @@ msgstr "Couleur de fond (gris léger)"
 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."
@@ -737,11 +861,11 @@ msgstr ""
 "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"
 
@@ -763,7 +887,7 @@ msgstr ""
 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."
@@ -771,27 +895,27 @@ msgstr ""
 "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."
@@ -819,11 +943,11 @@ msgstr "Mauvaise réponse du hub %s"
 msgid "Enable PubSubHubbub feed publishing."
 msgstr "Active la publication de flux vers PubSubHubbub."
 
-#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70
+#: plugins/qrcode/qrcode.php: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."
@@ -831,15 +955,15 @@ msgstr ""
 "Erreur de l'extension Wallabag : Merci de définir le paramètre « "
 "WALLABAG_URL » dans la page d'administration des extensions."
 
-#: plugins/wallabag/wallabag.php:47
+#: plugins/wallabag/wallabag.php: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)"
 
@@ -851,6 +975,48 @@ msgstr "Désolé, il y a rien à voir ici."
 msgid "URL or leave empty to post a note"
 msgstr "URL ou laisser vide pour créer une note"
 
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+msgid "BULK CREATION"
+msgstr "CRÉATION DE MASSE"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+msgid "Metadata asynchronous retrieval is disabled."
+msgstr "La récupération asynchrone des meta-données est désactivée."
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+msgid ""
+"We recommend that you enable the setting <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"
@@ -885,14 +1051,40 @@ msgstr "Renommer le tag"
 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"
@@ -1016,71 +1208,72 @@ msgstr ""
 "miniatures."
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
 msgid "Synchronize thumbnails"
 msgstr "Synchroniser les miniatures"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "All"
 msgstr "Tous"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
 msgid "Only common media hosts"
 msgstr "Seulement les hébergeurs de média connus"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
 msgid "None"
 msgstr "Aucune"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
-msgid "Save"
-msgstr "Enregistrer"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid "The Daily Shaarli"
-msgstr "Le Quotidien Shaarli"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
-msgid "1 RSS entry per day"
-msgstr "1 entrée RSS par jour"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
-msgid "Previous day"
-msgstr "Jour précédent"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "All links of one day in a single page."
-msgstr "Tous les liens d'un jour sur une page."
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
-msgid "Next day"
-msgstr "Jour suivant"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+msgid "1 RSS entry per :type"
+msgid_plural ""
+msgstr[0] "1 entrée RSS par :type"
+msgstr[1] ""
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+msgid "Previous :type"
+msgid_plural ""
+msgstr[0] ":type précédent"
+msgstr[1] "Jour précédent"
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
+msgid "All links of one :type in a single page."
+msgid_plural ""
+msgstr[0] "Tous les liens d'un :type sur une page."
+msgstr[1] "Tous les liens d'un jour sur une page."
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+msgid "Next :type"
+msgid_plural ""
+msgstr[0] ":type suivant"
+msgstr[1] ""
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
 msgid "Edit Shaare"
 msgstr "Modifier le Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
 msgid "New Shaare"
 msgstr "Nouveau Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
 msgid "Created:"
 msgstr "Création :"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 msgid "URL"
 msgstr "URL"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 msgid "Title"
 msgstr "Titre"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
@@ -1088,33 +1281,27 @@ msgstr "Titre"
 msgid "Description"
 msgstr "Description"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
-msgid "Tags"
-msgstr "Tags"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-msgid "Private"
-msgstr "Privé"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
 msgid "Description will be rendered with"
 msgstr "La description sera générée avec"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
 msgid "Markdown syntax documentation"
 msgstr "Documentation sur la syntaxe Markdown"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
 msgid "Markdown syntax"
 msgstr "la syntaxe Markdown"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:115
+msgid "Cancel"
+msgstr "Annuler"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
 msgid "Apply Changes"
 msgstr "Appliquer les changements"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php: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
@@ -1122,6 +1309,11 @@ msgstr "Appliquer les changements"
 msgid "Delete"
 msgstr "Supprimer"
 
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+msgid "Save all"
+msgstr "Tout enregistrer"
+
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
 msgid "Export Database"
 msgstr "Exporter les données"
@@ -1179,10 +1371,6 @@ msgstr "Les doublons s'appuient sur les URL"
 msgid "Add default tags"
 msgstr "Ajouter des tags par défaut"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Install Shaarli"
-msgstr "Installation de Shaarli"
-
 #: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
 msgid "It looks like it's the first time you run Shaarli. Please configure it."
 msgstr ""
@@ -1215,6 +1403,10 @@ msgstr "Mes liens"
 msgid "Install"
 msgstr "Installer"
 
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190
+msgid "Server requirements"
+msgstr "Pré-requis serveur"
+
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
 msgid "shaare"
@@ -1288,8 +1480,8 @@ msgid "without any tag"
 msgstr "sans tag"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:41
 msgid "Fold"
 msgstr "Replier"
 
@@ -1313,6 +1505,10 @@ msgstr "Changer statut épinglé"
 msgid "Sticky"
 msgstr "Épinglé"
 
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+msgid "Share a private link"
+msgstr "Partager un lien privé"
+
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5
 msgid "Filters"
@@ -1331,7 +1527,7 @@ msgstr "Afficher uniquement les liens publics"
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18
 msgid "Filter untagged links"
-msgstr "Filtrer par liens privés"
+msgstr "Filtrer par liens sans tag"
 
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24
@@ -1342,8 +1538,8 @@ msgstr "Tout sélectionner"
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:29
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:89
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
 msgid "Fold all"
 msgstr "Replier tout"
 
@@ -1359,9 +1555,9 @@ msgid "Remember me"
 msgstr "Rester connecté"
 
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:49
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
 msgid "by the Shaarli community"
 msgstr "par la communauté Shaarli"
 
@@ -1370,18 +1566,23 @@ 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 ?"
 
@@ -1511,6 +1712,100 @@ msgstr "Configuration des extensions"
 msgid "No parameter available."
 msgstr "Aucun paramètre disponible."
 
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+msgid "General"
+msgstr "Général"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+msgid "Index URL"
+msgstr "URL de l'index"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Base path"
+msgstr "Chemin de base"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+msgid "Client IP"
+msgstr "IP du client"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
+msgid "Trusted reverse proxies"
+msgstr "Reverse proxies de confiance"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
+msgid "N/A"
+msgstr "N/A"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
+msgid "Visit releases page on Github"
+msgstr "Visiter la page des releases sur Github"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
+msgid "Synchronize all link thumbnails"
+msgstr "Synchroniser toutes les miniatures"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:2
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:2
+msgid "Permissions"
+msgstr "Permissions"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:8
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:8
+msgid "There are permissions that need to be fixed."
+msgstr "Il y a des permissions qui doivent être corrigées."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:23
+msgid "All read/write permissions are properly set."
+msgstr "Toutes les permissions de lecture/écriture sont définies correctement."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:32
+msgid "Running PHP"
+msgstr "Fonctionnant avec PHP"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:36
+msgid "End of life: "
+msgstr "Fin de vie : "
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:48
+msgid "Extension"
+msgstr "Extension"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:49
+msgid "Usage"
+msgstr "Utilisation"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:50
+msgid "Status"
+msgstr "Statut"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:51
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:66
+msgid "Loaded"
+msgstr "Chargé"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Required"
+msgstr "Obligatoire"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Optional"
+msgstr "Optionnel"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:70
+msgid "Not loaded"
+msgstr "Non chargé"
+
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 msgid "tags"
@@ -1561,15 +1856,19 @@ msgstr "Configurer Shaarli"
 msgid "Enable, disable and configure plugins"
 msgstr "Activer, désactiver et configurer les extensions"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
+msgid "Check instance's server configuration"
+msgstr "Vérifier la configuration serveur de l'instance"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
 msgid "Change your password"
 msgstr "Modifier le mot de passe"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 msgid "Rename or delete a tag in all links"
 msgstr "Renommer ou supprimer un tag dans tous les liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 msgid ""
 "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
 "delicious...)"
@@ -1577,11 +1876,11 @@ msgstr ""
 "Importer des marques pages au format Netscape HTML (comme exportés depuis "
 "Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 msgid "Import links"
 msgstr "Importer des liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
 msgid ""
 "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
 "Opera, delicious...)"
@@ -1589,15 +1888,11 @@ msgstr ""
 "Exporter les marques pages au format Netscape HTML (comme exportés depuis "
 "Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54
 msgid "Export database"
 msgstr "Exporter les données"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:55
-msgid "Synchronize all link thumbnails"
-msgstr "Synchroniser toutes les miniatures"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
 msgid ""
 "Drag one of these button to your bookmarks toolbar or right-click it and "
 "\"Bookmark This Link\""
@@ -1605,13 +1900,13 @@ msgstr ""
 "Glisser un de ces boutons dans votre barre de favoris ou cliquer droit "
 "dessus et « Ajouter aux favoris »"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
 msgid "then click on the bookmarklet in any page you want to share."
 msgstr ""
 "puis cliquer sur le marque-page depuis un site que vous souhaitez partager."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
 msgid ""
 "Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
 "Link"
@@ -1619,40 +1914,40 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « "
 "Ajouter aux favoris »"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
 msgid "then click ✚Shaare link button in any page you want to share"
 msgstr "puis cliquer sur ✚Shaare depuis un site que vous souhaitez partager"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
 msgid "The selected text is too long, it will be truncated."
 msgstr "Le texte sélectionné est trop long, il sera tronqué."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "Shaare link"
 msgstr "Shaare"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:107
 msgid ""
 "Then click ✚Add Note button anytime to start composing a private Note (text "
 "post) to your Shaarli"
 msgstr ""
 "Puis cliquer sur ✚Add Note pour commencer à rédiger une Note sur Shaarli"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
 msgid "Add Note"
 msgstr "Ajouter une Note"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:132
 msgid "3rd party"
 msgstr "Applications tierces"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:140
 msgid "plugin"
 msgstr "extension"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
 msgid ""
 "Drag this link to your bookmarks toolbar, or right-click it and choose "
 "Bookmark This Link"
@@ -1660,11 +1955,11 @@ msgstr ""
 "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"
diff --git a/inc/languages/ru/LC_MESSAGES/shaarli.po b/inc/languages/ru/LC_MESSAGES/shaarli.po
new file mode 100644 (file)
index 0000000..98e7042
--- /dev/null
@@ -0,0 +1,1944 @@
+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 ""
+"Перетащите эту ссылку на панель закладок или щелкните по ней правой кнопкой "
+"мыши и выберите \"Добавить ссылку в закладки\""
index b10397dda2d079cb785caa6fddef262d77331503..862c53efa5d6716ba6faef19b0adf073266a5587 100644 (file)
--- a/index.php
+++ b/index.php
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Shaarli - The personal, minimalist, super-fast, database free, bookmarking service.
  *
@@ -25,9 +26,13 @@ require_once 'application/Utils.php';
 
 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;
@@ -48,10 +53,22 @@ if ($conf->get('dev.debug', false)) {
     });
 }
 
+$logger = new Logger(
+    dirname($conf->get('resource.log')),
+    !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
+    ['filename' => basename($conf->get('resource.log'))]
+);
 $sessionManager = new SessionManager($_SESSION, $conf, session_save_path());
 $sessionManager->initialize();
 $cookieManager = new CookieManager($_COOKIE);
-$loginManager = new LoginManager($conf, $sessionManager, $cookieManager);
+$banManager = new BanManager(
+    $conf->get('security.trusted_proxies', []),
+    $conf->get('security.ban_after'),
+    $conf->get('security.ban_duration'),
+    $conf->get('resource.ban_file', 'data/ipbans.php'),
+    $logger
+);
+$loginManager = new LoginManager($conf, $sessionManager, $cookieManager, $banManager, $logger);
 $loginManager->generateStaySignedInToken($_SERVER['REMOTE_ADDR']);
 
 // Sniff browser language and set date format accordingly.
@@ -62,16 +79,26 @@ if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
 new Languages(setlocale(LC_MESSAGES, 0), $conf);
 
 $conf->setEmpty('general.timezone', date_default_timezone_get());
-$conf->setEmpty('general.title', t('Shared bookmarks on '). escape(index_url($_SERVER)));
+$conf->setEmpty('general.title', t('Shared bookmarks on ') . escape(index_url($_SERVER)));
 
-RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory
+RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl') . '/' . $conf->get('resource.theme') . '/'; // template directory
 RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory
 
 date_default_timezone_set($conf->get('general.timezone', 'UTC'));
 
 $loginManager->checkLoginState(client_ip_id($_SERVER));
 
-$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager);
+$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);
 
@@ -110,13 +137,16 @@ $app->group('/admin', function () {
     $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save');
     $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index');
     $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save');
-    $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:addShaare');
-    $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayCreateForm');
-    $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayEditForm');
-    $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:save');
-    $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ManageShaareController:deleteBookmark');
-    $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ManageShaareController:changeVisibility');
-    $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark');
+    $this->post('/tags/change-separator', '\Shaarli\Front\Controller\Admin\ManageTagController:changeSeparator');
+    $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare');
+    $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm');
+    $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm');
+    $this->get('/shaare/private/{hash}', '\Shaarli\Front\Controller\Admin\ShaareManageController:sharePrivate');
+    $this->post('/shaare-batch', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateBatchForms');
+    $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:save');
+    $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ShaareManageController:deleteBookmark');
+    $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ShaareManageController:changeVisibility');
+    $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ShaareManageController:pinBookmark');
     $this->patch(
         '/shaare/{id:[0-9]+}/update-thumbnail',
         '\Shaarli\Front\Controller\Admin\ThumbnailsController:ajaxUpdate'
@@ -128,11 +158,22 @@ $app->group('/admin', function () {
     $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index');
     $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
     $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
+    $this->get('/server', '\Shaarli\Front\Controller\Admin\ServerController:index');
+    $this->get('/clear-cache', '\Shaarli\Front\Controller\Admin\ServerController:clearCache');
     $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
-
+    $this->get('/metadata', '\Shaarli\Front\Controller\Admin\MetadataController:ajaxRetrieveTitle');
     $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
 })->add('\Shaarli\Front\ShaarliAdminMiddleware');
 
+$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 () {
@@ -151,6 +192,12 @@ $app->group('/api/v1', function () {
     $this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory');
 })->add('\Shaarli\Api\ApiMiddleware');
 
-$response = $app->run(true);
-
-$app->respond($response);
+try {
+    $response = $app->run(true);
+    $app->respond($response);
+} catch (Throwable $e) {
+    die(nl2br(
+        'An unexpected error happened, and the error template could not be displayed.' . PHP_EOL . PHP_EOL .
+        exception2text($e)
+    ));
+}
index ab0e4ea743648684298d048a1e2e01ea7f9c9d02..d84627129516969bff1e1baed69540d415aea369 100644 (file)
--- a/init.php
+++ b/init.php
@@ -2,7 +2,7 @@
 
 require_once __DIR__ . '/vendor/autoload.php';
 
-use Shaarli\ApplicationUtils;
+use Shaarli\Helper\ApplicationUtils;
 use Shaarli\Security\SessionManager;
 
 // Set 'UTC' as the default timezone if it is not defined in php.ini
index 8a24512a4bba538f0a89687bb0a0147b87d65898..b879b22359b719e723708a7fab40e060a0fdef06 100644 (file)
@@ -7,6 +7,7 @@
     "awesomplete": "^1.1.2",
     "blazy": "^1.8.2",
     "fork-awesome": "^1.1.7",
+    "he": "^1.2.0",
     "pure-extras": "^1.0.0",
     "purecss": "^1.0.0"
   },
index 29b95d56dacffcff94e3a92cd6137289750e6686..9bdc872092a0d3aff71bb1652db2148f3cc1790f 100644 (file)
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -5,13 +5,19 @@
   <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>
index ab6ed6de0cc34f2eca752ea293c90b1b45655a4b..80b1dd95bee836214e1c29f796b1f7a1e37a1721 100644 (file)
@@ -17,26 +17,26 @@ use Shaarli\Render\TemplatePage;
 function hook_addlink_toolbar_render_header($data)
 {
     if ($data['_PAGE_'] == TemplatePage::LINKLIST && $data['_LOGGEDIN_'] === true) {
-        $form = array(
-            'attr' => array(
+        $form = [
+            'attr' => [
                 'method' => 'GET',
                 'action' => $data['_BASE_PATH_'] . '/admin/shaare',
                 'name'   => 'addform',
                 'class'  => 'addform',
-            ),
-            'inputs' => array(
-                array(
+            ],
+            'inputs' => [
+                [
                     'type' => 'text',
                     'name' => 'post',
                     'placeholder' => t('URI'),
-                ),
-                array(
+                ],
+                [
                     'type' => 'submit',
                     'value' => t('Add link'),
                     'class' => 'bigbutton',
-                ),
-            ),
-        );
+                ],
+            ],
+        ];
         $data['fields_toolbar'][] = $form;
     }
 
index ed2715322686e82ca4a9373d3417f57babc67da1..88f2b65339b2ca41c0c0f9571d801857751435bf 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Plugin Archive.org.
  *
index e1fd5cfbdb19daf547519af2d7f41482c88f7b96..d3e1fa761a86501127bad13a985c42c6f45540ae 100644 (file)
@@ -28,14 +28,14 @@ function default_colors_init($conf)
 {
     $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];
     }
@@ -46,6 +46,20 @@ function default_colors_init($conf)
     }
 }
 
+/**
+ * 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.
  *
@@ -56,7 +70,7 @@ function default_colors_init($conf)
 function hook_default_colors_render_includes($data)
 {
     $file = PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css';
-    if (file_exists($file )) {
+    if (file_exists($file)) {
         $data['css_files'][] = $file ;
     }
 
@@ -75,7 +89,7 @@ function default_colors_generate_css_file($params): void
     $content = '';
     foreach (DEFAULT_COLORS_PLACEHOLDERS as $rule) {
         $content .= !empty($params[$rule])
-            ? default_colors_format_css_rule($params, $rule) .';'. PHP_EOL
+            ? default_colors_format_css_rule($params, $rule) . ';' . PHP_EOL
             : '';
     }
 
@@ -99,8 +113,8 @@ function default_colors_format_css_rule($data, $parameter)
     }
 
     $key = str_replace('DEFAULT_COLORS_', '', $parameter);
-    $key = str_replace('_', '-', strtolower($key)) .'-color';
-    return '  --'. $key .': '. $data[$parameter];
+    $key = str_replace('_', '-', strtolower($key)) . '-color';
+    return '  --' . $key . ': ' . $data[$parameter];
 }
 
 
diff --git a/plugins/demo_plugin/DemoPluginController.php b/plugins/demo_plugin/DemoPluginController.php
new file mode 100644 (file)
index 0000000..b8ace9c
--- /dev/null
@@ -0,0 +1,24 @@
+<?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'));
+    }
+}
index defb01f7e4457d05f95f0f95e939297300033904..15cfc2c51cd4889cc0d38f821e0ab856806a4167 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Demo Plugin.
  *
@@ -6,6 +7,8 @@
  * Can be used by plugin developers to make their own plugin.
  */
 
+require_once __DIR__ . '/DemoPluginController.php';
+
 /*
  * RENDER HEADER, INCLUDES, FOOTER
  *
@@ -59,6 +62,17 @@ function demo_plugin_init($conf)
     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.
@@ -82,14 +96,14 @@ function hook_demo_plugin_render_header($data)
              * 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;
         }
 
@@ -115,29 +129,29 @@ function hook_demo_plugin_render_header($data)
          *   <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;
@@ -187,7 +201,7 @@ function hook_demo_plugin_render_includes($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">' .
@@ -229,13 +243,13 @@ function hook_demo_plugin_render_linklist($data)
      * and a mandatory `html` key, which contains its value.
      * It's also recommended to add key 'on' or 'off' for theme rendering.
      */
-    $action = array(
-        'attr' => array(
+    $action = [
+        'attr' => [
             'href' => '?up',
             'title' => 'Uppercase!',
-        ),
+        ],
         'html' => '←',
-    );
+    ];
 
     if (isset($_GET['up'])) {
         // Manipulate link data
@@ -275,7 +289,7 @@ function hook_demo_plugin_render_linklist($data)
 function hook_demo_plugin_render_editlink($data)
 {
     // Load HTML into a string
-    $html = file_get_contents(PluginManager::$PLUGINS_PATH .'/demo_plugin/field.html');
+    $html = file_get_contents(PluginManager::$PLUGINS_PATH . '/demo_plugin/field.html');
 
     // Replace value in HTML if it exists in $data
     if (!empty($data['link']['stuff'])) {
@@ -303,7 +317,11 @@ function hook_demo_plugin_render_editlink($data)
 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;
 }
index d46321633e9fabdc15d714a7356af2dfc23767d3..a54509892e999eff7517d0c0ff2cc77aee7d0ecd 100644 (file)
@@ -19,9 +19,9 @@ function isso_init($conf)
 {
     $issoUrl = $conf->get('plugins.ISSO_SERVER');
     if (empty($issoUrl)) {
-        $error = t('Isso plugin error: '.
+        $error = t('Isso plugin error: ' .
             'Please define the "ISSO_SERVER" setting in the plugin administration page.');
-        return array($error);
+        return [$error];
     }
 }
 
@@ -49,12 +49,12 @@ function hook_isso_render_linklist($data, $conf)
         $isso = sprintf($issoHtml, $issoUrl, $issoUrl, $link['id'], $link['id']);
         $data['plugin_end_zone'][] = $isso;
     } else {
-        $button = '<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>';
index 17b1aeccf9926a2a908350ce9a3c448f285f641d..efea8610fc01cfa7c2e71b275f41f2955cb043be 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Piwik plugin.
  * Adds tracking code on each page.
@@ -22,7 +23,7 @@ function piwik_init($conf)
     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];
     }
 }
 
index 91a9c1e554c58437c5862adfd605019865096be7..4f874f92b98b4d09464ca5e748b03fe47135dd36 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Plugin PlayVideos
  *
@@ -19,14 +20,14 @@ use Shaarli\Render\TemplatePage;
 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;
     }
 
index 8fe6799ce6d00933445a9b7b7f1652dc13387af8..299b84fb192b3886c4d2d374725a3022a7bb117e 100644 (file)
@@ -42,7 +42,7 @@ function pubsubhubbub_init($conf)
 function hook_pubsubhubbub_render_feed($data, $conf)
 {
     $feedType = $data['_PAGE_'] == TemplatePage::FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
-    $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml');
+    $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.' . $feedType . '.xml');
     $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL'));
 
     return $data;
@@ -59,10 +59,10 @@ function hook_pubsubhubbub_render_feed($data, $conf)
  */
 function hook_pubsubhubbub_save_link($data, $conf)
 {
-    $feeds = array(
-        index_url($_SERVER) .'feed/atom',
-        index_url($_SERVER) .'feed/rss',
-    );
+    $feeds = [
+        index_url($_SERVER) . 'feed/atom',
+        index_url($_SERVER) . 'feed/rss',
+    ];
 
     $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post';
     try {
@@ -87,11 +87,11 @@ function hook_pubsubhubbub_save_link($data, $conf)
  */
 function nocurl_http_post($url, $postString)
 {
-    $params = array('http' => array(
+    $params = ['http' => [
         'method' => 'POST',
         'content' => $postString,
         'user_agent' => 'PubSubHubbub-Publisher-PHP/1.0',
-    ));
+    ]];
 
     $context = stream_context_create($params);
     $fp = @fopen($url, 'rb', false, $context);
index 24fd18baf99aefe2f6f58c7a9225735f6b54ad7e..2ae10476fcb9618f99e1792e7ccd96db95ba44f4 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Plugin qrcode
  * Add QRCode containing URL for each links.
index f4a0a92bdb8148a180fe169cfc2d2316d2cf1fd3..88f84ae3a10b5b39140dac1ea7bb7a78ec3b9dd9 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Plugin\Wallabag;
 
 /**
@@ -11,20 +12,20 @@ class WallabagInstance
      *          - 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.
index d0df3501d6389b40ce2e1c340675f4debada71ea..f2003cb9953be148dde47e822de31f7af7c8dc7a 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Wallabag plugin
  */
@@ -18,10 +19,11 @@ function wallabag_init($conf)
 {
     $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');
 }
 
 /**
@@ -35,7 +37,7 @@ function wallabag_init($conf)
 function hook_wallabag_render_linklist($data, $conf)
 {
     $wallabagUrl = $conf->get('plugins.WALLABAG_URL');
-    if (empty($wallabagUrl)) {
+    if (empty($wallabagUrl) || !$data['_LOGGEDIN_']) {
         return $data;
     }
 
@@ -51,7 +53,7 @@ function hook_wallabag_render_linklist($data, $conf)
         $wallabag = sprintf(
             $wallabagHtml,
             $wallabagInstance->getWallabagUrl(),
-            urlencode($value['url']),
+            urlencode(unescape($value['url'])),
             $path,
             $linkTitle
         );
index efef5e8746ed2b165d4902a1877a550cc007b462..8947f6791831cec961c9c84a73e644a7c197ee7f 100644 (file)
@@ -120,4 +120,43 @@ class PluginManagerTest extends \Shaarli\TestCase
         $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);
+    }
 }
index 6e787d7f110f377ca7f83edb3114f21e351910a8..59dca75f572f3d92948211e72e1487f365598b6c 100644 (file)
@@ -63,41 +63,25 @@ class UtilsTest extends \Shaarli\TestCase
     }
 
     /**
-     * Log a message to a file - IPv4 client address
+     * Format a log a message - IPv4 client address
      */
-    public function testLogmIp4()
+    public function testFormatLogIp4()
     {
-        $logMessage = 'IPv4 client connected';
-        logm(self::$testLogFile, '127.0.0.1', $logMessage);
-        list($date, $ip, $message) = $this->getLastLogEntry();
+        $message = 'IPv4 client connected';
+        $log = format_log($message, '127.0.0.1');
 
-        $this->assertInstanceOf(
-            'DateTime',
-            DateTime::createFromFormat(self::$dateFormat, $date)
-        );
-        $this->assertTrue(
-            filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false
-        );
-        $this->assertEquals($logMessage, $message);
+        static::assertSame('- 127.0.0.1 - IPv4 client connected', $log);
     }
 
     /**
-     * Log a message to a file - IPv6 client address
+     * Format a log a message - IPv6 client address
      */
-    public function testLogmIp6()
+    public function testFormatLogIp6()
     {
-        $logMessage = 'IPv6 client connected';
-        logm(self::$testLogFile, '2001:db8::ff00:42:8329', $logMessage);
-        list($date, $ip, $message) = $this->getLastLogEntry();
+        $message = 'IPv6 client connected';
+        $log = format_log($message, '2001:db8::ff00:42:8329');
 
-        $this->assertInstanceOf(
-            'DateTime',
-            DateTime::createFromFormat(self::$dateFormat, $date)
-        );
-        $this->assertTrue(
-            filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
-        );
-        $this->assertEquals($logMessage, $message);
+        static::assertSame('- 2001:db8::ff00:42:8329 - IPv6 client connected', $log);
     }
 
     /**
index 7ff92f5c96968c6e12c75098dc164526c0a3224d..f755e2d2b9a9d6e0052b0a5e7875e8d539d2c03e 100644 (file)
@@ -92,8 +92,8 @@ class PostLinkTest extends TestCase
 
         $mock = $this->createMock(Router::class);
         $mock->expects($this->any())
-             ->method('relativePathFor')
-             ->willReturn('api/v1/bookmarks/1');
+             ->method('pathFor')
+             ->willReturn('/api/v1/bookmarks/1');
 
         // affect @property-read... seems to work
         $this->controller->getCi()->router = $mock;
@@ -128,7 +128,7 @@ class PostLinkTest extends TestCase
 
         $response = $this->controller->postLink($request, new Response());
         $this->assertEquals(201, $response->getStatusCode());
-        $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]);
+        $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
         $data = json_decode((string) $response->getBody(), true);
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
@@ -175,7 +175,7 @@ class PostLinkTest extends TestCase
         $response = $this->controller->postLink($request, new Response());
 
         $this->assertEquals(201, $response->getStatusCode());
-        $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]);
+        $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
         $data = json_decode((string) $response->getBody(), true);
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
@@ -229,4 +229,52 @@ class PostLinkTest extends TestCase
             \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']);
+    }
 }
index 240ee323a345cff812cc9ad529ea4ebe25f1ca7a..fe24f2eb91a1ae1d27cd0032a45cced641442a95 100644 (file)
@@ -233,4 +233,52 @@ class PutLinkTest extends \Shaarli\TestCase
 
         $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']);
+    }
 }
index daafd2503369169500923895a1f5c6d625e5875a..f619aff3f7865d7aebddcd4fb06622b52521b2e0 100644 (file)
@@ -685,22 +685,6 @@ class BookmarkFileServiceTest extends TestCase
         $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
      */
@@ -897,6 +881,37 @@ class BookmarkFileServiceTest extends TestCase
         $this->publicLinkDB->findByHash('');
     }
 
+    /**
+     * Test filterHash() on a private bookmark while logged out.
+     */
+    public function testFilterHashPrivateWhileLoggedOut()
+    {
+        $this->expectException(BookmarkNotFoundException::class);
+        $this->expectExceptionMessage('The link you are trying to reach does not exist or has been deleted');
+
+        $hash = smallHash('20141125_084734' . 6);
+
+        $this->publicLinkDB->findByHash($hash);
+    }
+
+    /**
+     * Test filterHash() with private key.
+     */
+    public function testFilterHashWithPrivateKey()
+    {
+        $hash = smallHash('20141125_084734' . 6);
+        $privateKey = 'this is usually auto generated';
+
+        $bookmark = $this->privateLinkDB->findByHash($hash);
+        $bookmark->addAdditionalContentEntry('private_key', $privateKey);
+        $this->privateLinkDB->save();
+
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+        $bookmark = $this->privateLinkDB->findByHash($hash, $privateKey);
+
+        static::assertSame(6, $bookmark->getId());
+    }
+
     /**
      * Test linksCountPerTag all tags without filter.
      * Equal occurrences should be sorted alphabetically.
@@ -1043,33 +1058,105 @@ class BookmarkFileServiceTest extends TestCase
     }
 
     /**
-     * Test filterDay while logged in
+     * Test find by dates in the middle of the datastore (sorted by dates) with a single bookmark as a result.
      */
-    public function testFilterDayLoggedIn(): void
+    public function testFilterByDateMidTimePeriodSingleBookmark(): void
     {
-        $bookmarks = $this->privateLinkDB->filterDay('20121206');
-        $expectedIds = [4, 9, 1, 0];
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_160000'),
+            $before,
+            $after
+        );
 
-        static::assertCount(4, $bookmarks);
-        foreach ($bookmarks as $bookmark) {
-            $i = ($i ?? -1) + 1;
-            static::assertSame($expectedIds[$i], $bookmark->getId());
-        }
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(9, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_172539'), $after);
     }
 
     /**
-     * Test filterDay while logged out
+     * Test find by dates in the middle of the datastore (sorted by dates) with a multiple bookmarks as a result.
      */
-    public function testFilterDayLoggedOut(): void
+    public function testFilterByDateMidTimePeriodMultipleBookmarks(): void
     {
-        $bookmarks = $this->publicLinkDB->filterDay('20121206');
-        $expectedIds = [4, 9, 1];
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_180000'),
+            $before,
+            $after
+        );
 
-        static::assertCount(3, $bookmarks);
-        foreach ($bookmarks as $bookmark) {
-            $i = ($i ?? -1) + 1;
-            static::assertSame($expectedIds[$i], $bookmark->getId());
-        }
+        static::assertCount(2, $bookmarks);
+
+        static::assertSame(1, $bookmarks[0]->getId());
+        static::assertSame(9, $bookmarks[1]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_182539'), $after);
+    }
+
+    /**
+     * Test find by dates at the end of the datastore (sorted by dates).
+     */
+    public function testFilterByDateLastTimePeriod(): void
+    {
+        $after = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20150310_114640'),
+            DateTime::createFromFormat('Ymd_His', '20450101_010101'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(41, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20150310_114633'), $before);
+        static::assertNull($after);
+    }
+
+    /**
+     * Test find by dates at the beginning of the datastore (sorted by dates).
+     */
+    public function testFilterByDateFirstTimePeriod(): void
+    {
+        $before = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20000101_101010'),
+            DateTime::createFromFormat('Ymd_His', '20100309_110000'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(11, $bookmarks[0]->getId());
+        static::assertNull($before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20100310_101010'), $after);
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestWithSticky(): void
+    {
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertSame(41, $bookmark->getId());
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestEmptyDatastore(): void
+    {
+        unlink($this->conf->get('resource.datastore'));
+        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertNull($bookmark);
     }
 
     /**
index 574d8e3f270e5a4f80638dfc78db4779860eb39d..835674f2d6df2900efeba0c4d8a1022fae2a95b9 100644 (file)
@@ -44,7 +44,7 @@ class BookmarkFilterTest extends TestCase
         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);
     }
 
     /**
index 4c7ae4c07fb91f56ab4e9718e775425cd3b6872e..cb91b26ba775227792b7cad2ec661b07eda45780 100644 (file)
@@ -78,6 +78,23 @@ class BookmarkTest extends TestCase
         $this->assertTrue($bookmark->isNote());
     }
 
+    /**
+     * Test fromArray() with a link with a custom tags separator
+     */
+    public function testFromArrayCustomTagsSeparator()
+    {
+        $data = [
+            'id' => 1,
+            'tags' => ['tag1', 'tag2', 'chair'],
+        ];
+
+        $bookmark = (new Bookmark())->fromArray($data, '@');
+        $this->assertEquals($data['id'], $bookmark->getId());
+        $this->assertEquals($data['tags'], $bookmark->getTags());
+        $this->assertEquals('tag1@tag2@chair', $bookmark->getTagsString('@'));
+    }
+
+
     /**
      * Test validate() with a valid minimal bookmark
      */
@@ -252,7 +269,7 @@ class BookmarkTest extends TestCase
     {
         $bookmark = new Bookmark();
 
-        $str = 'tag1    tag2 tag3.tag3-2, tag4   ,  -tag5   ';
+        $str = 'tag1    tag2 tag3.tag3-2 tag4     -tag5   ';
         $bookmark->setTagsString($str);
         $this->assertEquals(
             [
@@ -276,9 +293,9 @@ class BookmarkTest extends TestCase
         $array = [
             'tag1    ',
             '     tag2',
-            'tag3.tag3-2,',
-            ',  tag4',
-            ',  ',
+            'tag3.tag3-2',
+            '  tag4',
+            '  ',
             '-tag5   ',
         ];
         $bookmark->setTags($array);
@@ -347,4 +364,48 @@ class BookmarkTest extends TestCase
         $bookmark->deleteTag('nope');
         $this->assertEquals(['tag1', 'tag2', 'chair'], $bookmark->getTags());
     }
+
+    /**
+     * Test shouldUpdateThumbnail() with bookmarks needing an update.
+     */
+    public function testShouldUpdateThumbnail(): void
+    {
+        $bookmark = (new Bookmark())->setUrl('http://domain.tld/with-image');
+
+        static::assertTrue($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('http://domain.tld/with-image')
+            ->setThumbnail('unknown file')
+        ;
+
+        static::assertTrue($bookmark->shouldUpdateThumbnail());
+    }
+
+    /**
+     * Test shouldUpdateThumbnail() with bookmarks that should not update.
+     */
+    public function testShouldNotUpdateThumbnail(): void
+    {
+        $bookmark = (new Bookmark());
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('ftp://domain.tld/other-protocol', ['ftp'])
+        ;
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('http://domain.tld/with-image')
+            ->setThumbnail(__FILE__)
+        ;
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())->setUrl('/shaare/abcdef');
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+    }
 }
index 29941c8cd0ed32307faa0eea5bde9b99b3d77967..46a7f1fe7a4fd42b749bee30bd455d41ccdc107a 100644 (file)
@@ -168,6 +168,36 @@ class LinkUtilsTest extends TestCase
         $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.
      */
@@ -215,61 +245,104 @@ class LinkUtilsTest extends TestCase
         $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',
@@ -277,10 +350,11 @@ class LinkUtilsTest extends TestCase
             . '<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);
@@ -290,18 +364,19 @@ class LinkUtilsTest extends TestCase
     /**
      * Test the download callback with valid values and no charset
      */
-    public function testCurlDownloadCallbackOkHtmlCharset()
+    public function testCurlDownloadCallbackOkHtmlCharset(): void
     {
+        $charset = null;
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
             false,
-            'ut_curl_getinfo_no_charset'
+            ' '
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
             '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />',
             'end' => 'th=device-width">'
                 . '<title>Refactoring · GitHub</title>'
@@ -310,14 +385,10 @@ class LinkUtilsTest extends TestCase
             . '<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);
@@ -327,25 +398,27 @@ class LinkUtilsTest extends TestCase
     /**
      * Test the download callback with valid values and no title
      */
-    public function testCurlDownloadCallbackOkNoTitle()
+    public function testCurlDownloadCallbackOkNoTitle(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
             false,
-            'ut_curl_getinfo_ok'
+            ' '
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
             'end' => 'th=device-width">Refactoring · GitHub<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);
@@ -353,81 +426,56 @@ class LinkUtilsTest extends TestCase
     }
 
     /**
-     * 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',
@@ -435,14 +483,11 @@ class LinkUtilsTest extends TestCase
             . '<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);
@@ -453,8 +498,9 @@ class LinkUtilsTest extends TestCase
      * Test the download callback with valid value, and retrieve_description option enabled,
      * but no desc or keyword defined in the page.
      */
-    public function testCurlDownloadCallbackOkWithDescNotFound()
+    public function testCurlDownloadCallbackOkWithDescNotFound(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
@@ -464,24 +510,16 @@ class LinkUtilsTest extends TestCase
             'ut_curl_getinfo_ok'
         );
         $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
             'th=device-width">'
                 . '<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);
@@ -581,6 +619,115 @@ class LinkUtilsTest extends TestCase
         $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.
      *
index 5d52daefd5f7cf0e8d62559ee19cd8853c9e8e6a..04d4ef014878c75d18da09e841b20451d410a508 100644 (file)
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Shaarli\Container;
 
+use Psr\Log\LoggerInterface;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Feed\FeedBuilder;
@@ -12,6 +13,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
 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;
@@ -41,11 +43,15 @@ class ContainerBuilderTest extends TestCase
     /** @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);
@@ -54,7 +60,9 @@ class ContainerBuilderTest extends TestCase
             $this->conf,
             $this->sessionManager,
             $this->cookieManager,
-            $this->loginManager
+            $this->loginManager,
+            $this->pluginManager,
+            $this->createMock(LoggerInterface::class)
         );
     }
 
@@ -72,6 +80,8 @@ class ContainerBuilderTest extends TestCase
         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);
index 904db9dc2251a4f23da5ecbf57df59c1b85e5d72..1decfaf3b5c70fd00e31dd1200bcae62f5b892e2 100644 (file)
@@ -40,10 +40,10 @@ class CachedPageTest extends \Shaarli\TestCase
      */
     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);
     }
 
@@ -52,7 +52,7 @@ class CachedPageTest extends \Shaarli\TestCase
      */
     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>');
@@ -68,7 +68,7 @@ class CachedPageTest extends \Shaarli\TestCase
      */
     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>');
@@ -80,7 +80,7 @@ class CachedPageTest extends \Shaarli\TestCase
      */
     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>');
@@ -96,7 +96,7 @@ class CachedPageTest extends \Shaarli\TestCase
      */
     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(
@@ -110,7 +110,7 @@ class CachedPageTest extends \Shaarli\TestCase
      */
     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(
@@ -118,4 +118,43 @@ class CachedPageTest extends \Shaarli\TestCase
             $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());
+    }
 }
index 3fc6f8dc58f4c8f38e877f1c49c18d2d083cbcde..4fcc5dd19cc35356498f6acc7270b80ad4aef9bc 100644 (file)
@@ -289,4 +289,24 @@ class BookmarkDefaultFormatterTest extends TestCase
             $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 &nbsp;#hashtag',
+            $link['description']
+        );
+    }
 }
index d82db0a7c3e4692b4fb9bd9be81bdde4def86a11..13644df9963b4655dd1b3cd47e652c65cf85ec18 100644 (file)
@@ -62,7 +62,7 @@ class ConfigureControllerTest extends TestCase
         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']);
     }
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php b/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php
deleted file mode 100644 (file)
index 0f27ec2..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<?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']);
-    }
-}
index 8a0ff7a96ead9956bd9429a746a2be64d47b2fa7..af6f273f899db98758c420efcd2d9a9922e83373 100644 (file)
@@ -6,6 +6,7 @@ namespace Shaarli\Front\Controller\Admin;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Config\ConfigManager;
 use Shaarli\Front\Exception\WrongTokenException;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -44,9 +45,32 @@ class ManageTagControllerTest extends TestCase
         static::assertSame('changetag', (string) $result->getBody());
 
         static::assertSame('fromtag', $assignedVariables['fromtag']);
+        static::assertSame('@', $assignedVariables['tags_separator']);
         static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']);
     }
 
+    /**
+     * Test displaying manage tag page
+     */
+    public function testIndexWhitespaceSeparator(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key) {
+            return $key === 'general.tags_separator' ? ' ' : $key;
+        });
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->controller->index($request, $response);
+
+        static::assertSame('&nbsp;', $assignedVariables['tags_separator']);
+        static::assertSame('whitespace', $assignedVariables['tags_separator_desc']);
+    }
+
     /**
      * Test posting a tag update - rename tag - valid info provided.
      */
@@ -269,4 +293,116 @@ class ManageTagControllerTest extends TestCase
         static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
         static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
     }
+
+    /**
+     * Test changeSeparator to '#': redirection + success message.
+     */
+    public function testChangeSeparatorValid(): void
+    {
+        $toSeparator = '#';
+
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
+                return $key === 'separator' ? $toSeparator : $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf
+            ->expects(static::once())
+            ->method('set')
+            ->with('general.tags_separator', $toSeparator, true, true)
+        ;
+
+        $result = $this->controller->changeSeparator($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(
+            ['Your tags separator setting has been updated!'],
+            $session[SessionManager::KEY_SUCCESS_MESSAGES]
+        );
+    }
+
+    /**
+     * Test changeSeparator to '#@' (too long): redirection + error message.
+     */
+    public function testChangeSeparatorInvalidTooLong(): void
+    {
+        $toSeparator = '#@';
+
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
+                return $key === 'separator' ? $toSeparator : $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf->expects(static::never())->method('set');
+
+        $result = $this->controller->changeSeparator($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertSame(
+            ['Tags separator must be a single character.'],
+            $session[SessionManager::KEY_ERROR_MESSAGES]
+        );
+    }
+
+    /**
+     * Test changeSeparator to '#@' (too long): redirection + error message.
+     */
+    public function testChangeSeparatorInvalidReservedCharacter(): void
+    {
+        $toSeparator = '*';
+
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
+                return $key === 'separator' ? $toSeparator : $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf->expects(static::never())->method('set');
+
+        $result = $this->controller->changeSeparator($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertStringStartsWith(
+            'These characters are reserved and can\'t be used as tags separator',
+            $session[SessionManager::KEY_ERROR_MESSAGES][0]
+        );
+    }
 }
diff --git a/tests/front/controller/admin/ServerControllerTest.php b/tests/front/controller/admin/ServerControllerTest.php
new file mode 100644 (file)
index 0000000..355cce7
--- /dev/null
@@ -0,0 +1,184 @@
+<?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');
+    }
+}
diff --git a/tests/front/controller/admin/ShaareAddControllerTest.php b/tests/front/controller/admin/ShaareAddControllerTest.php
new file mode 100644 (file)
index 0000000..a27ebe6
--- /dev/null
@@ -0,0 +1,97 @@
+<?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']);
+    }
+}
similarity index 98%
rename from tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php
rename to tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php
index 096d077435886686484b627a035ef769ea5d2d65..28b1c023192c780a5a8cc0d620c60e90d664a85a 100644 (file)
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
@@ -10,7 +10,7 @@ use Shaarli\Formatter\BookmarkFormatter;
 use Shaarli\Formatter\BookmarkRawFormatter;
 use Shaarli\Formatter\FormatterFactory;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -21,7 +21,7 @@ class ChangeVisibilityBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaareManageController */
     protected $controller;
 
     public function setUp(): void
@@ -29,7 +29,7 @@ class ChangeVisibilityBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaareManageController($this->container);
     }
 
     /**
similarity index 96%
rename from tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php
rename to tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php
index 83bbee7c39909c8867028d3908e5e44df0ac92a3..a276d988f0d1993c7278cbbf30debdab02055e17 100644 (file)
@@ -2,14 +2,14 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Formatter\BookmarkFormatter;
 use Shaarli\Formatter\FormatterFactory;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -20,7 +20,7 @@ class DeleteBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaareManageController */
     protected $controller;
 
     public function setUp(): void
@@ -28,7 +28,7 @@ class DeleteBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaareManageController($this->container);
     }
 
     /**
@@ -38,6 +38,8 @@ class DeleteBookmarkTest extends TestCase
     {
         $parameters = ['id' => '123'];
 
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/shaare/abcdef';
+
         $request = $this->createMock(Request::class);
         $request
             ->method('getParam')
@@ -90,6 +92,8 @@ class DeleteBookmarkTest extends TestCase
     {
         $parameters = ['id' => '123 456 789'];
 
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/?searchtags=abcdef';
+
         $request = $this->createMock(Request::class);
         $request
             ->method('getParam')
@@ -152,7 +156,7 @@ class DeleteBookmarkTest extends TestCase
         $result = $this->controller->deleteBookmark($request, $response);
 
         static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+        static::assertSame(['/subfolder/?searchtags=abcdef'], $result->getHeader('location'));
     }
 
     /**
similarity index 95%
rename from tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php
rename to tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php
index 50ce7df14fabe73c7005125d9970ccb43091284e..b89206ce19e077f6ec5e0bdcd0986df1b3a6aecf 100644 (file)
@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -18,7 +18,7 @@ class PinBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaareManageController */
     protected $controller;
 
     public function setUp(): void
@@ -26,7 +26,7 @@ class PinBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaareManageController($this->container);
     }
 
     /**
diff --git a/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php b/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php
new file mode 100644 (file)
index 0000000..ae61dfb
--- /dev/null
@@ -0,0 +1,139 @@
+<?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'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php
new file mode 100644 (file)
index 0000000..ce8e112
--- /dev/null
@@ -0,0 +1,63 @@
+<?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']);
+    }
+}
similarity index 71%
rename from tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
rename to tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
index 2eb952514b8d1b0cd622bb7c3cd96253081fe812..964773da1e15e9f63b9a0f12dbe1387348cec732 100644 (file)
@@ -2,13 +2,14 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
 use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
 use Shaarli\TestCase;
 use Slim\Http\Request;
 use Slim\Http\Response;
@@ -17,7 +18,7 @@ class DisplayCreateFormTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaarePublishController */
     protected $controller;
 
     public function setUp(): void
@@ -25,14 +26,15 @@ class DisplayCreateFormTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
+        $this->controller = new ShaarePublishController($this->container);
     }
 
     /**
      * Test displaying bookmark create form
      * Ensure that every step of the standard workflow works properly.
      */
-    public function testDisplayCreateFormWithUrl(): void
+    public function testDisplayCreateFormWithUrlAndWithMetadataRetrieval(): void
     {
         $this->container->environment = [
             'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
@@ -53,40 +55,20 @@ class DisplayCreateFormTest extends TestCase
         });
         $response = new Response();
 
-        $this->container->httpAccess
-            ->expects(static::once())
-            ->method('getCurlDownloadCallback')
-            ->willReturnCallback(
-                function (&$charset, &$title, &$description, &$tags) use (
-                    $remoteTitle,
-                    $remoteDesc,
-                    $remoteTags
-                ): callable {
-                    return function () use (
-                        &$charset,
-                        &$title,
-                        &$description,
-                        &$tags,
-                        $remoteTitle,
-                        $remoteDesc,
-                        $remoteTags
-                    ): void {
-                        $charset = 'ISO-8859-1';
-                        $title = $remoteTitle;
-                        $description = $remoteDesc;
-                        $tags = $remoteTags;
-                    };
-                }
-            )
-        ;
-        $this->container->httpAccess
-            ->expects(static::once())
-            ->method('getHttpResponse')
-            ->with($expectedUrl, 30, 4194304)
-            ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
-                $callback();
-            })
-        ;
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $param, $default) {
+            if ($param === 'general.enable_async_metadata') {
+                return false;
+            }
+
+            return $default;
+        });
+
+        $this->container->metadataRetriever->expects(static::once())->method('retrieve')->willReturn([
+            'title' => $remoteTitle,
+            'description' => $remoteDesc,
+            'tags' => $remoteTags,
+        ]);
 
         $this->container->bookmarkService
             ->expects(static::once())
@@ -119,7 +101,73 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($expectedUrl, $assignedVariables['link']['url']);
         static::assertSame($remoteTitle, $assignedVariables['link']['title']);
         static::assertSame($remoteDesc, $assignedVariables['link']['description']);
-        static::assertSame($remoteTags, $assignedVariables['link']['tags']);
+        static::assertSame($remoteTags . ' ', $assignedVariables['link']['tags']);
+        static::assertFalse($assignedVariables['link']['private']);
+
+        static::assertTrue($assignedVariables['link_is_new']);
+        static::assertSame($referer, $assignedVariables['http_referer']);
+        static::assertSame($tags, $assignedVariables['tags']);
+        static::assertArrayHasKey('source', $assignedVariables);
+        static::assertArrayHasKey('default_private_links', $assignedVariables);
+        static::assertArrayHasKey('async_metadata', $assignedVariables);
+        static::assertArrayHasKey('retrieve_description', $assignedVariables);
+    }
+
+    /**
+     * Test displaying bookmark create form without any external metadata retrieval attempt
+     */
+    public function testDisplayCreateFormWithUrlAndWithoutMetadata(): void
+    {
+        $this->container->environment = [
+            'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
+        ];
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
+        $expectedUrl = str_replace('&utm_ad=pay', '', $url);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
+            return $key === 'post' ? $url : null;
+        });
+        $response = new Response();
+
+        $this->container->metadataRetriever->expects(static::never())->method('retrieve');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_editlink'], ['render_includes'])
+            ->willReturnCallback(function (string $hook, array $data): array {
+                if ('render_editlink' === $hook) {
+                    static::assertSame('', $data['link']['title']);
+                    static::assertSame('', $data['link']['description']);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame('', $assignedVariables['link']['title']);
+        static::assertSame('', $assignedVariables['link']['description']);
+        static::assertSame('', $assignedVariables['link']['tags']);
         static::assertFalse($assignedVariables['link']['private']);
 
         static::assertTrue($assignedVariables['link_is_new']);
@@ -127,6 +175,8 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($tags, $assignedVariables['tags']);
         static::assertArrayHasKey('source', $assignedVariables);
         static::assertArrayHasKey('default_private_links', $assignedVariables);
+        static::assertArrayHasKey('async_metadata', $assignedVariables);
+        static::assertArrayHasKey('retrieve_description', $assignedVariables);
     }
 
     /**
@@ -142,7 +192,7 @@ class DisplayCreateFormTest extends TestCase
             'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash',
             'title' => 'Provided Title',
             'description' => 'Provided description.',
-            'tags' => 'abc def',
+            'tags' => 'abc@def',
             'private' => '1',
             'source' => 'apps',
         ];
@@ -166,7 +216,7 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($expectedUrl, $assignedVariables['link']['url']);
         static::assertSame($parameters['title'], $assignedVariables['link']['title']);
         static::assertSame($parameters['description'], $assignedVariables['link']['description']);
-        static::assertSame($parameters['tags'], $assignedVariables['link']['tags']);
+        static::assertSame($parameters['tags'] . '@', $assignedVariables['link']['tags']);
         static::assertTrue($assignedVariables['link']['private']);
         static::assertTrue($assignedVariables['link_is_new']);
         static::assertSame($parameters['source'], $assignedVariables['source']);
@@ -310,7 +360,7 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($expectedUrl, $assignedVariables['link']['url']);
         static::assertSame($title, $assignedVariables['link']['title']);
         static::assertSame($description, $assignedVariables['link']['description']);
-        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
         static::assertTrue($assignedVariables['link']['private']);
         static::assertSame($createdAt, $assignedVariables['link']['created']);
     }
similarity index 93%
rename from tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php
rename to tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
index 2dc3f41c65b303bbb658ee6cf57bfea94ed4f607..738cea1230a9841b9715fefb3c4cb25165a2e244 100644 (file)
@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -18,7 +18,7 @@ class DisplayEditFormTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaarePublishController */
     protected $controller;
 
     public function setUp(): void
@@ -26,7 +26,7 @@ class DisplayEditFormTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaarePublishController($this->container);
     }
 
     /**
@@ -74,7 +74,7 @@ class DisplayEditFormTest extends TestCase
         static::assertSame($url, $assignedVariables['link']['url']);
         static::assertSame($title, $assignedVariables['link']['title']);
         static::assertSame($description, $assignedVariables['link']['description']);
-        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
         static::assertTrue($assignedVariables['link']['private']);
         static::assertSame($createdAt, $assignedVariables['link']['created']);
     }
similarity index 83%
rename from tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php
rename to tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php
index 37542c260eeef5bc67771d8fdf0e22e5f86575bb..b6a861bc448c81b7ebcaa60ad0df3fdcd6495b00 100644 (file)
@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
 use Shaarli\Front\Exception\WrongTokenException;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
@@ -20,7 +20,7 @@ class SaveBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaarePublishController */
     protected $controller;
 
     public function setUp(): void
@@ -28,7 +28,7 @@ class SaveBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaarePublishController($this->container);
     }
 
     /**
@@ -209,7 +209,7 @@ class SaveBookmarkTest extends TestCase
     /**
      * Test save a bookmark - try to retrieve the thumbnail
      */
-    public function testSaveBookmarkWithThumbnail(): void
+    public function testSaveBookmarkWithThumbnailSync(): void
     {
         $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
 
@@ -224,7 +224,13 @@ class SaveBookmarkTest extends TestCase
 
         $this->container->conf = $this->createMock(ConfigManager::class);
         $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
-            return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
+            if ($key === 'thumbnails.mode') {
+                return Thumbnailer::MODE_ALL;
+            } elseif ($key === 'general.enable_async_metadata') {
+                return false;
+            }
+
+            return $default;
         });
 
         $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
@@ -274,6 +280,51 @@ class SaveBookmarkTest extends TestCase
         static::assertSame(302, $result->getStatusCode());
     }
 
+    /**
+     * Test save a bookmark - do not attempt to retrieve thumbnails if async mode is enabled.
+     */
+    public function testSaveBookmarkWithThumbnailAsync(): void
+    {
+        $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'thumbnails.mode') {
+                return Thumbnailer::MODE_ALL;
+            } elseif ($key === 'general.enable_async_metadata') {
+                return true;
+            }
+
+            return $default;
+        });
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer->expects(static::never())->method('get');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark): Bookmark {
+                static::assertNull($bookmark->getThumbnail());
+
+                return $bookmark;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+    }
+
     /**
      * Change the password with a wrong existing password
      */
index 0c95df97554a0a80129ea0ef64a8b17bcbbdfe41..dec938f209516d65ff479021c2d6177c295a3d71 100644 (file)
@@ -173,7 +173,7 @@ class BookmarkListControllerTest extends TestCase
         $request = $this->createMock(Request::class);
         $request->method('getParam')->willReturnCallback(function (string $key) {
             if ('searchtags' === $key) {
-                return 'abc def';
+                return 'abc@def';
             }
             if ('searchterm' === $key) {
                 return 'ghi jkl';
@@ -204,7 +204,7 @@ class BookmarkListControllerTest extends TestCase
             ->expects(static::once())
             ->method('search')
             ->with(
-                ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'],
+                ['searchtags' => 'abc@def', 'searchterm' => 'ghi jkl'],
                 'private',
                 false,
                 true
@@ -222,7 +222,7 @@ class BookmarkListControllerTest extends TestCase
         static::assertSame('linklist', (string) $result->getBody());
 
         static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']);
-        static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc+def', $assignedVariables['previous_page_url']);
+        static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc%40def', $assignedVariables['previous_page_url']);
     }
 
     /**
@@ -291,6 +291,37 @@ class BookmarkListControllerTest extends TestCase
         );
     }
 
+    /**
+     * Test GET /shaare/{hash}?key={key} - Find a link by hash using a private link.
+     */
+    public function testPermalinkWithPrivateKey(): void
+    {
+        $hash = 'abcdef';
+        $privateKey = 'this is a private key';
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key, $default = null) use ($privateKey) {
+            return $key === 'key' ? $privateKey : $default;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash, $privateKey)
+            ->willReturn((new Bookmark())->setId(123)->setTitle('Title 1')->setUrl('http://url1.tld'))
+        ;
+
+        $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+        static::assertCount(1, $assignedVariables['links']);
+    }
+
     /**
      * Test getting link list with thumbnail updates.
      *   -> 2 thumbnails update, only 1 datastore write
@@ -307,7 +338,13 @@ class BookmarkListControllerTest extends TestCase
         $this->container->conf
             ->method('get')
             ->willReturnCallback(function (string $key, $default) {
-                return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
+                if ($key === 'thumbnails.mode') {
+                    return Thumbnailer::MODE_ALL;
+                } elseif ($key === 'general.enable_async_metadata') {
+                    return false;
+                }
+
+                return $default;
             })
         ;
 
@@ -357,7 +394,13 @@ class BookmarkListControllerTest extends TestCase
         $this->container->conf
             ->method('get')
             ->willReturnCallback(function (string $key, $default) {
-                return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
+                if ($key === 'thumbnails.mode') {
+                    return Thumbnailer::MODE_ALL;
+                } elseif ($key === 'general.enable_async_metadata') {
+                    return false;
+                }
+
+                return $default;
             })
         ;
 
@@ -378,6 +421,47 @@ class BookmarkListControllerTest extends TestCase
         static::assertSame('linklist', (string) $result->getBody());
     }
 
+    /**
+     * Test getting a permalink with thumbnail update with async setting: no update should run.
+     */
+    public function testThumbnailUpdateFromPermalinkAsync(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $default) {
+                if ($key === 'thumbnails.mode') {
+                    return Thumbnailer::MODE_ALL;
+                } elseif ($key === 'general.enable_async_metadata') {
+                    return true;
+                }
+
+                return $default;
+            })
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer->expects(static::never())->method('get');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->willReturn((new Bookmark())->setId(2)->setUrl('https://url.tld')->setTitle('Title 1'))
+        ;
+        $this->container->bookmarkService->expects(static::never())->method('set');
+        $this->container->bookmarkService->expects(static::never())->method('save');
+
+        $result = $this->controller->permalink($request, $response, ['hash' => 'abc']);
+
+        static::assertSame(200, $result->getStatusCode());
+    }
+
     /**
      * Trigger legacy controller in link list controller: permalink
      */
index fc78bc13dc5020411198d2f710ccda1dc79fa016..70fbce5482d75ff9beef4c423dfb3d3e52de094e 100644 (file)
@@ -28,52 +28,49 @@ class DailyControllerTest extends TestCase
     public function testValidIndexControllerInvokeDefault(): void
     {
         $currentDay = new \DateTimeImmutable('2020-05-13');
+        $previousDate = new \DateTime('2 days ago 00:00:00');
+        $nextDate = new \DateTime('today 00:00:00');
 
         $request = $this->createMock(Request::class);
-        $request->method('getQueryParam')->willReturn($currentDay->format('Ymd'));
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'day' ? $currentDay->format('Ymd') : null;
+        });
         $response = new Response();
 
         // Save RainTPL assigned variables
         $assignedVariables = [];
         $this->assignTemplateVars($assignedVariables);
 
-        // Links dataset: 2 links with thumbnails
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('days')
-            ->willReturnCallback(function () use ($currentDay): array {
-               return [
-                   '20200510',
-                   $currentDay->format('Ymd'),
-                   '20200516',
-               ];
-            })
-        ;
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('filterDay')
-            ->willReturnCallback(function (): array {
-                return [
-                    (new Bookmark())
-                        ->setId(1)
-                        ->setUrl('http://url.tld')
-                        ->setTitle(static::generateString(50))
-                        ->setDescription(static::generateString(500))
-                    ,
-                    (new Bookmark())
-                        ->setId(2)
-                        ->setUrl('http://url2.tld')
-                        ->setTitle(static::generateString(50))
-                        ->setDescription(static::generateString(500))
-                    ,
-                    (new Bookmark())
-                        ->setId(3)
-                        ->setUrl('http://url3.tld')
-                        ->setTitle(static::generateString(50))
-                        ->setDescription(static::generateString(500))
-                    ,
-                ];
-            })
+            ->method('findByDate')
+            ->willReturnCallback(
+                function ($from, $to, &$previous, &$next) use ($currentDay, $previousDate, $nextDate): array {
+                    $previous = $previousDate;
+                    $next = $nextDate;
+
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(3)
+                            ->setUrl('http://url3.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
         ;
 
         // Make sure that PluginManager hook is triggered
@@ -81,20 +78,22 @@ class DailyControllerTest extends TestCase
             ->expects(static::atLeastOnce())
             ->method('executeHooks')
             ->withConsecutive(['render_daily'])
-            ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array {
-                if ('render_daily' === $hook) {
-                    static::assertArrayHasKey('linksToDisplay', $data);
-                    static::assertCount(3, $data['linksToDisplay']);
-                    static::assertSame(1, $data['linksToDisplay'][0]['id']);
-                    static::assertSame($currentDay->getTimestamp(), $data['day']);
-                    static::assertSame('20200510', $data['previousday']);
-                    static::assertSame('20200516', $data['nextday']);
-
-                    static::assertArrayHasKey('loggedin', $param);
+            ->willReturnCallback(
+                function (string $hook, array $data, array $param) use ($currentDay, $previousDate, $nextDate): array {
+                    if ('render_daily' === $hook) {
+                        static::assertArrayHasKey('linksToDisplay', $data);
+                        static::assertCount(3, $data['linksToDisplay']);
+                        static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                        static::assertSame($currentDay->getTimestamp(), $data['day']);
+                        static::assertSame($previousDate->format('Ymd'), $data['previousday']);
+                        static::assertSame($nextDate->format('Ymd'), $data['nextday']);
+
+                        static::assertArrayHasKey('loggedin', $param);
+                    }
+
+                    return $data;
                 }
-
-                return $data;
-            })
+            )
         ;
 
         $result = $this->controller->index($request, $response);
@@ -107,6 +106,11 @@ class DailyControllerTest extends TestCase
         );
         static::assertEquals($currentDay, $assignedVariables['dayDate']);
         static::assertEquals($currentDay->getTimestamp(), $assignedVariables['day']);
+        static::assertSame($previousDate->format('Ymd'), $assignedVariables['previousday']);
+        static::assertSame($nextDate->format('Ymd'), $assignedVariables['nextday']);
+        static::assertSame('day', $assignedVariables['type']);
+        static::assertSame('May 13, 2020', $assignedVariables['dayDesc']);
+        static::assertSame('Daily', $assignedVariables['localizedType']);
         static::assertCount(3, $assignedVariables['linksToDisplay']);
 
         $link = $assignedVariables['linksToDisplay'][0];
@@ -171,26 +175,19 @@ class DailyControllerTest extends TestCase
         $currentDay = new \DateTimeImmutable('2020-05-13');
 
         $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'day' ? $currentDay->format('Ymd') : null;
+        });
         $response = new Response();
 
         // Save RainTPL assigned variables
         $assignedVariables = [];
         $this->assignTemplateVars($assignedVariables);
 
-        // Links dataset: 2 links with thumbnails
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('days')
+            ->method('findByDate')
             ->willReturnCallback(function () use ($currentDay): array {
-                return [
-                    $currentDay->format($currentDay->format('Ymd')),
-                ];
-            })
-        ;
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('filterDay')
-            ->willReturnCallback(function (): array {
                 return [
                     (new Bookmark())
                         ->setId(1)
@@ -250,20 +247,10 @@ class DailyControllerTest extends TestCase
         $assignedVariables = [];
         $this->assignTemplateVars($assignedVariables);
 
-        // Links dataset: 2 links with thumbnails
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('days')
+            ->method('findByDate')
             ->willReturnCallback(function () use ($currentDay): array {
-                return [
-                    $currentDay->format($currentDay->format('Ymd')),
-                ];
-            })
-        ;
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('filterDay')
-            ->willReturnCallback(function (): array {
                 return [
                     (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'),
                     (new Bookmark())
@@ -320,14 +307,7 @@ class DailyControllerTest extends TestCase
         // Links dataset: 2 links with thumbnails
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('days')
-            ->willReturnCallback(function (): array {
-                return [];
-            })
-        ;
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('filterDay')
+            ->method('findByDate')
             ->willReturnCallback(function (): array {
                 return [];
             })
@@ -347,7 +327,7 @@ class DailyControllerTest extends TestCase
         static::assertSame(200, $result->getStatusCode());
         static::assertSame('daily', (string) $result->getBody());
         static::assertCount(0, $assignedVariables['linksToDisplay']);
-        static::assertSame('Today', $assignedVariables['dayDesc']);
+        static::assertSame('Today - ' . (new \DateTime())->format('F j, Y'), $assignedVariables['dayDesc']);
         static::assertEquals((new \DateTime())->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
         static::assertEquals((new \DateTime())->setTime(0, 0), $assignedVariables['dayDate']);
     }
@@ -361,6 +341,7 @@ class DailyControllerTest extends TestCase
             new \DateTimeImmutable('2020-05-17'),
             new \DateTimeImmutable('2020-05-15'),
             new \DateTimeImmutable('2020-05-13'),
+            new \DateTimeImmutable('+1 month'),
         ];
 
         $request = $this->createMock(Request::class);
@@ -371,6 +352,7 @@ class DailyControllerTest extends TestCase
             (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
             (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
             (new Bookmark())->setId(4)->setCreated($dates[2])->setUrl('http://domain.tld/4'),
+            (new Bookmark())->setId(5)->setCreated($dates[3])->setUrl('http://domain.tld/5'),
         ]);
 
         $this->container->pageCacheManager
@@ -397,13 +379,14 @@ class DailyControllerTest extends TestCase
         static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
         static::assertSame('http://shaarli/subfolder/daily-rss', $assignedVariables['page_url']);
         static::assertFalse($assignedVariables['hide_timestamps']);
-        static::assertCount(2, $assignedVariables['days']);
+        static::assertCount(3, $assignedVariables['days']);
 
         $day = $assignedVariables['days'][$dates[0]->format('Ymd')];
+        $date = $dates[0]->setTime(23, 59, 59);
 
-        static::assertEquals($dates[0], $day['date']);
-        static::assertSame($dates[0]->format(\DateTime::RSS), $day['date_rss']);
-        static::assertSame(format_date($dates[0], false), $day['date_human']);
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
         static::assertSame('http://shaarli/subfolder/daily?day='. $dates[0]->format('Ymd'), $day['absolute_url']);
         static::assertCount(1, $day['links']);
         static::assertSame(1, $day['links'][0]['id']);
@@ -411,10 +394,11 @@ class DailyControllerTest extends TestCase
         static::assertEquals($dates[0], $day['links'][0]['created']);
 
         $day = $assignedVariables['days'][$dates[1]->format('Ymd')];
+        $date = $dates[1]->setTime(23, 59, 59);
 
-        static::assertEquals($dates[1], $day['date']);
-        static::assertSame($dates[1]->format(\DateTime::RSS), $day['date_rss']);
-        static::assertSame(format_date($dates[1], false), $day['date_human']);
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
         static::assertSame('http://shaarli/subfolder/daily?day='. $dates[1]->format('Ymd'), $day['absolute_url']);
         static::assertCount(2, $day['links']);
 
@@ -424,6 +408,18 @@ class DailyControllerTest extends TestCase
         static::assertSame(3, $day['links'][1]['id']);
         static::assertSame('http://domain.tld/3', $day['links'][1]['url']);
         static::assertEquals($dates[1], $day['links'][1]['created']);
+
+        $day = $assignedVariables['days'][$dates[2]->format('Ymd')];
+        $date = $dates[2]->setTime(23, 59, 59);
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?day='. $dates[2]->format('Ymd'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+        static::assertSame(4, $day['links'][0]['id']);
+        static::assertSame('http://domain.tld/4', $day['links'][0]['url']);
+        static::assertEquals($dates[2], $day['links'][0]['created']);
     }
 
     /**
@@ -475,4 +471,246 @@ class DailyControllerTest extends TestCase
         static::assertFalse($assignedVariables['hide_timestamps']);
         static::assertCount(0, $assignedVariables['days']);
     }
+
+    /**
+     * Test simple display index with week parameter
+     */
+    public function testSimpleIndexWeekly(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+        $expectedDay = new \DateTimeImmutable('2020-05-11');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'week' ? $currentDay->format('YW') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(
+                function (): array {
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Weekly - Week 20 (May 11, 2020) - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+        static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
+        static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertSame('', $assignedVariables['previousday']);
+        static::assertSame('', $assignedVariables['nextday']);
+        static::assertSame('Week 20 (May 11, 2020)', $assignedVariables['dayDesc']);
+        static::assertSame('week', $assignedVariables['type']);
+        static::assertSame('Weekly', $assignedVariables['localizedType']);
+    }
+
+    /**
+     * Test simple display index with month parameter
+     */
+    public function testSimpleIndexMonthly(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+        $expectedDay = new \DateTimeImmutable('2020-05-01');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'month' ? $currentDay->format('Ym') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(
+                function (): array {
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Monthly - May, 2020 - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+        static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
+        static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertSame('', $assignedVariables['previousday']);
+        static::assertSame('', $assignedVariables['nextday']);
+        static::assertSame('May, 2020', $assignedVariables['dayDesc']);
+        static::assertSame('month', $assignedVariables['type']);
+        static::assertSame('Monthly', $assignedVariables['localizedType']);
+    }
+
+    /**
+     * Test simple display RSS with week parameter
+     */
+    public function testSimpleRssWeekly(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-19'),
+            new \DateTimeImmutable('2020-05-13'),
+        ];
+        $expectedDates = [
+            new \DateTimeImmutable('2020-05-24 23:59:59'),
+            new \DateTimeImmutable('2020-05-17 23:59:59'),
+        ];
+
+        $this->container->environment['QUERY_STRING'] = 'week';
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
+            return $key === 'week' ? '' : null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+        ]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss?week', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(2, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('YW')];
+        $date = $expectedDates[0];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('Week 21 (May 18, 2020)', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?week='. $dates[0]->format('YW'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('YW')];
+        $date = $expectedDates[1];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('Week 20 (May 11, 2020)', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?week='. $dates[1]->format('YW'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+    }
+
+    /**
+     * Test simple display RSS with month parameter
+     */
+    public function testSimpleRssMonthly(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-19'),
+            new \DateTimeImmutable('2020-04-13'),
+        ];
+        $expectedDates = [
+            new \DateTimeImmutable('2020-05-31 23:59:59'),
+            new \DateTimeImmutable('2020-04-30 23:59:59'),
+        ];
+
+        $this->container->environment['QUERY_STRING'] = 'month';
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
+            return $key === 'month' ? '' : null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+        ]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss?month', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(2, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('Ym')];
+        $date = $expectedDates[0];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('May, 2020', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?month='. $dates[0]->format('Ym'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('Ym')];
+        $date = $expectedDates[1];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('April, 2020', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?month='. $dates[1]->format('Ym'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+    }
 }
index 75408cf4040e21297f049729213a63b21f915a3c..e18a6fa2faa278c99eb0071766cabd272ecf6036 100644 (file)
@@ -50,7 +50,31 @@ class ErrorControllerTest extends TestCase
     }
 
     /**
-     * Test displaying error with any exception (no debug): only display an error occurred with HTTP 500.
+     * Test displaying error with any exception (no debug) while logged in:
+     * display full error details
+     */
+    public function testDisplayAnyExceptionErrorNoDebugLoggedIn(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $result = ($this->controller)($request, $response, new \Exception('abc'));
+
+        static::assertSame(500, $result->getStatusCode());
+        static::assertSame('Error: abc', $assignedVariables['message']);
+        static::assertContainsPolyfill('Please report it on Github', $assignedVariables['text']);
+        static::assertArrayHasKey('stacktrace', $assignedVariables);
+    }
+
+    /**
+     * Test displaying error with any exception (no debug) while logged out:
+     * display standard error without detail
      */
     public function testDisplayAnyExceptionErrorNoDebug(): void
     {
@@ -61,10 +85,13 @@ class ErrorControllerTest extends TestCase
         $assignedVariables = [];
         $this->assignTemplateVars($assignedVariables);
 
+        $this->container->loginManager->method('isLoggedIn')->willReturn(false);
+
         $result = ($this->controller)($request, $response, new \Exception('abc'));
 
         static::assertSame(500, $result->getStatusCode());
         static::assertSame('An unexpected error occurred.', $assignedVariables['message']);
+        static::assertArrayNotHasKey('text', $assignedVariables);
         static::assertArrayNotHasKey('stacktrace', $assignedVariables);
     }
 }
index fc0bb7d1a3cc123424674c40070601ec241bb0d6..02229f68026dca2a8934612de915d9d3020dded6 100644 (file)
@@ -41,6 +41,10 @@ trait FrontControllerMockHelper
         // Config
         $this->container->conf = $this->createMock(ConfigManager::class);
         $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'general.tags_separator') {
+                return '@';
+            }
+
             return $default === null ? $parameter : $default;
         });
 
index 345ad544b85a582ccfab88b9f1a9ad4e74375843..2105ed770cd48b908c4f366b9ad5cc7129ff2b39 100644 (file)
@@ -79,6 +79,15 @@ class InstallControllerTest extends TestCase
         static::assertIsArray($assignedVariables['languages']);
         static::assertSame('Automatic', $assignedVariables['languages']['auto']);
         static::assertSame('French', $assignedVariables['languages']['fr']);
+
+        static::assertSame(PHP_VERSION, $assignedVariables['php_version']);
+        static::assertArrayHasKey('php_has_reached_eol', $assignedVariables);
+        static::assertArrayHasKey('php_eol', $assignedVariables);
+        static::assertArrayHasKey('php_extensions', $assignedVariables);
+        static::assertArrayHasKey('permissions', $assignedVariables);
+        static::assertEmpty($assignedVariables['permissions']);
+
+        static::assertSame('Install Shaarli', $assignedVariables['pagetitle']);
     }
 
     /**
index 1312ccb79199c1620651e805ffdded4bd71a838a..00d9eab3bba7f81e4b7c323bd7e25bc99880a74d 100644 (file)
@@ -195,7 +195,7 @@ class LoginControllerTest extends TestCase
         $this->container->loginManager
             ->expects(static::once())
             ->method('checkCredentials')
-            ->with('1.2.3.4', '1.2.3.4', 'bob', 'pass')
+            ->with('1.2.3.4', 'bob', 'pass')
             ->willReturn(true)
         ;
         $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
index 9305612ed484dbbef775567d39c46a3b8fce7c5b..4915573d11cf40f5e35f437a9cda47f0e00bf681 100644 (file)
@@ -100,7 +100,7 @@ class TagCloudControllerTest extends TestCase
             ->with()
             ->willReturnCallback(function (string $key): ?string {
                 if ('searchtags' === $key) {
-                    return 'ghi def';
+                    return 'ghi@def';
                 }
 
                 return null;
@@ -131,7 +131,7 @@ class TagCloudControllerTest extends TestCase
             ->withConsecutive(['render_tagcloud'])
             ->willReturnCallback(function (string $hook, array $data, array $param): array {
                if ('render_tagcloud' === $hook) {
-                   static::assertSame('ghi def', $data['search_tags']);
+                   static::assertSame('ghi@def@', $data['search_tags']);
                    static::assertCount(1, $data['tags']);
 
                    static::assertArrayHasKey('loggedin', $param);
@@ -147,7 +147,7 @@ class TagCloudControllerTest extends TestCase
         static::assertSame('tag.cloud', (string) $result->getBody());
         static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']);
 
-        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertSame('ghi@def@', $assignedVariables['search_tags']);
         static::assertCount(1, $assignedVariables['tags']);
 
         static::assertArrayHasKey('abc', $assignedVariables['tags']);
@@ -277,7 +277,7 @@ class TagCloudControllerTest extends TestCase
             ->with()
             ->willReturnCallback(function (string $key): ?string {
                 if ('searchtags' === $key) {
-                    return 'ghi def';
+                    return 'ghi@def';
                 } elseif ('sort' === $key) {
                     return 'alpha';
                 }
@@ -310,7 +310,7 @@ class TagCloudControllerTest extends TestCase
             ->withConsecutive(['render_taglist'])
             ->willReturnCallback(function (string $hook, array $data, array $param): array {
                 if ('render_taglist' === $hook) {
-                    static::assertSame('ghi def', $data['search_tags']);
+                    static::assertSame('ghi@def@', $data['search_tags']);
                     static::assertCount(1, $data['tags']);
 
                     static::assertArrayHasKey('loggedin', $param);
@@ -326,7 +326,7 @@ class TagCloudControllerTest extends TestCase
         static::assertSame('tag.list', (string) $result->getBody());
         static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']);
 
-        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertSame('ghi@def@', $assignedVariables['search_tags']);
         static::assertCount(1, $assignedVariables['tags']);
         static::assertSame(3, $assignedVariables['tags']['abc']);
     }
index 750ea02d85c47aea9f5f566089c1b8a9aaf94543..5a556c6def37631a170b0b5d955aa819c2284ca7 100644 (file)
@@ -50,7 +50,7 @@ class TagControllerTest extends TestCase
 
         static::assertInstanceOf(Response::class, $result);
         static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+        static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
     }
 
     public function testAddTagWithoutRefererAndExistingSearch(): void
@@ -80,7 +80,7 @@ class TagControllerTest extends TestCase
 
         static::assertInstanceOf(Response::class, $result);
         static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+        static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
     }
 
     public function testAddTagResetPagination(): void
@@ -96,7 +96,7 @@ class TagControllerTest extends TestCase
 
         static::assertInstanceOf(Response::class, $result);
         static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+        static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
     }
 
     public function testAddTagWithRefererAndEmptySearch(): void
similarity index 81%
rename from tests/ApplicationUtilsTest.php
rename to tests/helper/ApplicationUtilsTest.php
index a232b351f4cfdae5c63c4d31553f055500a4111b..654857b944e7925cfd81b1cd915600495fea9f2a 100644 (file)
@@ -1,7 +1,8 @@
 <?php
-namespace Shaarli;
+namespace Shaarli\Helper;
 
 use Shaarli\Config\ConfigManager;
+use Shaarli\FakeApplicationUtils;
 
 require_once 'tests/utils/FakeApplicationUtils.php';
 
@@ -339,6 +340,35 @@ class ApplicationUtilsTest extends \Shaarli\TestCase
         );
     }
 
+    /**
+     * 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.
@@ -349,4 +379,37 @@ class ApplicationUtilsTest extends \Shaarli\TestCase
             ApplicationUtils::checkUpdate('dev', self::$testUpdateFile, 100, true, true)
         );
     }
+
+    /**
+     * Basic test of getPhpExtensionsRequirement()
+     */
+    public function testGetPhpExtensionsRequirementSimple(): void
+    {
+        static::assertCount(8, ApplicationUtils::getPhpExtensionsRequirement());
+        static::assertSame([
+            'name' => 'json',
+            'required' => true,
+            'desc' => 'Configuration parsing',
+            'loaded' => true,
+        ], ApplicationUtils::getPhpExtensionsRequirement()[0]);
+    }
+
+    /**
+     * Test getPhpEol with a known version: 7.4 -> 2022
+     */
+    public function testGetKnownPhpEol(): void
+    {
+        static::assertSame('2022-11-28', ApplicationUtils::getPhpEol('7.4.7'));
+    }
+
+    /**
+     * Test getPhpEol with an unknown version: 7.4 -> 2022
+     */
+    public function testGetUnknownPhpEol(): void
+    {
+        static::assertSame(
+            (((int) (new \DateTime())->format('Y')) + 2) . (new \DateTime())->format('-m-d'),
+            ApplicationUtils::getPhpEol('7.51.34')
+        );
+    }
 }
diff --git a/tests/helper/DailyPageHelperTest.php b/tests/helper/DailyPageHelperTest.php
new file mode 100644 (file)
index 0000000..2d74580
--- /dev/null
@@ -0,0 +1,341 @@
+<?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'),
+            ],
+        ];
+    }
+}
similarity index 53%
rename from tests/FileUtilsTest.php
rename to tests/helper/FileUtilsTest.php
index 9163bdf1face0b250ad801b13de5cd96385507a5..8035f79cff3f94ca320c23bba6a756864618a455 100644 (file)
@@ -1,27 +1,51 @@
 <?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');
     }
 
     /**
@@ -107,4 +131,67 @@ class FileUtilsTest extends \Shaarli\TestCase
         $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);
+    }
 }
diff --git a/tests/http/MetadataRetrieverTest.php b/tests/http/MetadataRetrieverTest.php
new file mode 100644 (file)
index 0000000..cae6509
--- /dev/null
@@ -0,0 +1,154 @@
+<?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);
+    }
+}
index f7391b867f593efd932aba8ae66f46ab6a1685fd..395dd4b70d37826a2434f8bfc3eb73bdad05d97d 100644 (file)
@@ -51,10 +51,10 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
      */
     public function testReadEmptyUpdatesFile()
     {
-        $this->assertEquals(array(), UpdaterUtils::read_updates_file(''));
+        $this->assertEquals(array(), UpdaterUtils::readUpdatesFile(''));
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         touch($updatesFile);
-        $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile));
+        $this->assertEquals(array(), UpdaterUtils::readUpdatesFile($updatesFile));
         unlink($updatesFile);
     }
 
@@ -66,14 +66,14 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         $updatesMethods = array('m1', 'm2', 'm3');
 
-        UpdaterUtils::write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = UpdaterUtils::read_updates_file($updatesFile);
+        UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
+        $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
         $this->assertEquals($readMethods, $updatesMethods);
 
         // Update
         $updatesMethods[] = 'm4';
-        UpdaterUtils::write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = UpdaterUtils::read_updates_file($updatesFile);
+        UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
+        $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
         $this->assertEquals($readMethods, $updatesMethods);
         unlink($updatesFile);
     }
@@ -86,7 +86,7 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
         $this->expectException(\Exception::class);
         $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
 
-        UpdaterUtils::write_updates_file('', array('test'));
+        UpdaterUtils::writeUpdatesFile('', array('test'));
     }
 
     /**
@@ -101,7 +101,7 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
         touch($updatesFile);
         chmod($updatesFile, 0444);
         try {
-            @UpdaterUtils::write_updates_file($updatesFile, array('test'));
+            @UpdaterUtils::writeUpdatesFile($updatesFile, array('test'));
         } catch (Exception $e) {
             unlink($updatesFile);
             throw $e;
index c526d5c8382699c27a57ffbc12622e153c42c4cf..6856ebcafebdecc18070638daa6430c6c71386da 100644 (file)
@@ -531,7 +531,7 @@ class BookmarkImportTest extends TestCase
     {
         $post = array(
             'privacy' => 'public',
-            'default_tags' => 'tag1,tag2 tag3'
+            'default_tags' => 'tag1 tag2 tag3'
         );
         $files = file2array('netscape_basic.htm');
         $this->assertStringMatchesFormat(
@@ -552,7 +552,7 @@ class BookmarkImportTest extends TestCase
     {
         $post = array(
             'privacy' => 'public',
-            'default_tags' => 'tag1&,tag2 "tag3"'
+            'default_tags' => 'tag1& tag2 "tag3"'
         );
         $files = file2array('netscape_basic.htm');
         $this->assertStringMatchesFormat(
@@ -572,6 +572,43 @@ class BookmarkImportTest extends TestCase
         );
     }
 
+    /**
+     * Add user-specified tags to all imported bookmarks
+     */
+    public function testSetDefaultTagsWithCustomSeparator()
+    {
+        $separator = '@';
+        $this->conf->set('general.tags_separator', $separator);
+        $post = [
+            'privacy' => 'public',
+            'default_tags' => 'tag1@tag2@tag3@multiple words tag'
+        ];
+        $files = file2array('netscape_basic.htm');
+        $this->assertStringMatchesFormat(
+            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
+            .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
+            $this->netscapeBookmarkUtils->import($post, $files)
+        );
+        $this->assertEquals(2, $this->bookmarkService->count());
+        $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
+        $this->assertEquals(
+            'tag1@tag2@tag3@multiple words tag@private@secret',
+            $this->bookmarkService->get(0)->getTagsString($separator)
+        );
+        $this->assertEquals(
+            ['tag1', 'tag2', 'tag3', 'multiple words tag', 'private', 'secret'],
+            $this->bookmarkService->get(0)->getTags()
+        );
+        $this->assertEquals(
+            'tag1@tag2@tag3@multiple words tag@public@hello@world',
+            $this->bookmarkService->get(1)->getTagsString($separator)
+        );
+        $this->assertEquals(
+            ['tag1', 'tag2', 'tag3', 'multiple words tag', 'public', 'hello', 'world'],
+            $this->bookmarkService->get(1)->getTags()
+        );
+    }
+
     /**
      * Ensure each imported bookmark has a unique id
      *
index cc844c60f41e34edc0b61665e97c6f1227d6b3ac..54e97612538858d08f47e7a145f824399ad23805 100644 (file)
@@ -193,4 +193,27 @@ class PluginDefaultColorsTest extends TestCase
         $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);
+    }
 }
index 36317215976e4a5c38fe8bf721525b903d764d8d..9a402fb75a177fd585c65778f9322d811f74adcf 100644 (file)
@@ -49,14 +49,15 @@ class PluginWallabagTest extends \Shaarli\TestCase
         $conf = new ConfigManager('');
         $conf->set('plugins.WALLABAG_URL', 'value');
         $str = 'http://randomstr.com/test';
-        $data = array(
+        $data = [
             'title' => $str,
-            'links' => array(
-                array(
+            'links' => [
+                [
                     'url' => $str,
-                )
-            )
-        );
+                ]
+            ],
+            '_LOGGEDIN_' => true,
+        ];
 
         $data = hook_wallabag_render_linklist($data, $conf);
         $link = $data['links'][0];
@@ -69,4 +70,26 @@ class PluginWallabagTest extends \Shaarli\TestCase
         $this->assertNotFalse(strpos($link['link_plugin'][0], urlencode($str)));
         $this->assertNotFalse(strpos($link['link_plugin'][0], $conf->get('plugins.WALLABAG_URL')));
     }
+
+    /**
+     * Test render_linklist hook while logged out: no change.
+     */
+    public function testWallabagLinklistLoggedOut(): void
+    {
+        $conf = new ConfigManager('');
+        $str = 'http://randomstr.com/test';
+        $data = [
+            'title' => $str,
+            'links' => [
+                [
+                    'url' => $str,
+                ]
+            ],
+            '_LOGGEDIN_' => false,
+        ];
+
+        $result = hook_wallabag_render_linklist($data, $conf);
+
+        static::assertSame($data, $result);
+    }
 }
index 03be4f4e8c997bd9eb875ad1f42a65bf4d294eb7..34cd339e1a8cb84409f689d3a48f67dbb5124744 100644 (file)
@@ -27,3 +27,19 @@ function hook_test_error()
 {
     new Unknown();
 }
+
+function test_register_routes(): array
+{
+    return [
+        [
+            'method' => 'GET',
+            'route' => '/test',
+            'callable' => 'getFunction',
+        ],
+        [
+            'method' => 'POST',
+            'route' => '/custom',
+            'callable' => 'postFunction',
+        ],
+    ];
+}
diff --git a/tests/plugins/test_route_invalid/test_route_invalid.php b/tests/plugins/test_route_invalid/test_route_invalid.php
new file mode 100644 (file)
index 0000000..0c5a510
--- /dev/null
@@ -0,0 +1,12 @@
+<?php
+
+function test_route_invalid_register_routes(): array
+{
+    return [
+        [
+            'method' => 'GET',
+            'route' => 'not a route',
+            'callable' => 'getFunction',
+        ],
+    ];
+}
index 698d3d10bdeadedafa4bfe2d01cdd8272251f685..29d2791b0198b1ec3b8787799b6f51f860e60381 100644 (file)
@@ -3,7 +3,8 @@
 
 namespace Shaarli\Security;
 
-use Shaarli\FileUtils;
+use Psr\Log\LoggerInterface;
+use Shaarli\Helper\FileUtils;
 use Shaarli\TestCase;
 
 /**
@@ -387,7 +388,7 @@ class BanManagerTest extends TestCase
             3,
             1800,
             $this->banFile,
-            $this->logFile
+            $this->createMock(LoggerInterface::class)
         );
     }
 }
index d302983de2b013e8ec91e79e9c04eb62f59b75fb..f7609fc676e8601a03dc688cdf2cee6407e1fe97 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace Shaarli\Security;
 
+use Psr\Log\LoggerInterface;
+use Shaarli\FakeConfigManager;
 use Shaarli\TestCase;
 
 /**
@@ -9,7 +11,7 @@ use Shaarli\TestCase;
  */
 class LoginManagerTest extends TestCase
 {
-    /** @var \FakeConfigManager Configuration Manager instance */
+    /** @var FakeConfigManager Configuration Manager instance */
     protected $configManager = null;
 
     /** @var LoginManager Login Manager instance */
@@ -60,6 +62,9 @@ class LoginManagerTest extends TestCase
     /** @var CookieManager */
     protected $cookieManager;
 
+    /** @var BanManager */
+    protected $banManager;
+
     /**
      * Prepare or reset test resources
      */
@@ -71,7 +76,7 @@ class LoginManagerTest extends TestCase
 
         $this->passwordHash = sha1($this->password . $this->login . $this->salt);
 
-        $this->configManager = new \FakeConfigManager([
+        $this->configManager = new FakeConfigManager([
             'credentials.login' => $this->login,
             'credentials.hash' => $this->passwordHash,
             'credentials.salt' => $this->salt,
@@ -91,18 +96,29 @@ class LoginManagerTest extends TestCase
             return $this->cookie[$key] ?? null;
         });
         $this->sessionManager = new SessionManager($this->session, $this->configManager, 'session_path');
-        $this->loginManager = new LoginManager($this->configManager, $this->sessionManager, $this->cookieManager);
+        $this->banManager = $this->createMock(BanManager::class);
+        $this->loginManager = new LoginManager(
+            $this->configManager,
+            $this->sessionManager,
+            $this->cookieManager,
+            $this->banManager,
+            $this->createMock(LoggerInterface::class)
+        );
         $this->server['REMOTE_ADDR'] = $this->ipAddr;
     }
 
     /**
      * Record a failed login attempt
      */
-    public function testHandleFailedLogin()
+    public function testHandleFailedLogin(): void
     {
+        $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt');
+        $this->banManager->method('isBanned')->willReturn(true);
+
         $this->loginManager->handleFailedLogin($this->server);
         $this->loginManager->handleFailedLogin($this->server);
-        $this->assertFalse($this->loginManager->canLogin($this->server));
+
+        static::assertFalse($this->loginManager->canLogin($this->server));
     }
 
     /**
@@ -114,8 +130,13 @@ class LoginManagerTest extends TestCase
             'REMOTE_ADDR' => $this->trustedProxy,
             'HTTP_X_FORWARDED_FOR' => $this->ipAddr,
         ];
+
+        $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt');
+        $this->banManager->method('isBanned')->willReturn(true);
+
         $this->loginManager->handleFailedLogin($server);
         $this->loginManager->handleFailedLogin($server);
+
         $this->assertFalse($this->loginManager->canLogin($server));
     }
 
@@ -196,10 +217,16 @@ class LoginManagerTest extends TestCase
      */
     public function testCheckLoginStateNotConfigured()
     {
-        $configManager = new \FakeConfigManager([
+        $configManager = new FakeConfigManager([
             'resource.ban_file' => $this->banFile,
         ]);
-        $loginManager = new LoginManager($configManager, null, $this->cookieManager);
+        $loginManager = new LoginManager(
+            $configManager,
+            $this->sessionManager,
+            $this->cookieManager,
+            $this->banManager,
+            $this->createMock(LoggerInterface::class)
+        );
         $loginManager->checkLoginState('');
 
         $this->assertFalse($loginManager->isLoggedIn());
@@ -270,7 +297,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsWrongLogin()
     {
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', 'b4dl0g1n', $this->password)
+            $this->loginManager->checkCredentials('', 'b4dl0g1n', $this->password)
         );
     }
 
@@ -280,7 +307,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsWrongPassword()
     {
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', $this->login, 'b4dp455wd')
+            $this->loginManager->checkCredentials('', $this->login, 'b4dp455wd')
         );
     }
 
@@ -290,7 +317,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsWrongLoginAndPassword()
     {
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', 'b4dl0g1n', 'b4dp455wd')
+            $this->loginManager->checkCredentials('', 'b4dl0g1n', 'b4dp455wd')
         );
     }
 
@@ -300,7 +327,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsGoodLoginAndPassword()
     {
         $this->assertTrue(
-            $this->loginManager->checkCredentials('', '', $this->login, $this->password)
+            $this->loginManager->checkCredentials('', $this->login, $this->password)
         );
     }
 
@@ -311,7 +338,7 @@ class LoginManagerTest extends TestCase
     {
         $this->configManager->set('ldap.host', 'dummy');
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', $this->login, $this->password)
+            $this->loginManager->checkCredentials('', $this->login, $this->password)
         );
     }
 
index 3f9c3ef59fd2138faeb95fe07b715890a0f09ab4..6830d7146640c0a7b163275d6aa073e9b59eba0a 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Security;
 
+use Shaarli\FakeConfigManager;
 use Shaarli\TestCase;
 
 /**
@@ -12,7 +13,7 @@ class SessionManagerTest extends TestCase
     /** @var array Session ID hashes */
     protected static $sidHashes = null;
 
-    /** @var \FakeConfigManager ConfigManager substitute for testing */
+    /** @var FakeConfigManager ConfigManager substitute for testing */
     protected $conf = null;
 
     /** @var array $_SESSION array for testing */
@@ -34,7 +35,7 @@ class SessionManagerTest extends TestCase
      */
     protected function setUp(): void
     {
-        $this->conf = new \FakeConfigManager([
+        $this->conf = new FakeConfigManager([
             'credentials.login' => 'johndoe',
             'credentials.salt' => 'salt',
             'security.session_protection_disabled' => false,
index 47332544a7b254c8a56ab4adf6c80604daad5ac5..cadd826538f2e76f08bffe27bb3d105abcf43375 100644 (file)
@@ -60,10 +60,10 @@ class UpdaterTest extends TestCase
      */
     public function testReadEmptyUpdatesFile()
     {
-        $this->assertEquals(array(), UpdaterUtils::read_updates_file(''));
+        $this->assertEquals(array(), UpdaterUtils::readUpdatesFile(''));
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         touch($updatesFile);
-        $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile));
+        $this->assertEquals(array(), UpdaterUtils::readUpdatesFile($updatesFile));
         unlink($updatesFile);
     }
 
@@ -75,14 +75,14 @@ class UpdaterTest extends TestCase
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         $updatesMethods = array('m1', 'm2', 'm3');
 
-        UpdaterUtils::write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = UpdaterUtils::read_updates_file($updatesFile);
+        UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
+        $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
         $this->assertEquals($readMethods, $updatesMethods);
 
         // Update
         $updatesMethods[] = 'm4';
-        UpdaterUtils::write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = UpdaterUtils::read_updates_file($updatesFile);
+        UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
+        $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
         $this->assertEquals($readMethods, $updatesMethods);
         unlink($updatesFile);
     }
@@ -95,7 +95,7 @@ class UpdaterTest extends TestCase
         $this->expectException(\Exception::class);
         $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
 
-        UpdaterUtils::write_updates_file('', array('test'));
+        UpdaterUtils::writeUpdatesFile('', array('test'));
     }
 
     /**
@@ -110,7 +110,7 @@ class UpdaterTest extends TestCase
         touch($updatesFile);
         chmod($updatesFile, 0444);
         try {
-            @UpdaterUtils::write_updates_file($updatesFile, array('test'));
+            @UpdaterUtils::writeUpdatesFile($updatesFile, array('test'));
         } catch (Exception $e) {
             unlink($updatesFile);
             throw $e;
index de83d598575a9af23c335a0aa32ee770042c2399..d5289ede2c735afcb8aceb4a14d2c7a7e5e5f9a4 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace Shaarli;
 
+use Shaarli\Helper\ApplicationUtils;
+
 /**
  * Fake ApplicationUtils class to avoid HTTP requests
  */
index 360b34a981c91d797d29d769dd858bb1c032a36a..014c2af0d8f1ae3179ad3d591cc494710bef6737 100644 (file)
@@ -1,9 +1,13 @@
 <?php
 
+namespace Shaarli;
+
+use Shaarli\Config\ConfigManager;
+
 /**
  * Fake ConfigManager
  */
-class FakeConfigManager
+class FakeConfigManager extends ConfigManager
 {
     protected $values = [];
 
@@ -23,7 +27,7 @@ class FakeConfigManager
      * @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;
     }
@@ -35,7 +39,7 @@ class FakeConfigManager
      *
      * @return mixed The value if set, else the name of the key
      */
-    public function get($key)
+    public function get($key, $default = '')
     {
         if (isset($this->values[$key])) {
             return $this->values[$key];
index 516c9f51ea22d195bf71f70bcc9b786c083beade..aed5d2cf1a8baa85f16d8c53dff653f8cf98345c 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
 use Shaarli\History;
 
 /**
index 67d3ebd1c3f14e5d2dae92da82a2caf93d0178bb..4aac7ff1e69617df47b78f146ed739f030424c1c 100644 (file)
     </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}&nbsp;<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}>
+          &nbsp; <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>
index 89d08e2cab7e16c9a2658a17b6093e72476d896c..13b7f24a61b83188c8d899ad1d78daf3878b096b 100644 (file)
       <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>
index 3749bffb620a317c276c4b63a2e9aa42b031a67d..5e038c393822105287678394fa6e1b9ab4211d77 100644 (file)
@@ -6,12 +6,25 @@
 <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">
index d40d94968ad6d75b2145b4116af5f449de7788f5..871a3ba7531abb0c2268ab424ef54ffb5b2abafd 100644 (file)
@@ -1,9 +1,9 @@
 <?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} &#8212; {/if}
+              <a href="{$index_url}shaare/{$value.shorturl}">{'Permalink'|t}</a>
+              {if="$value.tags"} &#8212; {$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>
diff --git a/tpl/default/editlink.batch.html b/tpl/default/editlink.batch.html
new file mode 100644 (file)
index 0000000..b1f8e5b
--- /dev/null
@@ -0,0 +1,32 @@
+<!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>
index 568545bd5cee0fb100ef838fb9d08cc813079b3c..83e541fdf6b32aaf45a985a20645ab19c53bbc37 100644 (file)
@@ -1,3 +1,4 @@
+{if="empty($batch_mode)"}
 <!DOCTYPE html>
 <html{if="$language !== 'auto'"} lang="{$language}"{/if}>
 <head>
@@ -5,6 +6,10 @@
 </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"
@@ -12,6 +17,8 @@
           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}>
         &nbsp;<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}
index c3e0c3c1db3f914475fbf0d59fa6a3e349f684c1..34f9707dd2db00f589912bb0bc50210526ab3a59 100644 (file)
@@ -9,13 +9,17 @@
 <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>
index a506a2eb2543b76f7dbe5ee760de737938a0bce5..4f98d49dff9f066d7ff50b8a73633b31ff2613a7 100644 (file)
   </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>
index beab0eac81ba8d0912d09ec329dba5246a87b8b1..7208a3b6050ea87c909632ccddca1a88cc4c0561 100644 (file)
@@ -90,7 +90,7 @@
           {'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)"}
                     &middot;
 
 {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>
index c153def0450419477dccc8c56cf19c3f146f35e8..58ca18c5726da2b3519ff37cd3c47e2834322b5c 100644 (file)
@@ -18,8 +18,6 @@
   <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>
diff --git a/tpl/default/pluginscontent.html b/tpl/default/pluginscontent.html
new file mode 100644 (file)
index 0000000..1e4f6b8
--- /dev/null
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+  {include="includes"}
+</head>
+<body>
+  {include="page.header"}
+
+  {$content}
+
+  {include="page.footer"}
+</body>
+</html>
diff --git a/tpl/default/server.html b/tpl/default/server.html
new file mode 100644 (file)
index 0000000..de1c8b5
--- /dev/null
@@ -0,0 +1,129 @@
+<!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>
diff --git a/tpl/default/server.requirements.html b/tpl/default/server.requirements.html
new file mode 100644 (file)
index 0000000..85def9b
--- /dev/null
@@ -0,0 +1,68 @@
+<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>
index c067e1d459ed76dc232cc8e5f706e1f9a897306f..01b50b0217501ec38fa77d4f0facc57a9b4545a3 100644 (file)
@@ -48,7 +48,7 @@
 
     <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}
index 2cb08e387b468e8f2b39942a46ae0699abc98088..2df73598173ae522306ae1007ba5188824dcfbd8 100644 (file)
         <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}
index 74f6cdc74417194f9931a1ceabdd51b701575b4f..28ba9f90497df2c3da85513d9e50747b2a1114f8 100644 (file)
@@ -14,9 +14,9 @@
 
     <div class="dailyAbout">
         All links of one day<br>in a single page.<br>
-        {if="$previousday"} <a href="{$base_path}/daily&amp;day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if}
+        {if="$previousday"} <a href="{$base_path}/daily?day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if}
         -
-        {if="$nextday"}<a href="{$base_path}/daily&amp;day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</b>{/if}
+        {if="$nextday"}<a href="{$base_path}/daily?day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</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"}
index eb8807b5a2d0fc7bdbc594f0a020815f3e331834..343418bce1a8436c0edb50d2e0867b5c74ee88b8 100644 (file)
@@ -6,6 +6,7 @@
 {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">
@@ -56,5 +74,5 @@
     </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>
index eac05701c70abf1ed49479faa6dbbd2bf0cfcdcf..2ce9da423af296bd4638f8408f2d487445135ddc 100644 (file)
@@ -5,13 +5,13 @@
 <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#"
index 00896eb5ec65153cab29823bd738ece6b0ff2cb6..ff0dd40ca5bad2a63a643e7d2c0897c3d7c0e999 100644 (file)
@@ -61,7 +61,7 @@
                 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>
index 0fe4c7368f10ac071b76565fd2a2cdcc1ddb822c..be709aeb1b18d85066529f6258718a8e7088109f 100644 (file)
@@ -23,8 +23,6 @@
 </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}
@@ -34,3 +32,7 @@
 {/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>
index 0a33523b375c3f650242a155a68386ee6f4cb5b5..64d7f656e8406b58efb8cc39e287ea6d133144b2 100644 (file)
     </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>
 
 
index a73758cce4af906044e9ed9f1ae31fa8941993e1..2c316d323fe0c757435cbee996d274ad1aa2bdb2 100644 (file)
@@ -18,8 +18,10 @@ module.exports = [
   {
     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',
@@ -99,6 +101,7 @@ module.exports = [
       ].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: {
@@ -139,7 +142,8 @@ module.exports = [
               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',
               }
             }
           ],
index 0a12820c83b2a4a357ec95f8560a1ef9f47c713a..97fb0fad140aa6cb5d95525193156e9ff4834f46 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -2912,6 +2912,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
     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"
@@ -3047,9 +3052,9 @@ inherits@2.0.3:
   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"