]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge branch 'master' into v0.12 v0.12 v0.12.1
authorArthurHoaro <arthur@hoa.ro>
Thu, 12 Nov 2020 12:02:36 +0000 (13:02 +0100)
committerArthurHoaro <arthur@hoa.ro>
Thu, 12 Nov 2020 12:02:36 +0000 (13:02 +0100)
218 files changed:
.docker/nginx.conf
.dockerignore
.htaccess
.travis.yml
AUTHORS
CHANGELOG.md
Dockerfile
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/ApiController.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/FeedBuilder.php
application/formatter/BookmarkDefaultFormatter.php
application/formatter/BookmarkFormatter.php
application/formatter/BookmarkMarkdownExtraFormatter.php [new file with mode: 0644]
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 64% 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/render/PageBuilder.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/Server-configuration.md
doc/md/Shaarli-configuration.md
doc/md/dev/Development.md
doc/md/dev/Plugin-system.md
docker-compose.yml
inc/languages/fr/LC_MESSAGES/shaarli.po
inc/languages/jp/LC_MESSAGES/shaarli.po
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/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/HistoryTest.php
tests/UtilsTest.php
tests/api/controllers/info/InfoTest.php
tests/api/controllers/links/DeleteLinkTest.php
tests/api/controllers/links/GetLinkIdTest.php
tests/api/controllers/links/GetLinksTest.php
tests/api/controllers/links/PostLinkTest.php
tests/api/controllers/links/PutLinkTest.php
tests/api/controllers/tags/DeleteTagTest.php
tests/api/controllers/tags/GetTagNameTest.php
tests/api/controllers/tags/GetTagsTest.php
tests/api/controllers/tags/PutTagTest.php
tests/bookmark/BookmarkArrayTest.php
tests/bookmark/BookmarkFileServiceTest.php
tests/bookmark/BookmarkFilterTest.php
tests/bookmark/BookmarkInitializerTest.php
tests/bookmark/BookmarkTest.php
tests/bookmark/LinkUtilsTest.php
tests/bootstrap.php
tests/container/ContainerBuilderTest.php
tests/feed/FeedBuilderTest.php
tests/formatter/BookmarkDefaultFormatterTest.php
tests/formatter/BookmarkMarkdownExtraFormatterTest.php [new file with mode: 0644]
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 94% 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 80% similarity]
tests/front/controller/admin/ThumbnailsControllerTest.php
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/LegacyLinkDBTest.php
tests/legacy/LegacyUpdaterTest.php
tests/netscape/BookmarkExportTest.php
tests/netscape/BookmarkImportTest.php
tests/plugins/PluginWallabagTest.php
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
tests/utils/ReferenceLinkDB.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/includes.html
tpl/default/install.html
tpl/default/linklist.html
tpl/default/page.footer.html
tpl/default/picwall.html
tpl/default/pluginsadmin.html
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..f6120b71f2b1d4507cd46dcaf7ebf09934d98139 100644 (file)
@@ -44,6 +44,7 @@ RUN apk --update --no-cache add \
         php7-openssl \
         php7-session \
         php7-xml \
+        php7-simplexml \
         php7-zlib \
         s6
 
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 4fb0bfe0d07748ce7c4b39bf5de0e84b95c70374..46dda8d5e3e0fb874419a0f617e65f36826d1229 100644 (file)
--- a/README.md
+++ b/README.md
@@ -6,13 +6,13 @@ _Do you want to share the links you discover?_
 _Shaarli is a minimalist link sharing service that you can install on your own server._
 _It is designed to be personal (single-user), fast and handy._
 
-[![](https://img.shields.io/badge/stable-v0.10.4-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4)
+[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
 [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
 &bull;
-[![](https://img.shields.io/badge/latest-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
+[![](https://img.shields.io/badge/latest-v0.12.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0)
 [![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli)
 &bull;
-[![](https://img.shields.io/badge/master-v0.11.x-blue.svg)](https://github.com/shaarli/Shaarli)
+[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)
 [![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
 
 [![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli)
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..60e916317d7abf95b3b105ec6e57ef7bbe220e94 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);
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 f5b53b01fcc5f5f16c5d477d054c9483d2e142d9..9fb883589d43a61aff1003882d0531ac8fda3979 100644 (file)
@@ -1,6 +1,8 @@
 <?php
+
 namespace Shaarli\Api;
 
+use malkusch\lock\mutex\FlockMutex;
 use Shaarli\Api\Exceptions\ApiAuthorizationException;
 use Shaarli\Api\Exceptions\ApiException;
 use Shaarli\Bookmark\BookmarkFileService;
@@ -107,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');
@@ -143,6 +146,7 @@ class ApiMiddleware
         $linkDb = new BookmarkFileService(
             $conf,
             $this->container->get('history'),
+            new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
             true
         );
         $this->container['db'] = $linkDb;
index faebb8f5f00685f4a2413d429a3a917c2adc2833..05a2840a6221d3c50c215c7994415c586b01677c 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,12 +91,12 @@ class ApiUtils
      * If no URL is provided, it will generate a local note URL.
      * If no title is provided, it will use the URL as title.
      *
-     * @param array  $input          Request Link.
-     * @param bool   $defaultPrivate Request Link.
+     * @param array|null  $input          Request Link.
+     * @param bool        $defaultPrivate Setting defined if a bookmark is private by default.
      *
      * @return Bookmark instance.
      */
-    public static function buildLinkFromRequest($input, $defaultPrivate)
+    public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark
     {
         $bookmark = new Bookmark();
         $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
@@ -110,6 +112,15 @@ class ApiUtils
         $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
         $bookmark->setPrivate($private);
 
+        $created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? '');
+        if ($created instanceof \DateTimeInterface) {
+            $bookmark->setCreated($created);
+        }
+        $updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? '');
+        if ($updated instanceof \DateTimeInterface) {
+            $bookmark->setUpdated($updated);
+        }
+
         return $bookmark;
     }
 
index c4b3d0c3df983484c535d5396530c93843688cdd..88a845ebc0b8557be699a5292b4a55c91911dc6a 100644 (file)
@@ -4,6 +4,7 @@ namespace Shaarli\Api\Controllers;
 
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\History;
 use Slim\Container;
 
 /**
@@ -31,7 +32,7 @@ abstract class ApiController
     protected $bookmarkService;
 
     /**
-     * @var HistoryController
+     * @var History
      */
     protected $history;
 
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 2924795012d4c1cd037a008ecfd9f6fdb091ad49..c379b9622c6120b3eb8c29bde4702c17074a0961 100644 (file)
@@ -96,11 +96,12 @@ class Links extends ApiController
      */
     public function getLink($request, $response, $args)
     {
-        if (!$this->bookmarkService->exists($args['id'])) {
+        $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
+        if ($id === null || ! $this->bookmarkService->exists($id)) {
             throw new ApiLinkNotFoundException();
         }
         $index = index_url($this->ci['environment']);
-        $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index);
+        $out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
 
         return $response->withJson($out, 200, $this->jsonStyle);
     }
@@ -115,10 +116,11 @@ class Links extends ApiController
      */
     public function postLink($request, $response)
     {
-        $data = $request->getParsedBody();
-        $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
+        $data = (array) ($request->getParsedBody() ?? []);
+        $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
         // duplicate by URL, return 409 Conflict
-        if (! empty($bookmark->getUrl())
+        if (
+            ! empty($bookmark->getUrl())
             && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
         ) {
             return $response->withJson(
@@ -130,7 +132,7 @@ class Links extends ApiController
 
         $this->bookmarkService->add($bookmark);
         $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
-        $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]);
+        $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
         return $response->withAddedHeader('Location', $redirect)
                         ->withJson($out, 201, $this->jsonStyle);
     }
@@ -148,18 +150,20 @@ class Links extends ApiController
      */
     public function putLink($request, $response, $args)
     {
-        if (! $this->bookmarkService->exists($args['id'])) {
+        $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
+        if ($id === null || !$this->bookmarkService->exists($id)) {
             throw new ApiLinkNotFoundException();
         }
 
         $index = index_url($this->ci['environment']);
         $data = $request->getParsedBody();
 
-        $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
+        $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
         // duplicate URL on a different link, return 409 Conflict
-        if (! empty($requestBookmark->getUrl())
+        if (
+            ! empty($requestBookmark->getUrl())
             && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
-            && $dup->getId() != $args['id']
+            && $dup->getId() != $id
         ) {
             return $response->withJson(
                 ApiUtils::formatLink($dup, $index),
@@ -168,7 +172,7 @@ class Links extends ApiController
             );
         }
 
-        $responseBookmark = $this->bookmarkService->get($args['id']);
+        $responseBookmark = $this->bookmarkService->get($id);
         $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
         $this->bookmarkService->set($responseBookmark);
 
@@ -189,10 +193,11 @@ class Links extends ApiController
      */
     public function deleteLink($request, $response, $args)
     {
-        if (! $this->bookmarkService->exists($args['id'])) {
+        $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
+        if ($id === null || !$this->bookmarkService->exists($id)) {
             throw new ApiLinkNotFoundException();
         }
-        $bookmark = $this->bookmarkService->get($args['id']);
+        $bookmark = $this->bookmarkService->get($id);
         $this->bookmarkService->remove($bookmark);
 
         return $response->withStatus(204);
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 1beb8be2e127a2b0b905e71e256b0279f8018598..4238ef259a8938eacaba721d6ca92e9cac6d54ae 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 use DateTime;
@@ -17,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;
@@ -52,32 +54,37 @@ class Bookmark
     /** @var bool True if the bookmark can only be seen while logged in */
     protected $private;
 
+    /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
+    protected $additionalContent = [];
+
     /**
      * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
      *
-     * @param array $data
+     * @param array  $data
+     * @param string $tagsSeparator Tags separator loaded from the config file.
+     *                              This is a context data, and it should *never* be stored in the Bookmark object.
      *
      * @return $this
      */
-    public function fromArray($data)
+    public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
     {
-        $this->id = $data['id'];
-        $this->shortUrl = $data['shorturl'];
-        $this->url = $data['url'];
-        $this->title = $data['title'];
-        $this->description = $data['description'];
-        $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null;
-        $this->sticky = isset($data['sticky']) ? $data['sticky'] : false;
-        $this->created = $data['created'];
+        $this->id = $data['id'] ?? null;
+        $this->shortUrl = $data['shorturl'] ?? null;
+        $this->url = $data['url'] ?? null;
+        $this->title = $data['title'] ?? null;
+        $this->description = $data['description'] ?? null;
+        $this->thumbnail = $data['thumbnail'] ?? null;
+        $this->sticky = $data['sticky'] ?? false;
+        $this->created = $data['created'] ?? null;
         if (is_array($data['tags'])) {
             $this->tags = $data['tags'];
         } else {
-            $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY);
+            $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
         }
         if (! empty($data['updated'])) {
             $this->updated = $data['updated'];
         }
-        $this->private = $data['private'] ? true : false;
+        $this->private = ($data['private'] ?? false) ? true : false;
 
         return $this;
     }
@@ -93,24 +100,29 @@ class Bookmark
      *   - the URL with the permalink
      *   - the title with the URL
      *
+     * Also make sure that we do not save search highlights in the datastore.
+     *
      * @throws InvalidBookmarkException
      */
-    public function validate()
+    public function validate(): void
     {
-        if ($this->id === null
+        if (
+            $this->id === null
             || ! is_int($this->id)
             || empty($this->shortUrl)
             || empty($this->created)
-            || ! $this->created instanceof DateTimeInterface
         ) {
             throw new InvalidBookmarkException($this);
         }
         if (empty($this->url)) {
-            $this->url = '/shaare/'. $this->shortUrl;
+            $this->url = '/shaare/' . $this->shortUrl;
         }
         if (empty($this->title)) {
             $this->title = $this->url;
         }
+        if (array_key_exists('search_highlight', $this->additionalContent)) {
+            unset($this->additionalContent['search_highlight']);
+        }
     }
 
     /**
@@ -119,11 +131,11 @@ class Bookmark
      *   - created: with the current datetime
      *   - shortUrl: with a generated small hash from the date and the given ID
      *
-     * @param int $id
+     * @param int|null $id
      *
      * @return Bookmark
      */
-    public function setId($id)
+    public function setId(?int $id): Bookmark
     {
         $this->id = $id;
         if (empty($this->created)) {
@@ -139,9 +151,9 @@ class Bookmark
     /**
      * Get the Id.
      *
-     * @return int
+     * @return int|null
      */
-    public function getId()
+    public function getId(): ?int
     {
         return $this->id;
     }
@@ -149,9 +161,9 @@ class Bookmark
     /**
      * Get the ShortUrl.
      *
-     * @return string
+     * @return string|null
      */
-    public function getShortUrl()
+    public function getShortUrl(): ?string
     {
         return $this->shortUrl;
     }
@@ -159,9 +171,9 @@ class Bookmark
     /**
      * Get the Url.
      *
-     * @return string
+     * @return string|null
      */
-    public function getUrl()
+    public function getUrl(): ?string
     {
         return $this->url;
     }
@@ -171,7 +183,7 @@ class Bookmark
      *
      * @return string
      */
-    public function getTitle()
+    public function getTitle(): ?string
     {
         return $this->title;
     }
@@ -181,7 +193,7 @@ class Bookmark
      *
      * @return string
      */
-    public function getDescription()
+    public function getDescription(): string
     {
         return ! empty($this->description) ? $this->description : '';
     }
@@ -191,7 +203,7 @@ class Bookmark
      *
      * @return DateTimeInterface
      */
-    public function getCreated()
+    public function getCreated(): ?DateTimeInterface
     {
         return $this->created;
     }
@@ -201,7 +213,7 @@ class Bookmark
      *
      * @return DateTimeInterface
      */
-    public function getUpdated()
+    public function getUpdated(): ?DateTimeInterface
     {
         return $this->updated;
     }
@@ -209,11 +221,11 @@ class Bookmark
     /**
      * Set the ShortUrl.
      *
-     * @param string $shortUrl
+     * @param string|null $shortUrl
      *
      * @return Bookmark
      */
-    public function setShortUrl($shortUrl)
+    public function setShortUrl(?string $shortUrl): Bookmark
     {
         $this->shortUrl = $shortUrl;
 
@@ -223,14 +235,14 @@ class Bookmark
     /**
      * Set the Url.
      *
-     * @param string $url
-     * @param array  $allowedProtocols
+     * @param string|null $url
+     * @param string[]    $allowedProtocols
      *
      * @return Bookmark
      */
-    public function setUrl($url, $allowedProtocols = [])
+    public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
     {
-        $url = trim($url);
+        $url = $url !== null ? trim($url) : '';
         if (! empty($url)) {
             $url = whitelist_protocols($url, $allowedProtocols);
         }
@@ -242,13 +254,13 @@ class Bookmark
     /**
      * Set the Title.
      *
-     * @param string $title
+     * @param string|null $title
      *
      * @return Bookmark
      */
-    public function setTitle($title)
+    public function setTitle(?string $title): Bookmark
     {
-        $this->title = trim($title);
+        $this->title = $title !== null ? trim($title) : '';
 
         return $this;
     }
@@ -256,11 +268,11 @@ class Bookmark
     /**
      * Set the Description.
      *
-     * @param string $description
+     * @param string|null $description
      *
      * @return Bookmark
      */
-    public function setDescription($description)
+    public function setDescription(?string $description): Bookmark
     {
         $this->description = $description;
 
@@ -271,11 +283,11 @@ class Bookmark
      * Set the Created.
      * Note: you shouldn't set this manually except for special cases (like bookmark import)
      *
-     * @param DateTimeInterface $created
+     * @param DateTimeInterface|null $created
      *
      * @return Bookmark
      */
-    public function setCreated($created)
+    public function setCreated(?DateTimeInterface $created): Bookmark
     {
         $this->created = $created;
 
@@ -285,11 +297,11 @@ class Bookmark
     /**
      * Set the Updated.
      *
-     * @param DateTimeInterface $updated
+     * @param DateTimeInterface|null $updated
      *
      * @return Bookmark
      */
-    public function setUpdated($updated)
+    public function setUpdated(?DateTimeInterface $updated): Bookmark
     {
         $this->updated = $updated;
 
@@ -301,7 +313,7 @@ class Bookmark
      *
      * @return bool
      */
-    public function isPrivate()
+    public function isPrivate(): bool
     {
         return $this->private ? true : false;
     }
@@ -309,11 +321,11 @@ class Bookmark
     /**
      * Set the Private.
      *
-     * @param bool $private
+     * @param bool|null $private
      *
      * @return Bookmark
      */
-    public function setPrivate($private)
+    public function setPrivate(?bool $private): Bookmark
     {
         $this->private = $private ? true : false;
 
@@ -323,9 +335,9 @@ class Bookmark
     /**
      * Get the Tags.
      *
-     * @return array
+     * @return string[]
      */
-    public function getTags()
+    public function getTags(): array
     {
         return is_array($this->tags) ? $this->tags : [];
     }
@@ -333,13 +345,18 @@ class Bookmark
     /**
      * Set the Tags.
      *
-     * @param array $tags
+     * @param string[]|null $tags
      *
      * @return Bookmark
      */
-    public function setTags($tags)
+    public function setTags(?array $tags): Bookmark
     {
-        $this->setTagsString(implode(' ', $tags));
+        $this->tags = array_map(
+            function (string $tag): string {
+                return $tag[0] === '-' ? substr($tag, 1) : $tag;
+            },
+            tags_filter($tags, ' ')
+        );
 
         return $this;
     }
@@ -357,23 +374,41 @@ class Bookmark
     /**
      * Set the Thumbnail.
      *
-     * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found
+     * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
      *
      * @return Bookmark
      */
-    public function setThumbnail($thumbnail)
+    public function setThumbnail($thumbnail): Bookmark
     {
         $this->thumbnail = $thumbnail;
 
         return $this;
     }
 
+    /**
+     * Return true if:
+     *   - the bookmark's thumbnail is not already set to false (= not found)
+     *   - it's not a note
+     *   - it's an HTTP(S) link
+     *   - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
+     *
+     * @return bool True if the bookmark's thumbnail needs to be retrieved.
+     */
+    public function shouldUpdateThumbnail(): bool
+    {
+        return $this->thumbnail !== false
+            && !$this->isNote()
+            && startsWith(strtolower($this->url), 'http')
+            && (null === $this->thumbnail || !is_file($this->thumbnail))
+        ;
+    }
+
     /**
      * Get the Sticky.
      *
      * @return bool
      */
-    public function isSticky()
+    public function isSticky(): bool
     {
         return $this->sticky ? true : false;
     }
@@ -381,11 +416,11 @@ class Bookmark
     /**
      * Set the Sticky.
      *
-     * @param bool $sticky
+     * @param bool|null $sticky
      *
      * @return Bookmark
      */
-    public function setSticky($sticky)
+    public function setSticky(?bool $sticky): Bookmark
     {
         $this->sticky = $sticky ? true : false;
 
@@ -393,17 +428,19 @@ class Bookmark
     }
 
     /**
-     * @return string Bookmark's tags as a string, separated by a space
+     * @param string $separator Tags separator loaded from the config file.
+     *
+     * @return string Bookmark's tags as a string, separated by a separator
      */
-    public function getTagsString()
+    public function getTagsString(string $separator = ' '): string
     {
-        return implode(' ', $this->getTags());
+        return tags_array2str($this->getTags(), $separator);
     }
 
     /**
      * @return bool
      */
-    public function isNote()
+    public function isNote(): bool
     {
         // We check empty value to get a valid result if the link has not been saved yet
         return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
@@ -416,33 +453,65 @@ class Bookmark
      *   - multiple spaces will be removed
      *   - trailing dash in tags will be removed
      *
-     * @param string $tags
+     * @param string|null $tags
+     * @param string      $separator Tags separator loaded from the config file.
      *
      * @return $this
      */
-    public function setTagsString($tags)
+    public function setTagsString(?string $tags, string $separator = ' '): Bookmark
     {
-        // Remove first '-' char in tags.
-        $tags = preg_replace('/(^| )\-/', '$1', $tags);
-        // Explode all tags separted by spaces or commas
-        $tags = preg_split('/[\s,]+/', $tags);
-        // Remove eventual empty values
-        $tags = array_values(array_filter($tags));
+        $this->setTags(tags_str2array($tags, $separator));
 
-        $this->tags = $tags;
+        return $this;
+    }
+
+    /**
+     * Get entire additionalContent array.
+     *
+     * @return mixed[]
+     */
+    public function getAdditionalContent(): array
+    {
+        return $this->additionalContent;
+    }
+
+    /**
+     * Set a single entry in additionalContent, by key.
+     *
+     * @param string     $key
+     * @param mixed|null $value Any type of value can be set.
+     *
+     * @return $this
+     */
+    public function addAdditionalContentEntry(string $key, $value): self
+    {
+        $this->additionalContent[$key] = $value;
 
         return $this;
     }
 
+    /**
+     * Get a single entry in additionalContent, by key.
+     *
+     * @param string $key
+     * @param mixed|null $default
+     *
+     * @return mixed|null can be any type or even null.
+     */
+    public function getAdditionalContentEntry(string $key, $default = null)
+    {
+        return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
+    }
+
     /**
      * Rename a tag in tags list.
      *
      * @param string $fromTag
      * @param string $toTag
      */
-    public function renameTag($fromTag, $toTag)
+    public function renameTag(string $fromTag, string $toTag): void
     {
-        if (($pos = array_search($fromTag, $this->tags)) !== false) {
+        if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
             $this->tags[$pos] = trim($toTag);
         }
     }
@@ -452,9 +521,9 @@ class Bookmark
      *
      * @param string $tag
      */
-    public function deleteTag($tag)
+    public function deleteTag(string $tag): void
     {
-        if (($pos = array_search($tag, $this->tags)) !== false) {
+        if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
             unset($this->tags[$pos]);
             $this->tags = array_values($this->tags);
         }
index 3bd5eb20f64bebe26d6306ba3590190eb90ec55b..b93281166df4ef676500eff9a4319d48ade1c81c 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 use Shaarli\Bookmark\Exception\InvalidBookmarkException;
@@ -70,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()
@@ -187,13 +190,13 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
     /**
      * Returns a bookmark offset in bookmarks array from its unique ID.
      *
-     * @param int $id Persistent ID of a bookmark.
+     * @param int|null $id Persistent ID of a bookmark.
      *
      * @return int Real offset in local array, or null if doesn't exist.
      */
-    protected function getBookmarkOffset($id)
+    protected function getBookmarkOffset(?int $id): ?int
     {
-        if (isset($this->ids[$id])) {
+        if ($id !== null && isset($this->ids[$id])) {
             return $this->ids[$id];
         }
         return null;
@@ -205,7 +208,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
      *
      * @return int next ID.
      */
-    public function getNextId()
+    public function getNextId(): int
     {
         if (!empty($this->ids)) {
             return max(array_keys($this->ids)) + 1;
@@ -214,13 +217,14 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
     }
 
     /**
-     * @param $url
+     * @param string $url
      *
      * @return Bookmark|null
      */
-    public function getByUrl($url)
+    public function getByUrl(string $url): ?Bookmark
     {
-        if (! empty($url)
+        if (
+            ! empty($url)
             && isset($this->urls[$url])
             && isset($this->bookmarks[$this->urls[$url]])
         ) {
index c9ec260930d159f8603f1f5c796fbec5612c08c2..6666a251c821a9eb83e710ddb48973e14008261d 100644 (file)
@@ -1,10 +1,12 @@
 <?php
 
+declare(strict_types=1);
 
 namespace Shaarli\Bookmark;
 
-
+use DateTime;
 use Exception;
+use malkusch\lock\mutex\Mutex;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
 use Shaarli\Bookmark\Exception\EmptyDataStoreException;
@@ -47,15 +49,19 @@ class BookmarkFileService implements BookmarkServiceInterface
     /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
     protected $isLoggedIn;
 
+    /** @var Mutex */
+    protected $mutex;
+
     /**
      * @inheritDoc
      */
-    public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
+    public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn)
     {
         $this->conf = $conf;
         $this->history = $history;
+        $this->mutex = $mutex;
         $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
-        $this->bookmarksIO = new BookmarkIO($this->conf);
+        $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
         $this->isLoggedIn = $isLoggedIn;
 
         if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
@@ -63,7 +69,7 @@ class BookmarkFileService implements BookmarkServiceInterface
         } else {
             try {
                 $this->bookmarks = $this->bookmarksIO->read();
-            } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
+            } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
                 $this->bookmarks = new BookmarkArray();
 
                 if ($this->isLoggedIn) {
@@ -79,25 +85,29 @@ class BookmarkFileService implements BookmarkServiceInterface
             if (! $this->bookmarks instanceof BookmarkArray) {
                 $this->migrate();
                 exit(
-                    'Your data store has been migrated, please reload the page.'. PHP_EOL .
+                    'Your data store has been migrated, please reload the page.' . PHP_EOL .
                     'If this message keeps showing up, please delete data/updates.txt file.'
                 );
             }
         }
 
-        $this->bookmarkFilter = new BookmarkFilter($this->bookmarks);
+        $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
     }
 
     /**
      * @inheritDoc
      */
-    public function findByHash($hash)
+    public function findByHash(string $hash, string $privateKey = null): Bookmark
     {
         $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
         // PHP 7.3 introduced array_key_first() to avoid this hack
         $first = reset($bookmark);
-        if (! $this->isLoggedIn && $first->isPrivate()) {
-            throw new Exception('Not authorized');
+        if (
+            !$this->isLoggedIn
+            && $first->isPrivate()
+            && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
+        ) {
+            throw new BookmarkNotFoundException();
         }
 
         return $first;
@@ -106,7 +116,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function findByUrl($url)
+    public function findByUrl(string $url): ?Bookmark
     {
         return $this->bookmarks->getByUrl($url);
     }
@@ -115,10 +125,10 @@ class BookmarkFileService implements BookmarkServiceInterface
      * @inheritDoc
      */
     public function search(
-        $request = [],
-        $visibility = null,
-        $caseSensitive = false,
-        $untaggedOnly = false,
+        array $request = [],
+        string $visibility = null,
+        bool $caseSensitive = false,
+        bool $untaggedOnly = false,
         bool $ignoreSticky = false
     ) {
         if ($visibility === null) {
@@ -126,8 +136,8 @@ class BookmarkFileService implements BookmarkServiceInterface
         }
 
         // Filter bookmark database according to parameters.
-        $searchtags = isset($request['searchtags']) ? $request['searchtags'] : '';
-        $searchterm = isset($request['searchterm']) ? $request['searchterm'] : '';
+        $searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
+        $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
 
         if ($ignoreSticky) {
             $this->bookmarks->reorder('DESC', true);
@@ -135,7 +145,7 @@ class BookmarkFileService implements BookmarkServiceInterface
 
         return $this->bookmarkFilter->filter(
             BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
-            [$searchtags, $searchterm],
+            [$searchTags, $searchTerm],
             $caseSensitive,
             $visibility,
             $untaggedOnly
@@ -145,7 +155,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function get($id, $visibility = null)
+    public function get(int $id, string $visibility = null): Bookmark
     {
         if (! isset($this->bookmarks[$id])) {
             throw new BookmarkNotFoundException();
@@ -156,7 +166,8 @@ class BookmarkFileService implements BookmarkServiceInterface
         }
 
         $bookmark = $this->bookmarks[$id];
-        if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
+        if (
+            ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
             || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
         ) {
             throw new Exception('Unauthorized');
@@ -168,20 +179,17 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function set($bookmark, $save = true)
+    public function set(Bookmark $bookmark, bool $save = true): Bookmark
     {
         if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception(t('Provided data is invalid'));
-        }
         if (! isset($this->bookmarks[$bookmark->getId()])) {
             throw new BookmarkNotFoundException();
         }
         $bookmark->validate();
 
-        $bookmark->setUpdated(new \DateTime());
+        $bookmark->setUpdated(new DateTime());
         $this->bookmarks[$bookmark->getId()] = $bookmark;
         if ($save === true) {
             $this->save();
@@ -193,15 +201,12 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function add($bookmark, $save = true)
+    public function add(Bookmark $bookmark, bool $save = true): Bookmark
     {
         if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception(t('Provided data is invalid'));
-        }
-        if (! empty($bookmark->getId())) {
+        if (!empty($bookmark->getId())) {
             throw new Exception(t('This bookmarks already exists'));
         }
         $bookmark->setId($this->bookmarks->getNextId());
@@ -218,14 +223,11 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function addOrSet($bookmark, $save = true)
+    public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
     {
         if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception('Provided data is invalid');
-        }
         if ($bookmark->getId() === null) {
             return $this->add($bookmark, $save);
         }
@@ -235,14 +237,11 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function remove($bookmark, $save = true)
+    public function remove(Bookmark $bookmark, bool $save = true): void
     {
         if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception(t('Provided data is invalid'));
-        }
         if (! isset($this->bookmarks[$bookmark->getId()])) {
             throw new BookmarkNotFoundException();
         }
@@ -257,7 +256,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function exists($id, $visibility = null)
+    public function exists(int $id, string $visibility = null): bool
     {
         if (! isset($this->bookmarks[$id])) {
             return false;
@@ -268,7 +267,8 @@ class BookmarkFileService implements BookmarkServiceInterface
         }
 
         $bookmark = $this->bookmarks[$id];
-        if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
+        if (
+            ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
             || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
         ) {
             return false;
@@ -280,7 +280,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function count($visibility = null)
+    public function count(string $visibility = null): int
     {
         return count($this->search([], $visibility));
     }
@@ -288,7 +288,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function save()
+    public function save(): void
     {
         if (true !== $this->isLoggedIn) {
             // TODO: raise an Exception instead
@@ -303,14 +303,15 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function bookmarksCountPerTag($filteringTags = [], $visibility = null)
+    public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
     {
         $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
         $tags = [];
         $caseMapping = [];
         foreach ($bookmarks as $bookmark) {
             foreach ($bookmark->getTags() as $tag) {
-                if (empty($tag)
+                if (
+                    empty($tag)
                     || (! $this->isLoggedIn && startsWith($tag, '.'))
                     || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
                     || in_array($tag, $filteringTags, true)
@@ -339,38 +340,55 @@ class BookmarkFileService implements BookmarkServiceInterface
         $keys = array_keys($tags);
         $tmpTags = array_combine($keys, $keys);
         array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
+
         return $tags;
     }
 
     /**
      * @inheritDoc
      */
-    public function days()
-    {
-        $bookmarkDays = [];
-        foreach ($this->search() as $bookmark) {
-            $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0;
+    public function findByDate(
+        \DateTimeInterface $from,
+        \DateTimeInterface $to,
+        ?\DateTimeInterface &$previous,
+        ?\DateTimeInterface &$next
+    ): array {
+        $out = [];
+        $previous = null;
+        $next = null;
+
+        foreach ($this->search([], null, false, false, true) as $bookmark) {
+            if ($to < $bookmark->getCreated()) {
+                $next = $bookmark->getCreated();
+            } elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
+                $out[] = $bookmark;
+            } else {
+                if ($previous !== null) {
+                    break;
+                }
+                $previous = $bookmark->getCreated();
+            }
         }
-        $bookmarkDays = array_keys($bookmarkDays);
-        sort($bookmarkDays);
 
-        return $bookmarkDays;
+        return $out;
     }
 
     /**
      * @inheritDoc
      */
-    public function filterDay($request)
+    public function getLatest(): ?Bookmark
     {
-        $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
+        foreach ($this->search([], null, false, false, true) as $bookmark) {
+            return $bookmark;
+        }
 
-        return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility);
+        return null;
     }
 
     /**
      * @inheritDoc
      */
-    public function initialize()
+    public function initialize(): void
     {
         $initializer = new BookmarkInitializer($this);
         $initializer->initialize();
@@ -383,7 +401,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * Handles migration to the new database format (BookmarksArray).
      */
-    protected function migrate()
+    protected function migrate(): void
     {
         $bookmarkDb = new LegacyLinkDB(
             $this->conf->get('resource.datastore'),
@@ -391,14 +409,14 @@ class BookmarkFileService implements BookmarkServiceInterface
             false
         );
         $updater = new LegacyUpdater(
-            UpdaterUtils::read_updates_file($this->conf->get('resource.updates')),
+            UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
             $bookmarkDb,
             $this->conf,
             true
         );
         $newUpdates = $updater->update();
         if (! empty($newUpdates)) {
-            UpdaterUtils::write_updates_file(
+            UpdaterUtils::writeUpdatesFile(
                 $this->conf->get('resource.updates'),
                 $updater->getDoneUpdates()
             );
index 6636bbfeec63e6e759154eda8bde4c377337d26c..db83c51c135e012ce7c693a0ab6eee5f82070208 100644 (file)
@@ -1,9 +1,12 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 use Exception;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Config\ConfigManager;
 
 /**
  * Class LinkFilter.
@@ -56,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;
     }
 
     /**
@@ -77,8 +84,13 @@ class BookmarkFilter
      *
      * @throws BookmarkNotFoundException
      */
-    public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
-    {
+    public function filter(
+        string $type,
+        $request,
+        bool $casesensitive = false,
+        string $visibility = 'all',
+        bool $untaggedonly = false
+    ) {
         if (!in_array($visibility, ['all', 'public', 'private'])) {
             $visibility = 'all';
         }
@@ -100,10 +112,14 @@ class BookmarkFilter
                     $filtered = $this->bookmarks;
                 }
                 if (!empty($request[0])) {
-                    $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
+                    $filtered = (new BookmarkFilter($filtered, $this->conf))
+                        ->filterTags($request[0], $casesensitive, $visibility)
+                    ;
                 }
                 if (!empty($request[1])) {
-                    $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility);
+                    $filtered = (new BookmarkFilter($filtered, $this->conf))
+                        ->filterFulltext($request[1], $visibility)
+                    ;
                 }
                 return $filtered;
             case self::$FILTER_TEXT:
@@ -128,13 +144,13 @@ class BookmarkFilter
      *
      * @return Bookmark[] filtered bookmarks.
      */
-    private function noFilter($visibility = 'all')
+    private function noFilter(string $visibility = 'all')
     {
         if ($visibility === 'all') {
             return $this->bookmarks;
         }
 
-        $out = array();
+        $out = [];
         foreach ($this->bookmarks as $key => $value) {
             if ($value->isPrivate() && $visibility === 'private') {
                 $out[$key] = $value;
@@ -151,11 +167,11 @@ class BookmarkFilter
      *
      * @param string $smallHash permalink hash.
      *
-     * @return array $filtered array containing permalink data.
+     * @return Bookmark[] $filtered array containing permalink data.
      *
-     * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link.
+     * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
      */
-    private function filterSmallHash($smallHash)
+    private function filterSmallHash(string $smallHash)
     {
         foreach ($this->bookmarks as $key => $l) {
             if ($smallHash == $l->getShortUrl()) {
@@ -186,15 +202,15 @@ class BookmarkFilter
      * @param string $searchterms search query.
      * @param string $visibility  Optional: return only all/private/public bookmarks.
      *
-     * @return array search results.
+     * @return Bookmark[] search results.
      */
-    private function filterFulltext($searchterms, $visibility = 'all')
+    private function filterFulltext(string $searchterms, string $visibility = 'all')
     {
         if (empty($searchterms)) {
             return $this->noFilter($visibility);
         }
 
-        $filtered = array();
+        $filtered = [];
         $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
         $exactRegex = '/"([^"]+)"/';
         // Retrieve exact search terms.
@@ -206,8 +222,8 @@ class BookmarkFilter
         $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
 
         // Filter excluding terms and update andSearch.
-        $excludeSearch = array();
-        $andSearch = array();
+        $excludeSearch = [];
+        $andSearch = [];
         foreach ($explodedSearchAnd as $needle) {
             if ($needle[0] == '-' && strlen($needle) > 1) {
                 $excludeSearch[] = substr($needle, 1);
@@ -227,33 +243,38 @@ class BookmarkFilter
                 }
             }
 
-            // Concatenate link fields to search across fields.
-            // Adds a '\' separator for exact search terms.
-            $content  = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
-            $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
-            $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
-            $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
+            $lengths = [];
+            $content = $this->buildFullTextSearchableLink($link, $lengths);
 
             // Be optimistic
             $found = true;
+            $foundPositions = [];
 
             // First, we look for exact term search
-            for ($i = 0; $i < count($exactSearch) && $found; $i++) {
-                $found = strpos($content, $exactSearch[$i]) !== false;
-            }
-
-            // Iterate over keywords, if keyword is not found,
+            // Then iterate over keywords, if keyword is not found,
             // no need to check for the others. We want all or nothing.
-            for ($i = 0; $i < count($andSearch) && $found; $i++) {
-                $found = strpos($content, $andSearch[$i]) !== false;
+            foreach ([$exactSearch, $andSearch] as $search) {
+                for ($i = 0; $i < count($search) && $found !== false; $i++) {
+                    $found = mb_strpos($content, $search[$i]);
+                    if ($found === false) {
+                        break;
+                    }
+
+                    $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
+                }
             }
 
             // Exclude terms.
-            for ($i = 0; $i < count($excludeSearch) && $found; $i++) {
+            for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
                 $found = strpos($content, $excludeSearch[$i]) === false;
             }
 
-            if ($found) {
+            if ($found !== false) {
+                $link->addAdditionalContentEntry(
+                    'search_highlight',
+                    $this->postProcessFoundPositions($lengths, $foundPositions)
+                );
+
                 $filtered[$id] = $link;
             }
         }
@@ -268,8 +289,9 @@ class BookmarkFilter
      *
      * @return string generated regex fragment
      */
-    private static function tag2regex($tag)
+    protected function tag2regex(string $tag): string
     {
+        $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
         $len = strlen($tag);
         if (!$len || $tag === "-" || $tag === "*") {
             // nothing to search, return empty regex
@@ -283,12 +305,13 @@ class BookmarkFilter
             $i = 0; // start at first character
             $regex = '(?='; // use positive lookahead
         }
-        $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
+        // before tag may only be the separator or the beginning
+        $regex .= '.*(?:^|' . $tagsSeparator . ')';
         // iterate over string, separating it into placeholder and content
         for (; $i < $len; $i++) {
             if ($tag[$i] === '*') {
                 // placeholder found
-                $regex .= '[^ ]*?';
+                $regex .= '[^' . $tagsSeparator . ']*?';
             } else {
                 // regular characters
                 $offset = strpos($tag, '*', $i);
@@ -304,7 +327,8 @@ class BookmarkFilter
                 $i = $offset;
             }
         }
-        $regex .= '(?:$| ))'; // after the tag may only be a space or the end
+        // after the tag may only be the separator or the end
+        $regex .= '(?:$|' . $tagsSeparator . '))';
         return $regex;
     }
 
@@ -314,22 +338,23 @@ class BookmarkFilter
      * You can specify one or more tags, separated by space or a comma, e.g.
      *  print_r($mydb->filterTags('linux programming'));
      *
-     * @param string $tags          list of tags separated by commas or blank spaces.
-     * @param bool   $casesensitive ignore case if false.
-     * @param string $visibility    Optional: return only all/private/public bookmarks.
+     * @param string|array $tags          list of tags, separated by commas or blank spaces if passed as string.
+     * @param bool         $casesensitive ignore case if false.
+     * @param string       $visibility    Optional: return only all/private/public bookmarks.
      *
-     * @return array filtered bookmarks.
+     * @return Bookmark[] filtered bookmarks.
      */
-    public function filterTags($tags, $casesensitive = false, $visibility = 'all')
+    public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
     {
+        $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
         // get single tags (we may get passed an array, even though the docs say different)
         $inputTags = $tags;
         if (!is_array($tags)) {
             // we got an input string, split tags
-            $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
+            $inputTags = tags_str2array($inputTags, $tagsSeparator);
         }
 
-        if (!count($inputTags)) {
+        if (count($inputTags) === 0) {
             // no input tags
             return $this->noFilter($visibility);
         }
@@ -346,7 +371,7 @@ class BookmarkFilter
         }
 
         // build regex from all tags
-        $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
+        $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
         if (!$casesensitive) {
             // make regex case insensitive
             $re .= 'i';
@@ -366,10 +391,11 @@ class BookmarkFilter
                     continue;
                 }
             }
-            $search = $link->getTagsString(); // build search string, start with tags of current link
+            // build search string, start with tags of current link
+            $search = $link->getTagsString($tagsSeparator);
             if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
                 // description given and at least one possible tag found
-                $descTags = array();
+                $descTags = [];
                 // find all tags in the form of #tag in the description
                 preg_match_all(
                     '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -378,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
@@ -396,9 +422,9 @@ class BookmarkFilter
      *
      * @param string $visibility return only all/private/public bookmarks.
      *
-     * @return array filtered bookmarks.
+     * @return Bookmark[] filtered bookmarks.
      */
-    public function filterUntagged($visibility)
+    public function filterUntagged(string $visibility)
     {
         $filtered = [];
         foreach ($this->bookmarks as $key => $link) {
@@ -410,7 +436,7 @@ class BookmarkFilter
                 }
             }
 
-            if (empty(trim($link->getTagsString()))) {
+            if (empty($link->getTags())) {
                 $filtered[$key] = $link;
             }
         }
@@ -427,11 +453,11 @@ class BookmarkFilter
      * @param string $day day to filter.
      * @param string $visibility return only all/private/public bookmarks.
 
-     * @return array all link matching given day.
+     * @return Bookmark[] all link matching given day.
      *
      * @throws Exception if date format is invalid.
      */
-    public function filterDay($day, $visibility)
+    public function filterDay(string $day, string $visibility)
     {
         if (!checkDateFormat('Ymd', $day)) {
             throw new Exception('Invalid date format');
@@ -460,9 +486,9 @@ class BookmarkFilter
      * @param string $tags          string containing a list of tags.
      * @param bool   $casesensitive will convert everything to lowercase if false.
      *
-     * @return array filtered tags string.
+     * @return string[] filtered tags string.
      */
-    public static function tagsStrToArray($tags, $casesensitive)
+    public static function tagsStrToArray(string $tags, bool $casesensitive): array
     {
         // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
         $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
@@ -470,4 +496,75 @@ class BookmarkFilter
 
         return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
     }
+
+    /**
+     * This method finalize the content of the foundPositions array,
+     * by associated all search results to their associated bookmark field,
+     * making sure that there is no overlapping results, etc.
+     *
+     * @param array $fieldLengths   Start and end positions of every bookmark fields in the aggregated bookmark content.
+     * @param array $foundPositions Positions where the search results were found in the aggregated content.
+     *
+     * @return array Updated $foundPositions, by bookmark field.
+     */
+    protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
+    {
+        // Sort results by starting position ASC.
+        usort($foundPositions, function (array $entryA, array $entryB): int {
+            return $entryA['start'] > $entryB['start'] ? 1 : -1;
+        });
+
+        $out = [];
+        $currentMax = -1;
+        foreach ($foundPositions as $foundPosition) {
+            // we do not allow overlapping highlights
+            if ($foundPosition['start'] < $currentMax) {
+                continue;
+            }
+
+            $currentMax = $foundPosition['end'];
+            foreach ($fieldLengths as $part => $length) {
+                if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
+                    continue;
+                }
+
+                $out[$part][] = [
+                    'start' => $foundPosition['start'] - $length['start'],
+                    'end' => $foundPosition['end'] - $length['start'],
+                ];
+                break;
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
+     * Also populate $length array with starting and ending positions of every bookmark field
+     * inside concatenated content.
+     *
+     * @param Bookmark $link
+     * @param array    $lengths (by reference)
+     *
+     * @return string Lowercase concatenated fields content.
+     */
+    protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
+    {
+        $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
+        $content  = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
+        $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
+        $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
+        $content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
+
+        $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
+        $nextField = $lengths['title']['end'] + 1;
+        $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
+        $nextField = $lengths['description']['end'] + 1;
+        $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
+        $nextField = $lengths['url']['end'] + 1;
+        $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
+
+        return $content;
+    }
 }
index 6bf7f3654ebfdd41f87b9468f00c135c2725e9da..c78dbe41fe7dc8af6c3cd8a96ea2986dea45a914 100644 (file)
@@ -1,7 +1,11 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
+use malkusch\lock\mutex\Mutex;
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
 use Shaarli\Bookmark\Exception\EmptyDataStoreException;
 use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
@@ -27,11 +31,14 @@ class BookmarkIO
      */
     protected $conf;
 
+
+    /** @var Mutex */
+    protected $mutex;
+
     /**
      * string Datastore PHP prefix
      */
     protected static $phpPrefix = '<?php /* ';
-
     /**
      * string Datastore PHP suffix
      */
@@ -42,16 +49,21 @@ class BookmarkIO
      *
      * @param ConfigManager $conf instance
      */
-    public function __construct($conf)
+    public function __construct(ConfigManager $conf, Mutex $mutex = null)
     {
+        if ($mutex === null) {
+            // This should only happen with legacy classes
+            $mutex = new NoMutex();
+        }
         $this->conf = $conf;
         $this->datastore = $conf->get('resource.datastore');
+        $this->mutex = $mutex;
     }
 
     /**
      * Reads database from disk to memory
      *
-     * @return BookmarkArray instance
+     * @return Bookmark[]
      *
      * @throws NotWritableDataStoreException    Data couldn't be loaded
      * @throws EmptyDataStoreException          Datastore file exists but does not contain any bookmark
@@ -67,11 +79,16 @@ class BookmarkIO
             throw new NotWritableDataStoreException($this->datastore);
         }
 
+        $content = null;
+        $this->mutex->synchronized(function () use (&$content) {
+            $content = file_get_contents($this->datastore);
+        });
+
         // Note that gzinflate is faster than gzuncompress.
         // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
         $links = unserialize(gzinflate(base64_decode(
-            substr(file_get_contents($this->datastore),
-                strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
+            substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
+        )));
 
         if (empty($links)) {
             if (filesize($this->datastore) > 100) {
@@ -86,7 +103,7 @@ class BookmarkIO
     /**
      * Saves the database from memory to disk
      *
-     * @param BookmarkArray $links instance.
+     * @param Bookmark[] $links
      *
      * @throws NotWritableDataStoreException the datastore is not writable
      */
@@ -95,14 +112,18 @@ class BookmarkIO
         if (is_file($this->datastore) && !is_writeable($this->datastore)) {
             // The datastore exists but is not writeable
             throw new NotWritableDataStoreException($this->datastore);
-        } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
+        } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
             // The datastore does not exist and its parent directory is not writeable
             throw new NotWritableDataStoreException(dirname($this->datastore));
         }
 
-        file_put_contents(
-            $this->datastore,
-            self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
-        );
+        $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix;
+
+        $this->mutex->synchronized(function () use ($data) {
+            file_put_contents(
+                $this->datastore,
+                $data
+            );
+        });
     }
 }
index 815047e38010d5123cb5f27539e7d5ec55fb419d..8ab5c441a6eb163f2293f782965ca2ad7f0a3ccc 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 /**
@@ -11,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
@@ -23,7 +28,7 @@ class BookmarkInitializer
      *
      * @param BookmarkServiceInterface $bookmarkService
      */
-    public function __construct($bookmarkService)
+    public function __construct(BookmarkServiceInterface $bookmarkService)
     {
         $this->bookmarkService = $bookmarkService;
     }
@@ -31,13 +36,13 @@ class BookmarkInitializer
     /**
      * Initialize the data store with default bookmarks
      */
-    public function initialize()
+    public function initialize(): void
     {
         $bookmark = new Bookmark();
-        $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
-        $bookmark->setUrl('https://vimeo.com/153493904');
+        $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
+        $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
         $bookmark->setDescription(t(
-'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
+            'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
 
 Explore your new Shaarli instance by trying out controls and menus.
 Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
@@ -52,7 +57,7 @@ Now you can edit or delete the default shaares.
         $bookmark = new Bookmark();
         $bookmark->setTitle(t('Note: Shaare descriptions'));
         $bookmark->setDescription(t(
-'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
+            'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
 This note is private, so you are the only one able to see it while logged in.
 
 You can use this to keep notes, post articles, code snippets, and much more.
@@ -89,7 +94,7 @@ Markdown also supports tables:
             'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
         );
         $bookmark->setDescription(t(
-'Welcome to Shaarli!
+            'Welcome to Shaarli!
 
 Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
 You can add a description to your bookmarks, such as this one, and tag them.
index b9b483eb8ae14ae70582ec5589eb67fd9f066558..08cdbb4ed4055cc3f6ef2672b991f9c3b1cfeda7 100644 (file)
@@ -1,79 +1,73 @@
 <?php
 
-namespace Shaarli\Bookmark;
+declare(strict_types=1);
 
+namespace Shaarli\Bookmark;
 
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
-use Shaarli\Config\ConfigManager;
-use Shaarli\History;
 
 /**
  * Class BookmarksService
  *
  * This is the entry point to manipulate the bookmark DB.
+ *
+ * Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
+ * so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
  */
 interface BookmarkServiceInterface
 {
-    /**
-     * BookmarksService constructor.
-     *
-     * @param ConfigManager $conf       instance
-     * @param History       $history    instance
-     * @param bool          $isLoggedIn true if the current user is logged in
-     */
-    public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
-
     /**
      * Find a bookmark by hash
      *
-     * @param string $hash
+     * @param string      $hash       Bookmark's hash
+     * @param string|null $privateKey Optional key used to access private links while logged out
      *
-     * @return mixed
+     * @return Bookmark
      *
      * @throws \Exception
      */
-    public function findByHash($hash);
+    public function findByHash(string $hash, string $privateKey = null);
 
     /**
      * @param $url
      *
      * @return Bookmark|null
      */
-    public function findByUrl($url);
+    public function findByUrl(string $url): ?Bookmark;
 
     /**
      * Search bookmarks
      *
-     * @param mixed  $request
-     * @param string $visibility
-     * @param bool   $caseSensitive
-     * @param bool   $untaggedOnly
-     * @param bool   $ignoreSticky
+     * @param array   $request
+     * @param ?string $visibility
+     * @param bool    $caseSensitive
+     * @param bool    $untaggedOnly
+     * @param bool    $ignoreSticky
      *
      * @return Bookmark[]
      */
     public function search(
-        $request = [],
-        $visibility = null,
-        $caseSensitive = false,
-        $untaggedOnly = false,
+        array $request = [],
+        string $visibility = null,
+        bool $caseSensitive = false,
+        bool $untaggedOnly = false,
         bool $ignoreSticky = false
     );
 
     /**
      * Get a single bookmark by its ID.
      *
-     * @param int    $id         Bookmark ID
-     * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
-     *                           exception
+     * @param int    $id          Bookmark ID
+     * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+     *                            exception
      *
      * @return Bookmark
      *
      * @throws BookmarkNotFoundException
      * @throws \Exception
      */
-    public function get($id, $visibility = null);
+    public function get(int $id, string $visibility = null);
 
     /**
      * Updates an existing bookmark (depending on its ID).
@@ -86,7 +80,7 @@ interface BookmarkServiceInterface
      * @throws BookmarkNotFoundException
      * @throws \Exception
      */
-    public function set($bookmark, $save = true);
+    public function set(Bookmark $bookmark, bool $save = true): Bookmark;
 
     /**
      * Adds a new bookmark (the ID must be empty).
@@ -98,7 +92,7 @@ interface BookmarkServiceInterface
      *
      * @throws \Exception
      */
-    public function add($bookmark, $save = true);
+    public function add(Bookmark $bookmark, bool $save = true): Bookmark;
 
     /**
      * Adds or updates a bookmark depending on its ID:
@@ -112,7 +106,7 @@ interface BookmarkServiceInterface
      *
      * @throws \Exception
      */
-    public function addOrSet($bookmark, $save = true);
+    public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
 
     /**
      * Deletes a bookmark.
@@ -122,65 +116,72 @@ interface BookmarkServiceInterface
      *
      * @throws \Exception
      */
-    public function remove($bookmark, $save = true);
+    public function remove(Bookmark $bookmark, bool $save = true): void;
 
     /**
      * Get a single bookmark by its ID.
      *
-     * @param int    $id         Bookmark ID
-     * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
-     *                           exception
+     * @param int     $id         Bookmark ID
+     * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+     *                            exception
      *
      * @return bool
      */
-    public function exists($id, $visibility = null);
+    public function exists(int $id, string $visibility = null): bool;
 
     /**
      * Return the number of available bookmarks for given visibility.
      *
-     * @param string $visibility public|private|all
+     * @param ?string $visibility public|private|all
      *
      * @return int Number of bookmarks
      */
-    public function count($visibility = null);
+    public function count(string $visibility = null): int;
 
     /**
      * Write the datastore.
      *
      * @throws NotWritableDataStoreException
      */
-    public function save();
+    public function save(): void;
 
     /**
      * Returns the list tags appearing in the bookmarks with the given tags
      *
-     * @param array  $filteringTags tags selecting the bookmarks to consider
-     * @param string $visibility    process only all/private/public bookmarks
+     * @param array|null  $filteringTags tags selecting the bookmarks to consider
+     * @param string|null $visibility    process only all/private/public bookmarks
      *
      * @return array tag => bookmarksCount
      */
-    public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all');
+    public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
 
     /**
-     * Returns the list of days containing articles (oldest first)
+     * Return a list of bookmark matching provided period of time.
+     * It also update directly previous and next date outside of given period found in the datastore.
+     *
+     * @param \DateTimeInterface      $from     Starting date.
+     * @param \DateTimeInterface      $to       Ending date.
+     * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
+     * @param \DateTimeInterface|null $next     (by reference) updated with first created date found after $to.
      *
-     * @return array containing days (in format YYYYMMDD).
+     * @return array List of bookmarks matching provided period of time.
      */
-    public function days();
+    public function findByDate(
+        \DateTimeInterface $from,
+        \DateTimeInterface $to,
+        ?\DateTimeInterface &$previous,
+        ?\DateTimeInterface &$next
+    ): array;
 
     /**
-     * Returns the list of articles for a given day.
+     * Returns the latest bookmark by creation date.
      *
-     * @param string $request day to filter. Format: YYYYMMDD.
-     *
-     * @return Bookmark[] list of shaare found.
-     *
-     * @throws BookmarkNotFoundException
+     * @return Bookmark|null Found Bookmark or null if the datastore is empty.
      */
-    public function filterDay($request);
+    public function getLatest(): ?Bookmark;
 
     /**
      * Creates the default database after a fresh install.
      */
-    public function initialize();
+    public function initialize(): void;
 }
index e7af4d552b409b77595985cc635273a6a1d8e448..d65e97ed47ca9163bb8f0f9feef66969b1981166 100644 (file)
@@ -66,16 +66,19 @@ function html_extract_tag($tag, $html)
 {
     $propertiesKey = ['property', 'name', 'itemprop'];
     $properties = implode('|', $propertiesKey);
-    // Try to retrieve OpenGraph image.
-    $ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#';
+    // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
+    $orCondition  = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
+    // Try to retrieve OpenGraph tag.
+    $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*content=(["\'])([^\1]*?)\1.*?>#';
     // If the attributes are not in the order property => content (e.g. Github)
     // New regex to keep this readable... more or less.
-    $ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#';
+    $ogRegexReverse = '#<meta[^>]+content=(["\'])([^\1]*?)\1[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
 
-    if (preg_match($ogRegex, $html, $matches) > 0
+    if (
+        preg_match($ogRegex, $html, $matches) > 0
         || preg_match($ogRegexReverse, $html, $matches) > 0
     ) {
-        return $matches[1];
+        return $matches[2];
     }
 
     return false;
@@ -114,7 +117,7 @@ function hashtag_autolink($description, $indexUrl = '')
      * \p{Mn} - any non marking space (accents, umlauts, etc)
      */
     $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
-    $replacement = '$1<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);
 }
 
@@ -136,12 +139,17 @@ function space2nbsp($text)
  *
  * @param string $description shaare's description.
  * @param string $indexUrl    URL to Shaarli's index.
-
+ * @param bool   $autolink    Turn on/off automatic linkifications of URLs and hashtags
+ *
  * @return string formatted description.
  */
-function format_description($description, $indexUrl = '')
+function format_description($description, $indexUrl = '', $autolink = true)
 {
-    return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl)));
+    if ($autolink) {
+        $description = hashtag_autolink(text2clickable($description), $indexUrl);
+    }
+
+    return nl2br(space2nbsp($description));
 }
 
 /**
@@ -169,3 +177,49 @@ function is_note($linkUrl)
 {
     return isset($linkUrl[0]) && $linkUrl[0] === '?';
 }
+
+/**
+ * Extract an array of tags from a given tag string, with provided separator.
+ *
+ * @param string|null $tags      String containing a list of tags separated by $separator.
+ * @param string      $separator Shaarli's default: ' ' (whitespace)
+ *
+ * @return array List of tags
+ */
+function tags_str2array(?string $tags, string $separator): array
+{
+    // For whitespaces, we use the special \s regex character
+    $separator = $separator === ' ' ? '\s' : $separator;
+
+    return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
+}
+
+/**
+ * Return a tag string with provided separator from a list of tags.
+ * Note that given array is clean up by tags_filter().
+ *
+ * @param array|null $tags      List of tags
+ * @param string     $separator
+ *
+ * @return string
+ */
+function tags_array2str(?array $tags, string $separator): string
+{
+    return implode($separator, tags_filter($tags, $separator));
+}
+
+/**
+ * Clean an array of tags: trim + remove empty entries
+ *
+ * @param array|null $tags List of tags
+ * @param string     $separator
+ *
+ * @return array
+ */
+function tags_filter(?array $tags, string $separator): array
+{
+    $trimDefault = " \t\n\r\0\x0B";
+    return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
+        return trim($entry, $trimDefault . $separator);
+    }, $tags ?? [])));
+}
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 55bb51b5b46506f95b2d79280796c694cc1e9fc9..f0234eca2f92a4bcca74ea99b915c18d4d2e64f4 100644 (file)
@@ -4,6 +4,8 @@ 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;
@@ -13,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;
@@ -47,6 +50,9 @@ class ContainerBuilder
     /** @var LoginManager */
     protected $login;
 
+    /** @var LoggerInterface */
+    protected $logger;
+
     /** @var string|null */
     protected $basePath = null;
 
@@ -54,12 +60,14 @@ class ContainerBuilder
         ConfigManager $conf,
         SessionManager $session,
         CookieManager $cookieManager,
-        LoginManager $login
+        LoginManager $login,
+        LoggerInterface $logger
     ) {
         $this->conf = $conf;
         $this->session = $session;
         $this->login = $login;
         $this->cookieManager = $cookieManager;
+        $this->logger = $logger;
     }
 
     public function build(): ShaarliContainer
@@ -70,6 +78,7 @@ class ContainerBuilder
         $container['sessionManager'] = $this->session;
         $container['cookieManager'] = $this->cookieManager;
         $container['loginManager'] = $this->login;
+        $container['logger'] = $this->logger;
         $container['basePath'] = $this->basePath;
 
         $container['plugins'] = function (ShaarliContainer $container): PluginManager {
@@ -84,14 +93,20 @@ class ContainerBuilder
             return new BookmarkFileService(
                 $container->conf,
                 $container->history,
+                new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
                 $container->loginManager->isLoggedIn()
             );
         };
 
+        $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
+            return new MetadataRetriever($container->conf, $container->httpAccess);
+        };
+
         $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
             return new PageBuilder(
                 $container->conf,
                 $container->sessionManager->getSession(),
+                $container->logger,
                 $container->bookmarkService,
                 $container->sessionManager->generateToken(),
                 $container->loginManager->isLoggedIn()
@@ -143,7 +158,7 @@ class ContainerBuilder
 
         $container['updater'] = function (ShaarliContainer $container): Updater {
             return new Updater(
-                UpdaterUtils::read_updates_file($container->conf->get('resource.updates')),
+                UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
                 $container->bookmarkService,
                 $container->conf,
                 $container->loginManager->isLoggedIn()
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 f6def6308849eb6401fd908a4d063046f163dc11..ed62af26e1c4b26ae4eb481e37ab32f0a5d9988a 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Feed;
 
 use DateTime;
@@ -102,19 +103,19 @@ class FeedBuilder
         }
 
         // Optionally filter the results:
-        $linksToDisplay = $this->linkDB->search($userInput, null, false, false, true);
+        $linksToDisplay = $this->linkDB->search($userInput ?? [], null, false, false, true);
 
         $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
 
         // Can't use array_keys() because $link is a LinkDB instance and not a real array.
-        $keys = array();
+        $keys = [];
         foreach ($linksToDisplay as $key => $value) {
             $keys[] = $key;
         }
 
         $pageaddr = escape(index_url($this->serverInfo));
         $this->formatter->addContextData('index_url', $pageaddr);
-        $linkDisplayed = array();
+        $linkDisplayed = [];
         for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
             $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
         }
@@ -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 9d4a0fa0235c591be9a29d7ad3714d15b97c1e49..7e0afafc8a413f375e14992181b03c2c294e2d47 100644 (file)
@@ -12,10 +12,13 @@ namespace Shaarli\Formatter;
  */
 class BookmarkDefaultFormatter extends BookmarkFormatter
 {
+    protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
+    protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
+
     /**
      * @inheritdoc
      */
-    public function formatTitle($bookmark)
+    protected function formatTitle($bookmark)
     {
         return escape($bookmark->getTitle());
     }
@@ -23,10 +26,33 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
     /**
      * @inheritdoc
      */
-    public function formatDescription($bookmark)
+    protected function formatTitleHtml($bookmark)
+    {
+        $title = $this->tokenizeSearchHighlightField(
+            $bookmark->getTitle() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
+        );
+
+        return $this->replaceTokens(escape($title));
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function formatDescription($bookmark)
     {
         $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
-        return format_description(escape($bookmark->getDescription()), $indexUrl);
+        $description = $this->tokenizeSearchHighlightField(
+            $bookmark->getDescription() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
+        );
+        $description = format_description(
+            escape($description),
+            $indexUrl,
+            $this->conf->get('formatter_settings.autolink', true)
+        );
+
+        return $this->replaceTokens($description);
     }
 
     /**
@@ -40,15 +66,36 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
     /**
      * @inheritdoc
      */
-    public function formatTagString($bookmark)
+    protected function formatTagListHtml($bookmark)
     {
-        return implode(' ', $this->formatTagList($bookmark));
+        $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
+        if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
+            return $this->formatTagList($bookmark);
+        }
+
+        $tags = $this->tokenizeSearchHighlightField(
+            $bookmark->getTagsString($tagsSeparator),
+            $bookmark->getAdditionalContentEntry('search_highlight')['tags']
+        );
+        $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
+        $tags = escape($tags);
+        $tags = $this->replaceTokensArray($tags);
+
+        return $tags;
     }
 
     /**
      * @inheritdoc
      */
-    public function formatUrl($bookmark)
+    protected function formatTagString($bookmark)
+    {
+        return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function formatUrl($bookmark)
     {
         if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
             return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
@@ -77,6 +124,19 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
         return escape($bookmark->getUrl());
     }
 
+    /**
+     * @inheritdoc
+     */
+    protected function formatUrlHtml($bookmark)
+    {
+        $url = $this->tokenizeSearchHighlightField(
+            $bookmark->getUrl() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
+        );
+
+        return $this->replaceTokens(escape($url));
+    }
+
     /**
      * @inheritdoc
      */
@@ -84,4 +144,72 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
     {
         return escape($bookmark->getThumbnail());
     }
+
+    /**
+     * Insert search highlight token in provided field content based on a list of search result positions
+     *
+     * @param string     $fieldContent
+     * @param array|null $positions    List of of search results with 'start' and 'end' positions.
+     *
+     * @return string Updated $fieldContent.
+     */
+    protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
+    {
+        if (empty($positions)) {
+            return $fieldContent;
+        }
+
+        $insertedTokens = 0;
+        $tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
+        foreach ($positions as $position) {
+            $position = [
+                'start' => $position['start'] + ($insertedTokens * $tokenLength),
+                'end' => $position['end'] + ($insertedTokens * $tokenLength),
+            ];
+
+            $content = mb_substr($fieldContent, 0, $position['start']);
+            $content .= static::SEARCH_HIGHLIGHT_OPEN;
+            $content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
+            $content .= static::SEARCH_HIGHLIGHT_CLOSE;
+            $content .= mb_substr($fieldContent, $position['end']);
+
+            $fieldContent = $content;
+
+            $insertedTokens += 2;
+        }
+
+        return $fieldContent;
+    }
+
+    /**
+     * Replace search highlight tokens with HTML highlighted span.
+     *
+     * @param string $fieldContent
+     *
+     * @return string updated content.
+     */
+    protected function replaceTokens(string $fieldContent): string
+    {
+        return str_replace(
+            [static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
+            ['<span class="search-highlight">', '</span>'],
+            $fieldContent
+        );
+    }
+
+    /**
+     * Apply replaceTokens to an array of content strings.
+     *
+     * @param string[] $fieldContents
+     *
+     * @return array
+     */
+    protected function replaceTokensArray(array $fieldContents): array
+    {
+        foreach ($fieldContents as &$entry) {
+            $entry = $this->replaceTokens($entry);
+        }
+
+        return $fieldContents;
+    }
 }
index 0042dafe402958905b892cdb2617e767dfdb8d11..124ce78bdc8224e07e034de60061a665fa886633 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Shaarli\Formatter;
 
-use DateTime;
+use DateTimeInterface;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
 
@@ -11,6 +11,29 @@ use Shaarli\Config\ConfigManager;
  *
  * Abstract class processing all bookmark attributes through methods designed to be overridden.
  *
+ * List of available formatted fields:
+ *   - id                 ID
+ *   - shorturl           Unique identifier, used in permalinks
+ *   - url                URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
+ *   - real_url           (legacy) same as `url`
+ *   - url_html           URL to be displayed in HTML content (it can contain HTML tags)
+ *   - title              Title
+ *   - title_html         Title to be displayed in HTML content (it can contain HTML tags)
+ *   - description        Description content. It most likely contains HTML tags
+ *   - thumbnail          Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
+ *   - taglist            List of tags (array)
+ *   - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
+ *   - taglist_html       List of tags (array) to be displayed in HTML content (it can contain HTML tags)
+ *   - tags               Tags separated by a single whitespace
+ *   - tags_urlencoded    Tags separated by a single whitespace, URL encoded: must be used to create a link
+ *   - sticky             Is sticky (bool)
+ *   - private            Is private (bool)
+ *   - class              Additional CSS class
+ *   - created            Creation DateTime
+ *   - updated            Last edit DateTime
+ *   - timestamp          Creation timestamp
+ *   - updated_timestamp  Last edit timestamp
+ *
  * @package Shaarli\Formatter
  */
 abstract class BookmarkFormatter
@@ -55,13 +78,16 @@ abstract class BookmarkFormatter
         $out['shorturl'] = $this->formatShortUrl($bookmark);
         $out['url'] = $this->formatUrl($bookmark);
         $out['real_url'] = $this->formatRealUrl($bookmark);
+        $out['url_html'] = $this->formatUrlHtml($bookmark);
         $out['title'] = $this->formatTitle($bookmark);
+        $out['title_html'] = $this->formatTitleHtml($bookmark);
         $out['description'] = $this->formatDescription($bookmark);
         $out['thumbnail'] = $this->formatThumbnail($bookmark);
-        $out['urlencoded_taglist'] = $this->formatUrlEncodedTagList($bookmark);
         $out['taglist'] = $this->formatTagList($bookmark);
-        $out['urlencoded_tags'] = $this->formatUrlEncodedTagString($bookmark);
+        $out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
+        $out['taglist_html'] = $this->formatTagListHtml($bookmark);
         $out['tags'] = $this->formatTagString($bookmark);
+        $out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
         $out['sticky'] = $bookmark->isSticky();
         $out['private'] = $bookmark->isPrivate();
         $out['class'] = $this->formatClass($bookmark);
@@ -69,6 +95,7 @@ abstract class BookmarkFormatter
         $out['updated'] = $this->formatUpdated($bookmark);
         $out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
         $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
+
         return $out;
     }
 
@@ -135,6 +162,18 @@ abstract class BookmarkFormatter
         return $this->formatUrl($bookmark);
     }
 
+    /**
+     * Format Url Html: to be displayed in HTML content, it can contains HTML tags.
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return string formatted Url HTML
+     */
+    protected function formatUrlHtml($bookmark)
+    {
+        return $this->formatUrl($bookmark);
+    }
+
     /**
      * Format Title
      *
@@ -147,6 +186,18 @@ abstract class BookmarkFormatter
         return $bookmark->getTitle();
     }
 
+    /**
+     * Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return string formatted Title
+     */
+    protected function formatTitleHtml($bookmark)
+    {
+        return $bookmark->getTitle();
+    }
+
     /**
      * Format Description
      *
@@ -190,11 +241,23 @@ abstract class BookmarkFormatter
      *
      * @return array formatted Tags
      */
-    protected function formatUrlEncodedTagList($bookmark)
+    protected function formatTagListUrlEncoded($bookmark)
     {
         return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
     }
 
+    /**
+     * Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return array formatted Tags
+     */
+    protected function formatTagListHtml($bookmark)
+    {
+        return $this->formatTagList($bookmark);
+    }
+
     /**
      * Format TagString
      *
@@ -204,7 +267,7 @@ abstract class BookmarkFormatter
      */
     protected function formatTagString($bookmark)
     {
-        return implode(' ', $this->formatTagList($bookmark));
+        return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
     }
 
     /**
@@ -214,9 +277,9 @@ abstract class BookmarkFormatter
      *
      * @return string formatted TagString
      */
-    protected function formatUrlEncodedTagString($bookmark)
+    protected function formatTagStringUrlEncoded($bookmark)
     {
-        return implode(' ', $this->formatUrlEncodedTagList($bookmark));
+        return implode(' ', $this->formatTagListUrlEncoded($bookmark));
     }
 
     /**
@@ -237,7 +300,7 @@ abstract class BookmarkFormatter
      *
      * @param Bookmark $bookmark instance
      *
-     * @return DateTime instance
+     * @return DateTimeInterface instance
      */
     protected function formatCreated(Bookmark $bookmark)
     {
@@ -249,7 +312,7 @@ abstract class BookmarkFormatter
      *
      * @param Bookmark $bookmark instance
      *
-     * @return DateTime instance
+     * @return DateTimeInterface instance
      */
     protected function formatUpdated(Bookmark $bookmark)
     {
@@ -288,6 +351,7 @@ abstract class BookmarkFormatter
 
     /**
      * Format tag list, e.g. remove private tags if the user is not logged in.
+     * TODO: this method is called multiple time to format tags, the result should be cached.
      *
      * @param array $tags
      *
diff --git a/application/formatter/BookmarkMarkdownExtraFormatter.php b/application/formatter/BookmarkMarkdownExtraFormatter.php
new file mode 100644 (file)
index 0000000..0694b23
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+namespace Shaarli\Formatter;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkMarkdownExtraFormatter
+ *
+ * Format bookmark description into MarkdownExtra format.
+ *
+ * @see https://michelf.ca/projects/php-markdown/extra/
+ *
+ * @package Shaarli\Formatter
+ */
+class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
+{
+    public function __construct(ConfigManager $conf, bool $isLoggedIn)
+    {
+        parent::__construct($conf, $isLoggedIn);
+
+        $this->parsedown = new \ParsedownExtra();
+    }
+}
index 5d244d4c92de249721f0c1c6e18ab79ba2222752..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;
@@ -56,7 +56,10 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
             return parent::formatDescription($bookmark);
         }
 
-        $processedDescription = $bookmark->getDescription();
+        $processedDescription = $this->tokenizeSearchHighlightField(
+            $bookmark->getDescription() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
+        );
         $processedDescription = $this->filterProtocols($processedDescription);
         $processedDescription = $this->formatHashTags($processedDescription);
         $processedDescription = $this->reverseEscapedHtml($processedDescription);
@@ -65,9 +68,10 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
             ->setBreaksEnabled(true)
             ->text($processedDescription);
         $processedDescription = $this->sanitizeHtml($processedDescription);
+        $processedDescription = $this->replaceTokens($processedDescription);
 
         if (!empty($processedDescription)) {
-            $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
+            $processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
         }
 
         return $processedDescription;
@@ -106,7 +110,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
             function ($match) use ($allowedProtocols, $indexUrl) {
                 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
                 $link .= whitelist_protocols($match[1], $allowedProtocols);
-                return ']('. $link.')';
+                return '](' . $link . ')';
             },
             $description
         );
@@ -133,7 +137,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
          * \p{Mn} - any non marking space (accents, umlauts, etc)
          */
         $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
-        $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
+        $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)';
 
         $descriptionLines = explode(PHP_EOL, $description);
         $descriptionOut = '';
@@ -174,17 +178,17 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
      */
     protected function sanitizeHtml($description)
     {
-        $escapeTags = array(
+        $escapeTags = [
             'script',
             'style',
             'link',
             'iframe',
             'frameset',
             'frame',
-        );
+        ];
         foreach ($escapeTags as $tag) {
             $description = preg_replace_callback(
-                '#<\s*'. $tag .'[^>]*>(.*</\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 e675fccabad0eacf408acc0901c113d0e777a5c9..dc421661c89ea0e2470a768284513a6267210a02 100644 (file)
@@ -30,7 +30,7 @@ class ConfigureController extends ShaarliAdminController
             'theme_available',
             ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
         );
-        $this->assignView('formatter_available', ['default', 'markdown']);
+        $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']);
         list($continents, $cities) = generateTimeZoneData(
             timezone_identifiers_list(),
             $this->container->conf->get('general.timezone')
@@ -51,7 +51,10 @@ class ConfigureController extends ShaarliAdminController
         $this->assignView('languages', Languages::getAvailableLanguages());
         $this->assignView('gd_enabled', extension_loaded('gd'));
         $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
-        $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+        $this->assignView(
+            'pagetitle',
+            t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
+        );
 
         return $response->write($this->render(TemplatePage::CONFIGURE));
     }
@@ -95,12 +98,15 @@ class ConfigureController extends ShaarliAdminController
         }
 
         $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
-        if ($thumbnailsMode !== Thumbnailer::MODE_NONE
+        if (
+            $thumbnailsMode !== Thumbnailer::MODE_NONE
             && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
         ) {
             $this->saveWarningMessage(
                 t('You have enabled or changed thumbnails mode.') .
-                '<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..fabeaf2
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Helper\ApplicationUtils;
+use Shaarli\Helper\FileUtils;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Slim controller used to handle Server administration page, and actions.
+ */
+class ServerController extends ShaarliAdminController
+{
+    /** @var string Cache type - main - by default pagecache/ and tmp/ */
+    protected const CACHE_MAIN = 'main';
+
+    /** @var string Cache type - thumbnails - by default cache/ */
+    protected const CACHE_THUMB = 'thumbnails';
+
+    /**
+     * GET /admin/server - Display page Server administration
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
+        if ($this->container->conf->get('updates.check_updates', true)) {
+            $latestVersion = 'v' . ApplicationUtils::getVersion(
+                ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
+            );
+            $releaseUrl .= 'tag/' . $latestVersion;
+        } else {
+            $latestVersion = t('Check disabled');
+        }
+
+        $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
+        $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
+        $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
+
+        $this->assignView('php_version', PHP_VERSION);
+        $this->assignView('php_eol', format_date($phpEol, false));
+        $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
+        $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
+        $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
+        $this->assignView('release_url', $releaseUrl);
+        $this->assignView('latest_version', $latestVersion);
+        $this->assignView('current_version', $currentVersion);
+        $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
+        $this->assignView('index_url', index_url($this->container->environment));
+        $this->assignView('client_ip', client_ip_id($this->container->environment));
+        $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
+
+        $this->assignView(
+            'pagetitle',
+            t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render('server'));
+    }
+
+    /**
+     * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
+     */
+    public function clearCache(Request $request, Response $response): Response
+    {
+        $exclude = ['.htaccess'];
+
+        if ($request->getQueryParam('type') === static::CACHE_THUMB) {
+            $folders = [$this->container->conf->get('resource.thumbnails_cache')];
+
+            $this->saveWarningMessage(
+                t('Thumbnails cache has been cleared.') . ' ' .
+                '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
+                    t('Please synchronize them.') .
+                '</a>'
+            );
+        } else {
+            $folders = [
+                $this->container->conf->get('resource.page_cache'),
+                $this->container->conf->get('resource.raintpl_tmp'),
+            ];
+
+            $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
+        }
+
+        // Make sure that we don't delete root cache folder
+        $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
+        foreach ($folders as $folder) {
+            FileUtils::clearFolder($folder, false, $exclude);
+        }
+
+        return $this->redirect($response, '/admin/server');
+    }
+}
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..4cbfcdc
--- /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'] = 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 81c87ed0369a4651abeeaa51ec7294159895decc..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));
@@ -52,7 +52,7 @@ class ThumbnailsController extends ShaarliAdminController
         }
 
         try {
-            $bookmark = $this->container->bookmarkService->get($id);
+            $bookmark = $this->container->bookmarkService->get((int) $id);
         } catch (BookmarkNotFoundException $e) {
             return $response->withStatus(404);
         }
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..846cfe22a81c310469391e63e84dc549991792c3 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));
@@ -106,11 +96,14 @@ class DailyController extends ShaarliVisitorController
         }
 
         $days = [];
+        $type = DailyPageHelper::extractRequestedType($request);
+        $format = DailyPageHelper::getFormatByType($type);
+        $length = DailyPageHelper::getRssLengthByType($type);
         foreach ($this->container->bookmarkService->search() as $bookmark) {
-            $day = $bookmark->getCreated()->format('Ymd');
+            $day = $bookmark->getCreated()->format($format);
 
             // Stop iterating after DAILY_RSS_NB_DAYS entries
-            if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) {
+            if (count($days) === $length && !isset($days[$day])) {
                 break;
             }
 
@@ -127,12 +120,19 @@ class DailyController extends ShaarliVisitorController
 
         /** @var Bookmark[] $bookmarks */
         foreach ($days as $day => $bookmarks) {
-            $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
+            $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
+            $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
+
+            // We only want the RSS entry to be published when the period is over.
+            if (new DateTime() < $endDateTime) {
+                continue;
+            }
+
             $dataPerDay[$day] = [
-                'date' => $dayDatetime,
-                'date_rss' => $dayDatetime->format(DateTime::RSS),
-                'date_human' => format_date($dayDatetime, false, true),
-                'absolute_url' => $indexUrl . 'daily?day=' . $day,
+                'date' => $endDateTime,
+                'date_rss' => $endDateTime->format(DateTime::RSS),
+                'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime),
+                'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
                 'links' => [],
             ];
 
@@ -141,16 +141,20 @@ class DailyController extends ShaarliVisitorController
 
                 // Make permalink URL absolute
                 if ($bookmark->isNote()) {
-                    $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl();
+                    $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
                 }
             }
         }
 
-        $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
-        $this->assignView('index_url', $indexUrl);
-        $this->assignView('page_url', $pageUrl);
-        $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false));
-        $this->assignView('days', $dataPerDay);
+        $this->assignAllView([
+            'title' => $this->container->conf->get('general.title', 'Shaarli'),
+            'index_url' => $indexUrl,
+            'page_url' => $pageUrl,
+            'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
+            'days' => $dataPerDay,
+            'type' => $type,
+            'localizedType' => $this->translateType($type),
+        ]);
 
         $rssContent = $this->render(TemplatePage::DAILY_RSS);
 
@@ -189,4 +193,13 @@ class DailyController extends ShaarliVisitorController
 
         return $columns;
     }
+
+    protected function translateType($type): string
+    {
+        return [
+            t('day') => t('Daily'),
+            t('week') => t('Weekly'),
+            t('month') => t('Monthly'),
+        ][t($type)] ?? t('Daily');
+    }
 }
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..bf96592949370026d888ae96f3e155dad483a8b3 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,16 @@ class InstallController extends ShaarliVisitorController
         $this->assignView('cities', $cities);
         $this->assignView('languages', Languages::getAvailableLanguages());
 
+        $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
+
+        $this->assignView('php_version', PHP_VERSION);
+        $this->assignView('php_eol', format_date($phpEol, false));
+        $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
+        $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
+        $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
+
+        $this->assignView('pagetitle', t('Install Shaarli'));
+
         return $response->write($this->render('install'));
     }
 
@@ -65,17 +76,18 @@ class InstallController extends ShaarliVisitorController
         // This part makes sure sessions works correctly.
         // (Because on some hosts, session.save_path may not be set correctly,
         // or we may not have write access to it.)
-        if (static::SESSION_TEST_VALUE
+        if (
+            static::SESSION_TEST_VALUE
             !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
         ) {
             // Step 2: Check if data in session is correct.
             $msg = t(
-                '<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 +106,8 @@ class InstallController extends ShaarliVisitorController
     public function save(Request $request, Response $response): Response
     {
         $timezone = 'UTC';
-        if (!empty($request->getParam('continent'))
+        if (
+            !empty($request->getParam('continent'))
             && !empty($request->getParam('city'))
             && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
         ) {
@@ -104,7 +117,7 @@ class InstallController extends ShaarliVisitorController
 
         $login = $request->getParam('setlogin');
         $this->container->conf->set('credentials.login', $login);
-        $salt = sha1(uniqid('', true) .'_'. mt_rand());
+        $salt = sha1(uniqid('', true) . '_' . mt_rand());
         $this->container->conf->set('credentials.salt', $salt);
         $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
 
@@ -113,7 +126,7 @@ class InstallController extends ShaarliVisitorController
         } else {
             $this->container->conf->set(
                 'general.title',
-                'Shared bookmarks on '.escape(index_url($this->container->environment))
+                'Shared bookmarks on ' . escape(index_url($this->container->environment))
             );
         }
 
@@ -150,7 +163,7 @@ class InstallController extends ShaarliVisitorController
     protected function checkPermissions(): bool
     {
         // Ensure Shaarli has proper access to its resources
-        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf);
+        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
         if (empty($errors)) {
             return true;
         }
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 55c075a2a87f7ae8c7cad95c5134bf78c53e3cf5..ae946c592240bcee977fce824b3073d368064ee6 100644 (file)
@@ -106,6 +106,7 @@ abstract class ShaarliVisitorController
             'target' => $template,
             'loggedin' => $this->container->loginManager->isLoggedIn(),
             'basePath' => $this->container->basePath,
+            'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath),
             'bookmarkService' => $this->container->bookmarkService
         ];
     }
@@ -143,7 +144,8 @@ abstract class ShaarliVisitorController
         if (null !== $referer) {
             $currentUrl = parse_url($referer);
             // If the referer is not related to Shaarli instance, redirect to default
-            if (isset($currentUrl['host'])
+            if (
+                isset($currentUrl['host'])
                 && strpos(index_url($this->container->environment), $currentUrl['host']) === false
             ) {
                 return $response->withRedirect($defaultPath);
@@ -172,7 +174,7 @@ abstract class ShaarliVisitorController
             }
         }
 
-        $queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
+        $queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
         $anchor = $anchor ? '#' . $anchor : '';
 
         return $response->withRedirect($path . $queryString . $anchor);
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 64%
rename from application/ApplicationUtils.php
rename to application/helper/ApplicationUtils.php
index 3aa218295c634e3d0d02b3e5e9fa00ff534d7804..212dd8e2dc7578aa6da6a3f8d403fff319ae3f42 100644 (file)
@@ -1,5 +1,6 @@
 <?php
-namespace Shaarli;
+
+namespace Shaarli\Helper;
 
 use Exception;
 use Shaarli\Config\ConfigManager;
@@ -14,8 +15,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 +65,8 @@ class ApplicationUtils
         }
 
         return str_replace(
-            array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL),
-            array('', '', ''),
+            [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
+            ['', '', ''],
             $data
         );
     }
@@ -125,7 +127,7 @@ class ApplicationUtils
         // Late Static Binding allows overriding within tests
         // See http://php.net/manual/en/language.oop5.late-static-bindings.php
         $latestVersion = static::getVersion(
-            self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
+            self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
         );
 
         if (!$latestVersion) {
@@ -171,35 +173,47 @@ class ApplicationUtils
     /**
      * Checks Shaarli has the proper access permissions to its resources
      *
-     * @param ConfigManager $conf Configuration Manager instance.
+     * @param ConfigManager $conf        Configuration Manager instance.
+     * @param bool          $minimalMode In minimal mode we only check permissions to be able to display a template.
+     *                                   Currently we only need to be able to read the theme and write in raintpl cache.
      *
      * @return array A list of the detected configuration issues
      */
-    public static function checkResourcePermissions($conf)
+    public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
     {
-        $errors = array();
+        $errors = [];
         $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
 
         // Check script and template directories are readable
-        foreach (array(
-                     'application',
-                     'inc',
-                     'plugins',
-                     $rainTplDir,
-                     $rainTplDir . '/' . $conf->get('resource.theme'),
-                 ) as $path) {
+        foreach (
+            [
+            'application',
+            'inc',
+            'plugins',
+            $rainTplDir,
+            $rainTplDir . '/' . $conf->get('resource.theme'),
+            ] as $path
+        ) {
             if (!is_readable(realpath($path))) {
                 $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
         }
 
         // Check cache and data directories are readable and writable
-        foreach (array(
-                     $conf->get('resource.thumbnails_cache'),
-                     $conf->get('resource.data_dir'),
-                     $conf->get('resource.page_cache'),
-                     $conf->get('resource.raintpl_tmp'),
-                 ) as $path) {
+        if ($minimalMode) {
+            $folders = [
+                $conf->get('resource.raintpl_tmp'),
+            ];
+        } else {
+            $folders = [
+            $conf->get('resource.thumbnails_cache'),
+            $conf->get('resource.data_dir'),
+            $conf->get('resource.page_cache'),
+            $conf->get('resource.raintpl_tmp'),
+            ];
+        }
+
+        foreach ($folders as $path) {
             if (!is_readable(realpath($path))) {
                 $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
@@ -208,14 +222,20 @@ class ApplicationUtils
             }
         }
 
+        if ($minimalMode) {
+            return $errors;
+        }
+
         // Check configuration files are readable and writable
-        foreach (array(
-                     $conf->getConfigFileExt(),
-                     $conf->get('resource.datastore'),
-                     $conf->get('resource.ban_file'),
-                     $conf->get('resource.log'),
-                     $conf->get('resource.update_check'),
-                 ) as $path) {
+        foreach (
+            [
+                 $conf->getConfigFileExt(),
+                 $conf->get('resource.datastore'),
+                 $conf->get('resource.ban_file'),
+                 $conf->get('resource.log'),
+                 $conf->get('resource.update_check'),
+             ] as $path
+        ) {
             if (!is_file(realpath($path))) {
                 # the file may not exist yet
                 continue;
@@ -246,4 +266,54 @@ class ApplicationUtils
     {
         return hash_hmac('sha256', $currentVersion, $salt);
     }
+
+    /**
+     * Get a list of PHP extensions used by Shaarli.
+     *
+     * @return array[] List of extension with following keys:
+     *                   - name: extension name
+     *                   - required: whether the extension is required to use Shaarli
+     *                   - desc: short description of extension usage in Shaarli
+     *                   - loaded: whether the extension is properly loaded or not
+     */
+    public static function getPhpExtensionsRequirement(): array
+    {
+        $extensions = [
+            ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
+            ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
+            ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
+            ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
+            ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
+            ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
+            ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
+            ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
+        ];
+
+        foreach ($extensions as &$extension) {
+            $extension['loaded'] = extension_loaded($extension['name']);
+        }
+
+        return $extensions;
+    }
+
+    /**
+     * Return the EOL date of given PHP version. If the version is unknown,
+     * we return today + 2 years.
+     *
+     * @param string $fullVersion PHP version, e.g. 7.4.7
+     *
+     * @return string Date format: YYYY-MM-DD
+     */
+    public static function getPhpEol(string $fullVersion): string
+    {
+        preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
+
+        return [
+            '7.1' => '2019-12-01',
+            '7.2' => '2020-11-30',
+            '7.3' => '2021-12-06',
+            '7.4' => '2022-11-28',
+            '8.0' => '2023-12-01',
+        ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
+    }
 }
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php
new file mode 100644 (file)
index 0000000..5fabc90
--- /dev/null
@@ -0,0 +1,208 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Helper;
+
+use Shaarli\Bookmark\Bookmark;
+use Slim\Http\Request;
+
+class DailyPageHelper
+{
+    public const MONTH = 'month';
+    public const WEEK = 'week';
+    public const DAY = 'day';
+
+    /**
+     * Extracts the type of the daily to display from the HTTP request parameters
+     *
+     * @param Request $request HTTP request
+     *
+     * @return string month/week/day
+     */
+    public static function extractRequestedType(Request $request): string
+    {
+        if ($request->getQueryParam(static::MONTH) !== null) {
+            return static::MONTH;
+        } elseif ($request->getQueryParam(static::WEEK) !== null) {
+            return static::WEEK;
+        }
+
+        return static::DAY;
+    }
+
+    /**
+     * Extracts a DateTimeImmutable from provided HTTP request.
+     * If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
+     * If the datastore is empty or no bookmark is provided, we use the current date.
+     *
+     * @param string        $type           month/week/day
+     * @param string|null   $requestedDate  Input string extracted from the request
+     * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
+     *
+     * @return \DateTimeImmutable from input or latest bookmark.
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function extractRequestedDateTime(
+        string $type,
+        ?string $requestedDate,
+        Bookmark $latestBookmark = null
+    ): \DateTimeImmutable {
+        $format = static::getFormatByType($type);
+        if (empty($requestedDate)) {
+            return $latestBookmark instanceof Bookmark
+                ? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
+                : new \DateTimeImmutable()
+            ;
+        }
+
+        // W is not supported by createFromFormat...
+        if ($type === static::WEEK) {
+            return (new \DateTimeImmutable())
+                ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
+            ;
+        }
+
+        return \DateTimeImmutable::createFromFormat($format, $requestedDate);
+    }
+
+    /**
+     * Get the DateTime format used by provided type
+     * Examples:
+     *   - day: 20201016 (<year><month><day>)
+     *   - week: 202041 (<year><week number>)
+     *   - month: 202010 (<year><month>)
+     *
+     * @param string $type month/week/day
+     *
+     * @return string DateTime compatible format
+     *
+     * @see https://www.php.net/manual/en/datetime.format.php
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getFormatByType(string $type): string
+    {
+        switch ($type) {
+            case static::MONTH:
+                return 'Ym';
+            case static::WEEK:
+                return 'YW';
+            case static::DAY:
+                return 'Ymd';
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get the first DateTime of the time period depending on given datetime and type.
+     * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
+     *       and we don't want to alter original datetime.
+     *
+     * @param string             $type      month/week/day
+     * @param \DateTimeImmutable $requested DateTime extracted from request input
+     *                                      (should come from extractRequestedDateTime)
+     *
+     * @return \DateTimeInterface First DateTime of the time period
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
+    {
+        switch ($type) {
+            case static::MONTH:
+                return $requested->modify('first day of this month midnight');
+            case static::WEEK:
+                return $requested->modify('Monday this week midnight');
+            case static::DAY:
+                return $requested->modify('Today midnight');
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get the last DateTime of the time period depending on given datetime and type.
+     * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
+     *       and we don't want to alter original datetime.
+     *
+     * @param string             $type      month/week/day
+     * @param \DateTimeImmutable $requested DateTime extracted from request input
+     *                                      (should come from extractRequestedDateTime)
+     *
+     * @return \DateTimeInterface Last DateTime of the time period
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
+    {
+        switch ($type) {
+            case static::MONTH:
+                return $requested->modify('last day of this month 23:59:59');
+            case static::WEEK:
+                return $requested->modify('Sunday this week 23:59:59');
+            case static::DAY:
+                return $requested->modify('Today 23:59:59');
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get localized description of the time period depending on given datetime and type.
+     * Example: for a month period, it returns `October, 2020`.
+     *
+     * @param string             $type      month/week/day
+     * @param \DateTimeImmutable $requested DateTime extracted from request input
+     *                                      (should come from extractRequestedDateTime)
+     *
+     * @return string Localized time period description
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string
+    {
+        switch ($type) {
+            case static::MONTH:
+                return $requested->format('F') . ', ' . $requested->format('Y');
+            case static::WEEK:
+                $requested = $requested->modify('Monday this week');
+                return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
+            case static::DAY:
+                $out = '';
+                if ($requested->format('Ymd') === date('Ymd')) {
+                    $out = t('Today') . ' - ';
+                } elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
+                    $out = t('Yesterday') . ' - ';
+                }
+                return $out . format_date($requested, false);
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get the number of items to display in the RSS feed depending on the given type.
+     *
+     * @param string $type month/week/day
+     *
+     * @return int number of elements
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getRssLengthByType(string $type): int
+    {
+        switch ($type) {
+            case static::MONTH:
+                return 12; // 1 year
+            case static::WEEK:
+                return 26; // ~6 months
+            case static::DAY:
+                return 30; // ~1 month
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+}
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..2e1401e
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * HTTP Tool used to extract metadata from external URL (title, description, etc.).
+ */
+class MetadataRetriever
+{
+    /** @var ConfigManager */
+    protected $conf;
+
+    /** @var HttpAccess */
+    protected $httpAccess;
+
+    public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
+    {
+        $this->conf = $conf;
+        $this->httpAccess = $httpAccess;
+    }
+
+    /**
+     * Retrieve metadata for given URL.
+     *
+     * @return array [
+     *                  'title' => <remote title>,
+     *                  'description' => <remote description>,
+     *                  'tags' => <remote keywords>,
+     *               ]
+     */
+    public function retrieve(string $url): array
+    {
+        $charset = null;
+        $title = null;
+        $description = null;
+        $tags = null;
+
+        // Short timeout to keep the application responsive
+        // The callback will fill $charset and $title with data from the downloaded page.
+        $this->httpAccess->getHttpResponse(
+            $url,
+            $this->conf->get('general.download_timeout', 30),
+            $this->conf->get('general.download_max_size', 4194304),
+            $this->httpAccess->getCurlHeaderCallback($charset),
+            $this->httpAccess->getCurlDownloadCallback(
+                $charset,
+                $title,
+                $description,
+                $tags,
+                $this->conf->get('general.retrieve_description'),
+                $this->conf->get('general.tags_separator', ' ')
+            )
+        );
+
+        if (!empty($title) && strtolower($charset) !== 'utf-8') {
+            $title = mb_convert_encoding($title, 'utf-8', $charset);
+        }
+
+        return [
+            'title' => $title,
+            'description' => $description,
+            'tags' => $tags,
+        ];
+    }
+}
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 1b2197c9d8d0aa1af56d44842ed18c3fa4f619de..3ea55728cc4c9b6af22d88da6f92e533b270069c 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Plugin;
 
 use Shaarli\Config\ConfigManager;
@@ -23,7 +24,7 @@ class PluginManager
      *
      * @var array $loadedPlugins
      */
-    private $loadedPlugins = array();
+    private $loadedPlugins = [];
 
     /**
      * @var ConfigManager Configuration Manager instance.
@@ -57,7 +58,7 @@ class PluginManager
     public function __construct(&$conf)
     {
         $this->conf = $conf;
-        $this->errors = array();
+        $this->errors = [];
     }
 
     /**
@@ -98,12 +99,13 @@ class PluginManager
      *
      * @return void
      */
-    public function executeHooks($hook, &$data, $params = array())
+    public function executeHooks($hook, &$data, $params = [])
     {
         $metadataParameters = [
             'target' => '_PAGE_',
             'loggedin' => '_LOGGEDIN_',
             'basePath' => '_BASE_PATH_',
+            'rootPath' => '_ROOT_PATH_',
             'bookmarkService' => '_BOOKMARK_SERVICE_',
         ];
 
@@ -195,7 +197,7 @@ class PluginManager
      */
     public function getPluginsMeta()
     {
-        $metaData = array();
+        $metaData = [];
         $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
 
         // Browse all plugin directories.
@@ -216,9 +218,9 @@ class PluginManager
             if (isset($metaData[$plugin]['parameters'])) {
                 $params = explode(';', $metaData[$plugin]['parameters']);
             } else {
-                $params = array();
+                $params = [];
             }
-            $metaData[$plugin]['parameters'] = array();
+            $metaData[$plugin]['parameters'] = [];
             foreach ($params as $param) {
                 if (empty($param)) {
                     continue;
index e5386f02605989de373e21e4b4295c3785ac058a..21ac6604f77a644bab417553d2c79d3d60dd77bc 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Plugin\Exception;
 
 use Exception;
index 41b357dd72caccc9fe0525b29d3dbc2a31876db2..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);
@@ -174,10 +186,12 @@ class PageBuilder
             }
         }
 
+        $rootPath = preg_replace('#/index\.php$#', '', $basePath);
         $this->assign('base_path', $basePath);
+        $this->assign('root_path', $rootPath);
         $this->assign(
             'asset_path',
-            $basePath . '/' .
+            $rootPath . '/' .
             rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' .
             $this->conf->get('resource.theme', 'default')
         );
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 d74c3118c4eded1713a339ebc824d1978c83f93d..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();
@@ -118,7 +125,7 @@ class LoginManager
      *
      * @return true when the user is logged in, false otherwise
      */
-    public function isLoggedIn()
+    public function isLoggedIn(): bool
     {
         if ($this->openShaarli) {
             return true;
@@ -129,48 +136,35 @@ class LoginManager
     /**
      * Check user credentials are valid
      *
-     * @param string $remoteIp   Remote client IP address
      * @param string $clientIpId Client IP address identifier
      * @param string $login      Username
      * @param string $password   Password
      *
      * @return bool true if the provided credentials are valid, false otherwise
      */
-    public function checkCredentials($remoteIp, $clientIpId, $login, $password)
+    public function checkCredentials($clientIpId, $login, $password)
     {
-        // Check login matches config
-        if ($login !== $this->configManager->get('credentials.login')) {
-            return false;
-        }
-
         // Check credentials
         try {
             $useLdapLogin = !empty($this->configManager->get('ldap.host'));
-            if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
-                || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
+            if (
+                $login === $this->configManager->get('credentials.login')
+                && (
+                    (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
+                    || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
+                )
             ) {
-                    $this->sessionManager->storeLoginInfo($clientIpId);
-                    logm(
-                        $this->configManager->get('resource.log'),
-                        $remoteIp,
-                        'Login successful'
-                    );
-                    return true;
+                $this->sessionManager->storeLoginInfo($clientIpId);
+                $this->logger->info(format_log('Login successful', $clientIpId));
+
+                return true;
             }
-        }
-        catch(Exception $exception) {
-            logm(
-                $this->configManager->get('resource.log'),
-                $remoteIp,
-                'Exception while checking credentials: ' . $exception
-            );
+        } catch (Exception $exception) {
+            $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId));
         }
 
-        logm(
-            $this->configManager->get('resource.log'),
-            $remoteIp,
-            'Login failed for user ' . $login
-        );
+        $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId));
+
         return false;
     }
 
@@ -183,7 +177,8 @@ class LoginManager
      *
      * @return bool true if the provided credentials are valid, false otherwise
      */
-    public function checkCredentialsFromLocalConfig($login, $password) {
+    public function checkCredentialsFromLocalConfig($login, $password)
+    {
         $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
 
         return $login == $this->configManager->get('credentials.login')
@@ -202,14 +197,14 @@ class LoginManager
      */
     public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null)
     {
-        $connect = $connect ?? function($host) {
+        $connect = $connect ?? function ($host) {
             $resource = ldap_connect($host);
 
             ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3);
 
             return $resource;
         };
-        $bind = $bind ?? function($handle, $dn, $password) {
+        $bind = $bind ?? function ($handle, $dn, $password) {
             return ldap_bind($handle, $dn, $password);
         };
 
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 be986ae015ed7aef888814bd5757f0fe434125cc..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-link').innerHTML)) {
+      const type = event.currentTarget.getAttribute('data-type') || 'link';
+      if (!confirm(document.getElementById(`translation-delete-${type}`).innerHTML)) {
         event.preventDefault();
       }
     });
@@ -569,7 +567,7 @@ function init(description) {
           input.setAttribute('name', totag);
           input.setAttribute('value', totag);
           findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
-          block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
+          block.querySelector('a.tag-link').innerHTML = he.encode(totag);
           block
             .querySelector('a.tag-link')
             .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
@@ -582,7 +580,7 @@ function init(description) {
 
           // Refresh awesomplete values
           existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
-          awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+          awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
         }
       };
       xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
@@ -622,14 +620,14 @@ function init(description) {
         refreshToken(basePath);
 
         existingTags = existingTags.filter((tagItem) => tagItem !== tag);
-        awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+        awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
       }
     });
   });
 
   const autocompleteFields = document.querySelectorAll('input[data-multiple]');
   [...autocompleteFields].forEach((autocompleteField) => {
-    awesomepletes.push(createAwesompleteInstance(autocompleteField));
+    awesomepletes.push(createAwesompleteInstance(autocompleteField, tagsSeparator));
   });
 
   const exportForm = document.querySelector('#exportform');
@@ -642,4 +640,33 @@ function init(description) {
       });
     });
   }
+
+  const bulkCreationButton = document.querySelector('.addlink-batch-show-more-block');
+  if (bulkCreationButton != null) {
+    const toggleBulkCreationVisibility = (showMoreBlockElement, formElement) => {
+      if (bulkCreationButton.classList.contains('pure-u-0')) {
+        showMoreBlockElement.classList.remove('pure-u-0');
+        formElement.classList.add('pure-u-0');
+      } else {
+        showMoreBlockElement.classList.add('pure-u-0');
+        formElement.classList.remove('pure-u-0');
+      }
+    };
+
+    const bulkCreationForm = document.querySelector('.addlink-batch-form-block');
+
+    toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    bulkCreationButton.querySelector('a').addEventListener('click', (e) => {
+      e.preventDefault();
+      toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    });
+
+    // Force to send falsy value if the checkbox is not checked.
+    const privateButton = bulkCreationForm.querySelector('input[type="checkbox"][name="private"]');
+    const privateHiddenButton = bulkCreationForm.querySelector('input[type="hidden"][name="private"]');
+    privateButton.addEventListener('click', () => {
+      privateHiddenButton.disabled = !privateHiddenButton.disabled;
+    });
+    privateHiddenButton.disabled = privateButton.checked;
+  }
 })();
index a528adb0dbe7931ea79c0cf0875b85951a3038c8..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 {
@@ -671,6 +681,10 @@ body,
       content: '';
     }
   }
+
+  .search-highlight {
+    background-color: yellow;
+  }
 }
 
 .linklist-item-buttons {
@@ -1019,6 +1033,10 @@ body,
     &.button-red {
       background: $red;
     }
+
+    &.button-grey {
+      background: $light-grey;
+    }
   }
 
   .submit-buttons {
@@ -1043,7 +1061,7 @@ body,
   }
 
   table {
-    margin: auto;
+    margin: 10px auto 25px auto;
     width: 90%;
 
     .order {
@@ -1079,6 +1097,11 @@ body,
           position: absolute;
           right: 5%;
         }
+
+        &.button-grey {
+          position: absolute;
+          left: 5%;
+        }
       }
     }
   }
@@ -1253,11 +1276,15 @@ form {
     margin: 70px 0 25px;
   }
 
+  a {
+    color: var(--main-color);
+  }
+
   pre {
     margin: 0 20%;
     padding: 20px 0;
     text-align: left;
-    line-height: .7em;
+    line-height: 1em;
   }
 }
 
@@ -1269,6 +1296,57 @@ form {
   }
 }
 
+.loading-input {
+  position: relative;
+
+  @keyframes around {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+
+  .icon-container {
+    position: absolute;
+    right: 60px;
+    top: calc(50% - 10px);
+  }
+
+  .loader {
+    position: relative;
+    height: 20px;
+    width: 20px;
+    display: inline-block;
+    animation: around 5.4s infinite;
+
+    &::after,
+    &::before {
+      content: "";
+      background: $form-input-background;
+      position: absolute;
+      display: inline-block;
+      width: 100%;
+      height: 100%;
+      border-width: 2px;
+      border-color: #333 #333 transparent transparent;
+      border-style: solid;
+      border-radius: 20px;
+      box-sizing: border-box;
+      top: 0;
+      left: 0;
+      animation: around 0.7s ease-in-out infinite;
+    }
+
+    &::after {
+      animation: around 0.7s ease-in-out 0.1s infinite;
+      background: transparent;
+    }
+  }
+}
+
 // LOGIN
 .login-form-container {
   .remember-me {
@@ -1641,6 +1719,123 @@ form {
   }
 }
 
+// SERVER PAGE
+
+.server-tables-page,
+.server-tables {
+  .window-subtitle {
+    &::before {
+      display: block;
+      margin: 8px auto;
+      background: linear-gradient(to right, var(--background-color), $dark-grey, var(--background-color));
+      width: 50%;
+      height: 1px;
+      content: '';
+    }
+  }
+
+  .server-row {
+    p {
+      height: 25px;
+      padding: 0 10px;
+    }
+  }
+
+  .server-label {
+    text-align: right;
+    font-weight: bold;
+  }
+
+  i {
+    &.fa-color-green {
+      color: $main-green;
+    }
+
+    &.fa-color-orange {
+      color: $orange;
+    }
+
+    &.fa-color-red {
+      color: $red;
+    }
+  }
+
+  @media screen and (max-width: 64em) {
+    .server-label {
+      text-align: center;
+    }
+
+    .server-row {
+      p {
+        text-align: center;
+      }
+    }
+  }
+}
+
+// Batch creation
+input[name='save_edit_batch'] {
+  @extend %page-form-button;
+}
+
+.addlink-batch-show-more {
+  display: flex;
+  align-items: center;
+  margin: 20px 0 8px;
+
+  a {
+    color: var(--main-color);
+    text-decoration: none;
+  }
+
+  &::before,
+  &::after {
+    content: "";
+    flex-grow: 1;
+    background: rgba(0, 0, 0, 0.35);
+    height: 1px;
+    font-size: 0;
+    line-height: 0;
+  }
+
+  &::before {
+    margin: 0 16px 0 0;
+  }
+
+  &::after {
+    margin: 0 0 0 16px;
+  }
+}
+
+.dark-layer {
+  display: none;
+  position: fixed;
+  height: 100%;
+  width: 100%;
+  z-index: 998;
+  background-color: rgba(0, 0, 0, .75);
+  color: #fff;
+
+  .screen-center {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    text-align: center;
+    min-height: 100vh;
+  }
+
+  .progressbar {
+    width: 33%;
+  }
+}
+
+.addlink-batch-form-block {
+  .pure-alert {
+    margin: 25px 0 0 0;
+  }
+}
+
 // Print rules
 @media print {
   .shaarli-menu {
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 cd9fcf5b22e6b1df223858e93434e7877472764d..138319cabd36ca3cfd3576b014192f7bf76c33f9 100644 (file)
@@ -10,6 +10,7 @@
     },
     "keywords": ["bookmark", "link", "share", "web"],
     "config": {
+        "sort-packages": true,
         "platform": {
             "php": "7.1.29"
         }
         "php": ">=7.1",
         "ext-json": "*",
         "ext-zlib": "*",
-        "shaarli/netscape-bookmark-parser": "^2.1",
-        "erusev/parsedown": "^1.6",
-        "slim/slim": "^3.0",
         "arthurhoaro/web-thumbnailer": "^2.0",
+        "erusev/parsedown": "^1.6",
+        "erusev/parsedown-extra": "^0.8.1",
+        "gettext/gettext": "^4.4",
+        "katzgrau/klogger": "^1.2",
+        "malkusch/lock": "^2.1",
         "pubsubhubbub/publisher": "dev-master",
-        "gettext/gettext": "^4.4"
+        "shaarli/netscape-bookmark-parser": "^3.0",
+        "slim/slim": "^3.0"
     },
     "require-dev": {
         "roave/security-advisories": "dev-master",
@@ -55,6 +59,7 @@
             "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
             "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
             "Shaarli\\Front\\Exception\\": "application/front/exceptions",
+            "Shaarli\\Helper\\": "application/helper",
             "Shaarli\\Http\\": "application/http",
             "Shaarli\\Legacy\\": "application/legacy",
             "Shaarli\\Netscape\\": "application/netscape",
index 2c8b0ea7bc675e9797c6957178733aa03cd3b272..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": "98520a05a7185503ee13d05ffaa535f6",
+    "content-hash": "83852dec81e299a117a81206a5091472",
     "packages": [
         {
             "name": "arthurhoaro/web-thumbnailer",
             },
             "time": "2019-12-30T22:54:17+00:00"
         },
+        {
+            "name": "erusev/parsedown-extra",
+            "version": "0.8.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/erusev/parsedown-extra.git",
+                "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef",
+                "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef",
+                "shasum": ""
+            },
+            "require": {
+                "erusev/parsedown": "^1.7.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "ParsedownExtra": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Emanuil Rusev",
+                    "email": "hello@erusev.com",
+                    "homepage": "http://erusev.com"
+                }
+            ],
+            "description": "An extension of Parsedown that adds support for Markdown Extra.",
+            "homepage": "https://github.com/erusev/parsedown-extra",
+            "keywords": [
+                "markdown",
+                "markdown extra",
+                "parsedown",
+                "parser"
+            ],
+            "support": {
+                "issues": "https://github.com/erusev/parsedown-extra/issues",
+                "source": "https://github.com/erusev/parsedown-extra/tree/0.8.x"
+            },
+            "time": "2019-12-30T23:20:37+00:00"
+        },
         {
             "name": "gettext/gettext",
             "version": "v4.8.2",
             },
             "time": "2016-11-07T19:29:14+00:00"
         },
+        {
+            "name": "malkusch/lock",
+            "version": "v2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-lock/lock.git",
+                "reference": "093f389ec2f38fc8686d2f70e23378182fce7714"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-lock/lock/zipball/093f389ec2f38fc8686d2f70e23378182fce7714",
+                "reference": "093f389ec2f38fc8686d2f70e23378182fce7714",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1",
+                "psr/log": "^1"
+            },
+            "require-dev": {
+                "eloquent/liberator": "^2.0",
+                "ext-memcached": "*",
+                "ext-pcntl": "*",
+                "ext-pdo_mysql": "*",
+                "ext-pdo_sqlite": "*",
+                "ext-redis": "*",
+                "ext-sysvsem": "*",
+                "johnkary/phpunit-speedtrap": "^3.0",
+                "kriswallsmith/spork": "^0.3",
+                "mikey179/vfsstream": "^1.6",
+                "php-mock/php-mock-phpunit": "^2.1",
+                "phpunit/phpunit": "^7.4",
+                "predis/predis": "^1.1",
+                "squizlabs/php_codesniffer": "^3.3"
+            },
+            "suggest": {
+                "ext-pnctl": "Enables locking with flock without busy waiting in CLI scripts.",
+                "ext-redis": "To use this library with the PHP Redis extension.",
+                "ext-sysvsem": "Enables locking using semaphores.",
+                "predis/predis": "To use this library with predis."
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "malkusch\\lock\\": "classes/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "WTFPL"
+            ],
+            "authors": [
+                {
+                    "name": "Markus Malkusch",
+                    "email": "markus@malkusch.de",
+                    "homepage": "http://markus.malkusch.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Willem Stuursma-Ruwen",
+                    "email": "willem@stuursma.name",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Mutex library for exclusive code execution.",
+            "homepage": "https://github.com/malkusch/lock",
+            "keywords": [
+                "advisory-locks",
+                "cas",
+                "flock",
+                "lock",
+                "locking",
+                "memcache",
+                "mutex",
+                "mysql",
+                "postgresql",
+                "redis",
+                "redlock",
+                "semaphore"
+            ],
+            "support": {
+                "issues": "https://github.com/php-lock/lock/issues",
+                "source": "https://github.com/php-lock/lock/tree/v2.1"
+            },
+            "time": "2018-12-12T19:53:29+00:00"
+        },
         {
             "name": "nikic/fast-route",
             "version": "v1.3.0",
         },
         {
             "name": "shaarli/netscape-bookmark-parser",
-            "version": "v2.2.0",
+            "version": "v3.0.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/shaarli/netscape-bookmark-parser.git",
-                "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df"
+                "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/432a010af2bb1832d6fbc4763e6b0100b980a1df",
-                "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df",
+                "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/d2321f30413944b2d0a9844bf8cc588c71ae6305",
+                "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305",
                 "shasum": ""
             },
             "require": {
                 "katzgrau/klogger": "~1.0",
-                "php": ">=5.6"
+                "php": ">=7.1"
             },
             "require-dev": {
-                "phpunit/phpunit": "^5.0"
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
+                "squizlabs/php_codesniffer": "^3.5"
             },
             "type": "library",
             "autoload": {
             ],
             "support": {
                 "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues",
-                "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v2.2.0"
+                "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v3.0.1"
             },
-            "time": "2020-06-06T15:53:53+00:00"
+            "time": "2020-11-03T12:27:58+00:00"
         },
         {
             "name": "slim/slim",
             "source": {
                 "type": "git",
                 "url": "https://github.com/Roave/SecurityAdvisories.git",
-                "reference": "0749ceaf15c136d085b722a5bb88141398a54142"
+                "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/0749ceaf15c136d085b722a5bb88141398a54142",
-                "reference": "0749ceaf15c136d085b722a5bb88141398a54142",
+                "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
+                "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
                 "shasum": ""
             },
             "conflict": {
                 "bagisto/bagisto": "<0.1.5",
                 "barrelstrength/sprout-base-email": "<1.2.7",
                 "barrelstrength/sprout-forms": "<3.9",
-                "baserproject/basercms": ">=4,<=4.3.6",
+                "baserproject/basercms": ">=4,<=4.3.6|>=4.4,<4.4.1",
                 "bolt/bolt": "<3.7.1",
                 "brightlocal/phpwhois": "<=4.2.5",
                 "buddypress/buddypress": "<5.1.2",
                 "ezsystems/ezplatform-kernel": ">=1,<1.0.2.1",
                 "ezsystems/ezplatform-user": ">=1,<1.0.1",
                 "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.14.2|>=6,<6.7.9.1|>=6.8,<6.13.6.3|>=7,<7.2.4.1|>=7.3,<7.3.2.1|>=7.5,<7.5.7.1",
-                "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.1|>=2011,<2017.12.7.2|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.4.2",
+                "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.2|>=2011,<2017.12.7.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.5.1",
                 "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3",
                 "ezsystems/repository-forms": ">=2.3,<2.3.2.1",
                 "ezyang/htmlpurifier": "<4.1.1",
                 "magento/magento1ee": ">=1,<1.14.4.3",
                 "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2",
                 "marcwillmann/turn": "<0.3.3",
+                "mediawiki/core": ">=1.31,<1.31.9|>=1.32,<1.32.4|>=1.33,<1.33.3|>=1.34,<1.34.3|>=1.34.99,<1.35",
                 "mittwald/typo3_forum": "<1.2.1",
                 "monolog/monolog": ">=1.8,<1.12",
                 "namshi/jose": "<2.2",
+                "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6",
+                "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13",
                 "nystudio107/craft-seomatic": "<3.3",
                 "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1",
                 "october/backend": ">=1.0.319,<1.0.467",
                 "onelogin/php-saml": "<2.10.4",
                 "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5",
                 "openid/php-openid": "<2.3",
-                "openmage/magento-lts": "<19.4.6|>=20,<20.0.2",
+                "openmage/magento-lts": "<19.4.8|>=20,<20.0.4",
+                "orchid/platform": ">=9,<9.4.4",
                 "oro/crm": ">=1.7,<1.7.4",
                 "oro/platform": ">=1.7,<1.7.4",
                 "padraic/humbug_get_contents": "<1.1.2",
                 "privatebin/privatebin": "<1.2.2|>=1.3,<1.3.2",
                 "propel/propel": ">=2-alpha.1,<=2-alpha.7",
                 "propel/propel1": ">=1,<=1.7.1",
+                "pterodactyl/panel": "<0.7.19|>=1-rc.0,<=1-rc.6",
                 "pusher/pusher-php-server": "<2.2.1",
                 "rainlab/debugbar-plugin": "<3.1",
                 "robrichards/xmlseclibs": "<3.0.4",
                 "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11",
                 "sensiolabs/connect": "<4.2.3",
                 "serluck/phpwhois": "<=4.2.6",
-                "shopware/core": "<=6.3.1",
-                "shopware/platform": "<=6.3.1",
+                "shopware/core": "<=6.3.2",
+                "shopware/platform": "<=6.3.2",
                 "shopware/shopware": "<5.3.7",
                 "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1",
                 "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2",
                 "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
                 "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
                 "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
-                "sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5",
+                "sylius/sylius": "<1.6.9|>=1.7,<1.7.9|>=1.8,<1.8.3",
                 "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99",
                 "symbiote/silverstripe-versionedfiles": "<=2.0.3",
                 "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
                 "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.10|>=3.1,<3.1.7|>=3.2,<3.2.7|>=3.3,<3.3.5",
                 "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4",
                 "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1",
+                "typo3fluid/fluid": ">=2,<2.0.5|>=2.1,<2.1.4|>=2.2,<2.2.1|>=2.3,<2.3.5|>=2.4,<2.4.1|>=2.5,<2.5.5|>=2.6,<2.6.1",
                 "ua-parser/uap-php": "<3.8",
                 "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2",
                 "verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4",
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-09-24T17:02:11+00:00"
+            "time": "2020-11-01T20:01:47+00:00"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.5.6",
+            "version": "3.5.8",
             "source": {
                 "type": "git",
                 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "e97627871a7eab2f70e59166072a6b767d5834e0"
+                "reference": "9d583721a7157ee997f235f327de038e7ea6dac4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0",
-                "reference": "e97627871a7eab2f70e59166072a6b767d5834e0",
+                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/9d583721a7157ee997f235f327de038e7ea6dac4",
+                "reference": "9d583721a7157ee997f235f327de038e7ea6dac4",
                 "shasum": ""
             },
             "require": {
                 "source": "https://github.com/squizlabs/PHP_CodeSniffer",
                 "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
             },
-            "time": "2020-08-10T04:50:15+00:00"
+            "time": "2020-10-23T02:01:07+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.18.1",
+            "version": "v1.20.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "1c302646f6efc070cd46856e600e5e0684d6b454"
+                "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454",
-                "reference": "1c302646f6efc070cd46856e600e5e0684d6b454",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
+                "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.3.3"
+                "php": ">=7.1"
             },
             "suggest": {
                 "ext-ctype": "For best performance"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.18-dev"
+                    "dev-main": "1.20-dev"
                 },
                 "thanks": {
                     "name": "symfony/polyfill",
                 "portable"
             ],
             "support": {
-                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.18.0"
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
             },
             "funding": [
                 {
                     "type": "tidelift"
                 }
             ],
-            "time": "2020-07-14T12:35:20+00:00"
+            "time": "2020-10-23T14:02:19+00:00"
         },
         {
             "name": "theseer/tokenizer",
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 297d7c291e83c0b024137f16f614676a769f1aaa..a49b60334c11526bd289ffbd982e04bfbce5ab9d 100644 (file)
@@ -40,6 +40,8 @@ Supported PHP versions:
 
 Version | Status | Shaarli compatibility
 :---:|:---:|:---:
+8.0 | Supported | Yes
+7.4 | Supported | Yes
 7.3 | Supported | Yes
 7.2 | Supported | Yes
 7.1 | Supported | Yes
@@ -53,7 +55,7 @@ Required PHP extensions:
 
 Extension | Required? | Usage
 ---|:---:|---
-[`openssl`](http://php.net/manual/en/book.openssl.php) | requires | OpenSSL, HTTPS
+[`openssl`](http://php.net/manual/en/book.openssl.php) | required | OpenSSL, HTTPS
 [`php-json`](http://php.net/manual/en/book.json.php) | required | configuration parsing
 [`php-simplexml`](https://www.php.net/manual/en/book.simplexml.php) | required | REST API (Slim framework)
 [`php-mbstring`](http://php.net/manual/en/book.mbstring.php) | CentOS, Fedora, RHEL, Windows, some hosting providers | multibyte (Unicode) string support
@@ -191,19 +193,24 @@ sudo nano /etc/apache2/sites-available/shaarli.mydomain.org.conf
         Require all granted
     </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>
 ```
 
@@ -294,7 +301,7 @@ server {
     location / {
         # default index file when no file URI is requested
         index index.php;
-        try_files $uri /index.php$is_args$args;
+        try_files _ /index.php$is_args$args;
     }
 
     location ~ (index)\.php$ {
@@ -307,20 +314,9 @@ server {
         include        fastcgi.conf;
     }
 
-    location ~ \.php$ {
-        # deny access to all other PHP scripts
-        # disable this if you host other PHP applications on the same virtualhost
-        deny all;
-    }
-
-    location ~ /\. {
-        # deny access to dotfiles
-        deny all;
-    }
-
-    location ~ ~$ {
-        # deny access to temp editor files, e.g. "script.php~"
-        deny all;
+    location ~ /doc/html/ {
+        default_type "text/html";
+        try_files $uri $uri/ $uri.html =404;
     }
 
     location = /favicon.ico {
@@ -329,13 +325,12 @@ server {
     }
 
     # allow client-side caching of static files
-    location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
+    location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
         expires    max;
         add_header Cache-Control "public, must-revalidate, proxy-revalidate";
         # HTTP 1.0 compatibility
         add_header Pragma public;
     }
-
 }
 ```
 
@@ -360,7 +355,23 @@ sudo systemctl reload nginx
 
 If Shaarli is hosted on a server behind a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) (i.e. there is a proxy server between clients and the web server hosting Shaarli), configure it accordingly. See [Reverse proxy](Reverse-proxy.md) configuration.
 
+## Using Shaarli without URL rewriting
+
+By default, Shaarli uses Slim framework's URL, which requires
+URL rewriting.
+
+If you can't use URL rewriting for any reason (not supported by
+your web server, shared hosting, etc.), you *can* use Shaarli
+without URL rewriting.
+
+You just need to prefix your URL by `/index.php/`.
+Example: instead of accessing `https://shaarli.mydomain.org/`,
+use `https://shaarli.mydomain.org/index.php/`.
 
+**Recommended:**
+  * after installation, in the configuration page, set your header link to `/index.php/`.
+  * in your configuration file `config.json.php` set `general.root_url` to
+    `https://shaarli.mydomain.org/index.php/`.
 
 ## Allow import of large browser bookmarks export
 
@@ -421,7 +432,7 @@ By default Shaarli already disallows indexing of your local copy of the document
 before = common.conf
 [Definition]
 failregex = \s-\s<HOST>\s-\sLogin failed for user.*$
-ignoreregex = 
+ignoreregex =
 ```
 
 ```ini
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 c29774de05b46e47be8ed6c5b05b5aa539e68028..f09fadc2925db027873cd2d788ac6a239a6ffa68 100644 (file)
@@ -148,11 +148,16 @@ If a file needs to be included in server end, use simple relative path:
 `PluginManager::$PLUGINS_PATH . '/mything/template.html'`.
 
 If it needs to be included in front end side (e.g. an image),
-the relative path must be prefixed with special data `_BASE_PATH_`:
-`($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH . '/mything/picture.png`.
+the relative path must be prefixed with special data:
+
+  * if it's a link that will need to be processed by Shaarli, use `_BASE_PATH_`:
+    for e.g. `$data['_BASE_PATH_'] . '/admin/tools`.
+  * if you want to include an asset, you need to add the root URL (base path without `/index.php`, for people using Shaarli without URL rewriting), then use `_ROOT_PATH_`:
+    for e.g
+`$['_ROOT_PATH_'] . '/' . PluginManager::$PLUGINS_PATH . '/mything/picture.png`.
 
 Note that special placeholders for CSS and JS files (respectively `css_files` and `js_files`) are already prefixed
-with the base path in template files.
+with the root path in template files.
 
 ### It's not working!
 
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 9a6e3958ca2a571092ae4206178ca6546b8febfc..26dede4e29e780577ee692ed8766f609808cd277 100644 (file)
@@ -1,8 +1,8 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: Shaarli\n"
-"POT-Creation-Date: 2020-09-10 16:06+0200\n"
-"PO-Revision-Date: 2020-09-10 16:07+0200\n"
+"POT-Creation-Date: 2020-11-09 14:39+0100\n"
+"PO-Revision-Date: 2020-11-09 14:42+0100\n"
 "Last-Translator: \n"
 "Language-Team: Shaarli\n"
 "Language: fr_FR\n"
@@ -20,38 +20,11 @@ msgstr ""
 "X-Poedit-SearchPath-3: init.php\n"
 "X-Poedit-SearchPath-4: plugins\n"
 
-#: application/ApplicationUtils.php:161
-#, php-format
-msgid ""
-"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
-"cannot run. Your PHP version has known security vulnerabilities and should "
-"be updated as soon as possible."
-msgstr ""
-"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
-"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
-"connues et devrait être mise à jour au plus tôt."
-
-#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204
-msgid "directory is not readable"
-msgstr "le répertoire n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:207
-msgid "directory is not writable"
-msgstr "le répertoire n'est pas accessible en écriture"
-
-#: application/ApplicationUtils.php:225
-msgid "file is not readable"
-msgstr "le fichier n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:228
-msgid "file is not writable"
-msgstr "le fichier n'est pas accessible en écriture"
-
-#: application/History.php:179
+#: application/History.php:180
 msgid "History file isn't readable or writable"
 msgstr "Le fichier d'historique n'est pas accessible en lecture ou en écriture"
 
-#: application/History.php:190
+#: application/History.php:191
 msgid "Could not parse history file"
 msgstr "Format incorrect pour le fichier d'historique"
 
@@ -83,52 +56,46 @@ msgstr ""
 "l'extension php-gd doit être chargée pour utiliser les miniatures. Les "
 "miniatures sont désormais désactivées. Rechargez la page."
 
-#: application/Utils.php:383
+#: application/Utils.php:402
 msgid "Setting not set"
 msgstr "Paramètre non défini"
 
-#: application/Utils.php:390
+#: application/Utils.php:409
 msgid "Unlimited"
 msgstr "Illimité"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "B"
 msgstr "o"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "kiB"
 msgstr "ko"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "MiB"
 msgstr "Mo"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "GiB"
 msgstr "Go"
 
-#: application/bookmark/BookmarkFileService.php:174
-#: application/bookmark/BookmarkFileService.php:199
-#: application/bookmark/BookmarkFileService.php:224
+#: application/bookmark/BookmarkFileService.php:183
+#: application/bookmark/BookmarkFileService.php:205
+#: application/bookmark/BookmarkFileService.php:227
 #: application/bookmark/BookmarkFileService.php:241
 msgid "You're not authorized to alter the datastore"
 msgstr "Vous n'êtes pas autorisé à modifier les données"
 
-#: application/bookmark/BookmarkFileService.php:177
-#: application/bookmark/BookmarkFileService.php:202
-#: application/bookmark/BookmarkFileService.php:244
-msgid "Provided data is invalid"
-msgstr "Les informations fournies ne sont pas valides"
-
-#: application/bookmark/BookmarkFileService.php:205
+#: application/bookmark/BookmarkFileService.php:208
 msgid "This bookmarks already exists"
-msgstr "Ce marque-page existe déjà."
+msgstr "Ce marque-page existe déjà"
 
-#: application/bookmark/BookmarkInitializer.php:37
+#: application/bookmark/BookmarkInitializer.php:39
 msgid "(private bookmark with thumbnail demo)"
 msgstr "(marque page privé avec une miniature)"
 
-#: application/bookmark/BookmarkInitializer.php:40
+#: application/bookmark/BookmarkInitializer.php:42
 msgid ""
 "Shaarli will automatically pick up the thumbnail for links to a variety of "
 "websites.\n"
@@ -151,11 +118,11 @@ msgstr ""
 "\n"
 "Maintenant, vous pouvez modifier ou supprimer les shaares créés par défaut.\n"
 
-#: application/bookmark/BookmarkInitializer.php:53
+#: application/bookmark/BookmarkInitializer.php:55
 msgid "Note: Shaare descriptions"
 msgstr "Note : Description des Shaares"
 
-#: application/bookmark/BookmarkInitializer.php:55
+#: application/bookmark/BookmarkInitializer.php:57
 msgid ""
 "Adding a shaare without entering a URL creates a text-only \"note\" post "
 "such as this one.\n"
@@ -219,19 +186,19 @@ msgstr ""
 "| Citron   | Fruit     | Jaune | 30    |\n"
 "| Carotte  | Légume | Orange    | 14    |\n"
 
-#: application/bookmark/BookmarkInitializer.php:89
+#: application/bookmark/BookmarkInitializer.php:91
 #: application/legacy/LegacyLinkDB.php:246
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:49
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
 msgid ""
 "The personal, minimalist, super-fast, database free, bookmarking service"
 msgstr ""
 "Le gestionnaire de marque-pages personnel, minimaliste, et sans base de "
 "données"
 
-#: application/bookmark/BookmarkInitializer.php:92
+#: application/bookmark/BookmarkInitializer.php:94
 msgid ""
 "Welcome to Shaarli!\n"
 "\n"
@@ -320,7 +287,8 @@ msgid "Direct link"
 msgstr "Liens directs"
 
 #: application/feed/FeedBuilder.php:181
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
 msgid "Permalink"
 msgstr "Permalien"
@@ -336,12 +304,13 @@ msgid "You have enabled or changed thumbnails mode."
 msgstr "Vous avez activé ou changé le mode de miniatures."
 
 #: application/front/controller/admin/ConfigureController.php:103
+#: application/front/controller/admin/ServerController.php:75
 #: application/legacy/LegacyUpdater.php:538
 msgid "Please synchronize them."
 msgstr "Merci de les synchroniser."
 
 #: application/front/controller/admin/ConfigureController.php:113
-#: application/front/controller/visitor/InstallController.php:136
+#: application/front/controller/visitor/InstallController.php:146
 msgid "Error while writing config file after configuration update."
 msgstr ""
 "Une erreur s'est produite lors de la sauvegarde du fichier de configuration."
@@ -378,70 +347,47 @@ msgstr ""
 "le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
 "légères."
 
-#: application/front/controller/admin/ManageShaareController.php:29
-#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-msgid "Shaare a new link"
-msgstr "Partager un nouveau lien"
-
-#: application/front/controller/admin/ManageShaareController.php:78
-msgid "Note: "
-msgstr "Note : "
-
-#: application/front/controller/admin/ManageShaareController.php:109
-#: application/front/controller/admin/ManageShaareController.php:206
-#: application/front/controller/admin/ManageShaareController.php:275
-#: application/front/controller/admin/ManageShaareController.php:315
-#, php-format
-msgid "Bookmark with identifier %s could not be found."
-msgstr "Le lien avec l'identifiant %s n'a pas pu être trouvé."
-
-#: application/front/controller/admin/ManageShaareController.php:194
-#: application/front/controller/admin/ManageShaareController.php:252
-msgid "Invalid bookmark ID provided."
-msgstr "ID du lien non valide."
+#: application/front/controller/admin/ManageTagController.php:30
+msgid "whitespace"
+msgstr "espace"
 
-#: application/front/controller/admin/ManageShaareController.php:260
-msgid "Invalid visibility provided."
-msgstr "Visibilité du lien non valide."
-
-#: application/front/controller/admin/ManageShaareController.php:363
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
-msgid "Edit"
-msgstr "Modifier"
-
-#: application/front/controller/admin/ManageShaareController.php:366
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
-msgid "Shaare"
-msgstr "Shaare"
-
-#: application/front/controller/admin/ManageTagController.php:29
+#: application/front/controller/admin/ManageTagController.php:35
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 msgid "Manage tags"
 msgstr "Gérer les tags"
 
-#: application/front/controller/admin/ManageTagController.php:48
+#: application/front/controller/admin/ManageTagController.php:54
 msgid "Invalid tags provided."
 msgstr "Les tags fournis ne sont pas valides."
 
-#: application/front/controller/admin/ManageTagController.php:72
+#: application/front/controller/admin/ManageTagController.php:78
 #, php-format
 msgid "The tag was removed from %d bookmark."
 msgid_plural "The tag was removed from %d bookmarks."
 msgstr[0] "Le tag a été supprimé du %d lien."
 msgstr[1] "Le tag a été supprimé de %d liens."
 
-#: application/front/controller/admin/ManageTagController.php:77
+#: application/front/controller/admin/ManageTagController.php:83
 #, php-format
 msgid "The tag was renamed in %d bookmark."
 msgid_plural "The tag was renamed in %d bookmarks."
 msgstr[0] "Le tag a été renommé dans %d lien."
 msgstr[1] "Le tag a été renommé dans %d liens."
 
+#: application/front/controller/admin/ManageTagController.php:105
+msgid "Tags separator must be a single character."
+msgstr "Un séparateur de tags doit contenir un seul caractère."
+
+#: application/front/controller/admin/ManageTagController.php:111
+msgid "These characters are reserved and can't be used as tags separator: "
+msgstr ""
+"Ces caractères sont réservés et ne peuvent être utilisés comme des "
+"séparateurs de tags : "
+
 #: application/front/controller/admin/PasswordController.php:28
 #: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
 msgid "Change password"
 msgstr "Modifier le mot de passe"
 
@@ -463,16 +409,71 @@ msgstr "Votre mot de passe a été modifié"
 msgid "Plugin Administration"
 msgstr "Administration des plugins"
 
-#: application/front/controller/admin/PluginsController.php:75
+#: application/front/controller/admin/PluginsController.php:76
 msgid "Setting successfully saved."
 msgstr "Les paramètres ont été sauvegardés avec succès."
 
-#: application/front/controller/admin/PluginsController.php:78
+#: application/front/controller/admin/PluginsController.php:79
 msgid "Error while saving plugin configuration: "
 msgstr ""
 "Une erreur s'est produite lors de la sauvegarde de la configuration des "
 "plugins : "
 
+#: application/front/controller/admin/ServerController.php:35
+msgid "Check disabled"
+msgstr "Vérification désactivée"
+
+#: application/front/controller/admin/ServerController.php:57
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Server administration"
+msgstr "Administration serveur"
+
+#: application/front/controller/admin/ServerController.php:74
+msgid "Thumbnails cache has been cleared."
+msgstr "Le cache des miniatures a été vidé."
+
+#: application/front/controller/admin/ServerController.php:83
+msgid "Shaarli's cache folder has been cleared!"
+msgstr "Le dossier de cache de Shaarli a été vidé !"
+
+#: application/front/controller/admin/ShaareAddController.php:26
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+msgid "Shaare a new link"
+msgstr "Partagez un nouveau lien"
+
+#: application/front/controller/admin/ShaareManageController.php:35
+#: application/front/controller/admin/ShaareManageController.php:93
+msgid "Invalid bookmark ID provided."
+msgstr "L'ID du marque-page fourni n'est pas valide."
+
+#: application/front/controller/admin/ShaareManageController.php:47
+#: application/front/controller/admin/ShaareManageController.php:116
+#: application/front/controller/admin/ShaareManageController.php:156
+#: application/front/controller/admin/ShaarePublishController.php:82
+#, php-format
+msgid "Bookmark with identifier %s could not be found."
+msgstr "Le lien avec l'identifiant %s n'a pas pu être trouvé."
+
+#: application/front/controller/admin/ShaareManageController.php:101
+msgid "Invalid visibility provided."
+msgstr "Visibilité du lien non valide."
+
+#: application/front/controller/admin/ShaarePublishController.php:171
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+msgid "Edit"
+msgstr "Modifier"
+
+#: application/front/controller/admin/ShaarePublishController.php:174
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
+msgid "Shaare"
+msgstr "Shaare"
+
+#: application/front/controller/admin/ShaarePublishController.php:205
+msgid "Note: "
+msgstr "Note : "
+
 #: application/front/controller/admin/ThumbnailsController.php:37
 #: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
 msgid "Thumbnails update"
@@ -484,29 +485,62 @@ msgstr "Mise à jour des miniatures"
 msgid "Tools"
 msgstr "Outils"
 
-#: application/front/controller/visitor/BookmarkListController.php:115
+#: application/front/controller/visitor/BookmarkListController.php:120
 msgid "Search: "
 msgstr "Recherche : "
 
-#: application/front/controller/visitor/DailyController.php:45
-msgid "Today"
-msgstr "Aujourd'hui"
-
-#: application/front/controller/visitor/DailyController.php:47
-msgid "Yesterday"
-msgstr "Hier"
+#: application/front/controller/visitor/DailyController.php:200
+msgid "day"
+msgstr "jour"
 
-#: application/front/controller/visitor/DailyController.php:85
+#: application/front/controller/visitor/DailyController.php:200
+#: application/front/controller/visitor/DailyController.php:203
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
 #: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48
 msgid "Daily"
 msgstr "Quotidien"
 
-#: application/front/controller/visitor/ErrorController.php:36
+#: application/front/controller/visitor/DailyController.php:201
+msgid "week"
+msgstr "semaine"
+
+#: application/front/controller/visitor/DailyController.php:201
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Weekly"
+msgstr "Hebdomadaire"
+
+#: application/front/controller/visitor/DailyController.php:202
+msgid "month"
+msgstr "mois"
+
+#: application/front/controller/visitor/DailyController.php:202
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "Monthly"
+msgstr "Mensuel"
+
+#: application/front/controller/visitor/ErrorController.php:30
+msgid "Error: "
+msgstr "Erreur : "
+
+#: application/front/controller/visitor/ErrorController.php:34
+msgid "Please report it on Github."
+msgstr "Merci de la rapporter sur Github."
+
+#: application/front/controller/visitor/ErrorController.php:39
 msgid "An unexpected error occurred."
 msgstr "Une erreur inattendue s'est produite."
 
-#: application/front/controller/visitor/InstallController.php:73
+#: application/front/controller/visitor/ErrorNotFoundController.php:25
+msgid "Requested page could not be found."
+msgstr "La page demandée n'a pas pu être trouvée."
+
+#: application/front/controller/visitor/InstallController.php:64
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
+msgid "Install Shaarli"
+msgstr "Installation de Shaarli"
+
+#: application/front/controller/visitor/InstallController.php:83
 #, php-format
 msgid ""
 "<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
@@ -525,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:154
 msgid ""
 "Shaarli is now configured. Please login and start shaaring your bookmarks!"
 msgstr ""
 "Shaarli est maintenant configuré. Vous pouvez vous connecter et commencez à "
 "shaare vos liens !"
 
-#: application/front/controller/visitor/InstallController.php:158
+#: application/front/controller/visitor/InstallController.php:168
 msgid "Insufficient permissions:"
 msgstr "Permissions insuffisantes :"
 
@@ -546,7 +580,7 @@ msgstr "Permissions insuffisantes :"
 msgid "Login"
 msgstr "Connexion"
 
-#: application/front/controller/visitor/LoginController.php:78
+#: application/front/controller/visitor/LoginController.php:77
 msgid "Wrong login/password."
 msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
 
@@ -556,11 +590,9 @@ msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
 msgid "Picture wall"
 msgstr "Mur d'images"
 
-#: application/front/controller/visitor/TagCloudController.php:80
-#, fuzzy
-#| msgid "Tag list"
+#: application/front/controller/visitor/TagCloudController.php:90
 msgid "Tag "
-msgstr "Liste des tags"
+msgstr "Tag "
 
 #: application/front/exceptions/AlreadyInstalledException.php:11
 msgid "Shaarli has already been installed. Login to edit the configuration."
@@ -588,6 +620,86 @@ msgstr ""
 msgid "Wrong token."
 msgstr "Jeton invalide."
 
+#: application/helper/ApplicationUtils.php:162
+#, php-format
+msgid ""
+"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
+"cannot run. Your PHP version has known security vulnerabilities and should "
+"be updated as soon as possible."
+msgstr ""
+"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
+"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
+"connues et devrait être mise à jour au plus tôt."
+
+#: application/helper/ApplicationUtils.php:195
+#: application/helper/ApplicationUtils.php:215
+msgid "directory is not readable"
+msgstr "le répertoire n'est pas accessible en lecture"
+
+#: application/helper/ApplicationUtils.php:218
+msgid "directory is not writable"
+msgstr "le répertoire n'est pas accessible en écriture"
+
+#: application/helper/ApplicationUtils.php:240
+msgid "file is not readable"
+msgstr "le fichier n'est pas accessible en lecture"
+
+#: application/helper/ApplicationUtils.php:243
+msgid "file is not writable"
+msgstr "le fichier n'est pas accessible en écriture"
+
+#: application/helper/ApplicationUtils.php:277
+msgid "Configuration parsing"
+msgstr "Chargement de la configuration"
+
+#: application/helper/ApplicationUtils.php:278
+msgid "Slim Framework (routing, etc.)"
+msgstr "Slim Framwork (routage, etc.)"
+
+#: application/helper/ApplicationUtils.php:279
+msgid "Multibyte (Unicode) string support"
+msgstr "Support des chaînes de caractère multibytes (Unicode)"
+
+#: application/helper/ApplicationUtils.php:280
+msgid "Required to use thumbnails"
+msgstr "Obligatoire pour utiliser les miniatures"
+
+#: application/helper/ApplicationUtils.php:281
+msgid "Localized text sorting (e.g. e->è->f)"
+msgstr "Tri des textes traduits (ex : e->è->f)"
+
+#: application/helper/ApplicationUtils.php:282
+msgid "Better retrieval of bookmark metadata and thumbnail"
+msgstr "Meilleure récupération des meta-données des marque-pages et minatures"
+
+#: application/helper/ApplicationUtils.php:283
+msgid "Use the translation system in gettext mode"
+msgstr "Utiliser le système de traduction en mode gettext"
+
+#: application/helper/ApplicationUtils.php:284
+msgid "Login using LDAP server"
+msgstr "Authentification via un serveur LDAP"
+
+#: application/helper/DailyPageHelper.php:172
+msgid "Week"
+msgstr "Semaine"
+
+#: application/helper/DailyPageHelper.php:176
+msgid "Today"
+msgstr "Aujourd'hui"
+
+#: application/helper/DailyPageHelper.php:178
+msgid "Yesterday"
+msgstr "Hier"
+
+#: application/helper/FileUtils.php:100
+msgid "Provided path is not a directory."
+msgstr "Le chemin fourni n'est pas un dossier."
+
+#: application/helper/FileUtils.php:104
+msgid "Trying to delete a folder outside of Shaarli path."
+msgstr "Tentative de supprimer un dossier en dehors du chemin de Shaarli."
+
 #: application/legacy/LegacyLinkDB.php:131
 msgid "You are not authorized to add a link."
 msgstr "Vous n'êtes pas autorisé à ajouter un lien."
@@ -664,7 +776,7 @@ msgstr ""
 "a été importé avec succès en %d secondes : %d liens importés, %d liens "
 "écrasés, %d liens ignorés."
 
-#: application/plugin/PluginManager.php:122
+#: application/plugin/PluginManager.php:124
 msgid " [plugin incompatibility]: "
 msgstr " [incompatibilité de l'extension] : "
 
@@ -682,7 +794,7 @@ msgstr "Impossible de purger %s : le répertoire n'existe pas"
 msgid "An error occurred while running the update "
 msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour "
 
-#: index.php:62
+#: index.php:80
 msgid "Shared bookmarks on "
 msgstr "Liens partagés sur "
 
@@ -699,11 +811,11 @@ msgstr "Shaare"
 msgid "Adds the addlink input on the linklist page."
 msgstr "Ajoute le formulaire d'ajout de liens sur la page principale."
 
-#: plugins/archiveorg/archiveorg.php:26
+#: plugins/archiveorg/archiveorg.php:28
 msgid "View on archive.org"
 msgstr "Voir sur archive.org"
 
-#: plugins/archiveorg/archiveorg.php:39
+#: plugins/archiveorg/archiveorg.php:41
 msgid "For each link, add an Archive.org icon."
 msgstr "Pour chaque lien, ajoute une icône pour Archive.org."
 
@@ -823,7 +935,7 @@ msgstr "Mauvaise réponse du hub %s"
 msgid "Enable PubSubHubbub feed publishing."
 msgstr "Active la publication de flux vers PubSubHubbub."
 
-#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70
+#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:71
 msgid "For each link, add a QRCode icon."
 msgstr "Pour chaque lien, ajouter une icône de QRCode."
 
@@ -835,15 +947,15 @@ msgstr ""
 "Erreur de l'extension Wallabag : Merci de définir le paramètre « "
 "WALLABAG_URL » dans la page d'administration des extensions."
 
-#: plugins/wallabag/wallabag.php:47
+#: plugins/wallabag/wallabag.php:48
 msgid "Save to wallabag"
 msgstr "Sauvegarder dans Wallabag"
 
-#: plugins/wallabag/wallabag.php:71
+#: plugins/wallabag/wallabag.php:72
 msgid "Wallabag API URL"
 msgstr "URL de l'API Wallabag"
 
-#: plugins/wallabag/wallabag.php:72
+#: plugins/wallabag/wallabag.php:73
 msgid "Wallabag API version (1 or 2)"
 msgstr "Version de l'API Wallabag (1 ou 2)"
 
@@ -855,6 +967,48 @@ msgstr "Désolé, il y a rien à voir ici."
 msgid "URL or leave empty to post a note"
 msgstr "URL ou laisser vide pour créer une note"
 
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+msgid "BULK CREATION"
+msgstr "CRÉATION DE MASSE"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+msgid "Metadata asynchronous retrieval is disabled."
+msgstr "La récupération asynchrone des meta-données est désactivée."
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+msgid ""
+"We recommend that you enable the setting <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"
@@ -881,26 +1035,48 @@ msgid "Case sensitive"
 msgstr "Sensible à la casse"
 
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-msgid "Rename"
-msgstr "Renommer"
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+msgid "Rename tag"
+msgstr "Renommer le tag"
 
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:93
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
-msgid "Delete"
-msgstr "Supprimer"
+msgid "Delete tag"
+msgstr "Supprimer le tag"
 
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
 msgid "You can also edit tags in the"
 msgstr "Vous pouvez aussi modifier les tags dans la"
 
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
 msgid "tag list"
 msgstr "liste des tags"
 
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+msgid "Change tags separator"
+msgstr "Changer le séparateur de tags"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
+msgid "Your current tag separator is"
+msgstr "Votre séparateur actuel est"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
+msgid "New separator"
+msgstr "Nouveau séparateur"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
+#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
+#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
+msgid "Save"
+msgstr "Enregistrer"
+
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61
+msgid "Note that hashtags won't fully work with a non-whitespace separator."
+msgstr ""
+"Notez que les hashtags ne sont pas complètement fonctionnels avec un "
+"séparateur qui n'est pas un espace."
+
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "title"
 msgstr "titre"
@@ -1024,71 +1200,72 @@ msgstr ""
 "miniatures."
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
 msgid "Synchronize thumbnails"
 msgstr "Synchroniser les miniatures"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "All"
 msgstr "Tous"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
 msgid "Only common media hosts"
 msgstr "Seulement les hébergeurs de média connus"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
 msgid "None"
 msgstr "Aucune"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
-msgid "Save"
-msgstr "Enregistrer"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid "The Daily Shaarli"
-msgstr "Le Quotidien Shaarli"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
-msgid "1 RSS entry per day"
-msgstr "1 entrée RSS par jour"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
-msgid "Previous day"
-msgstr "Jour précédent"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "All links of one day in a single page."
-msgstr "Tous les liens d'un jour sur une page."
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
-msgid "Next day"
-msgstr "Jour suivant"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+msgid "1 RSS entry per :type"
+msgid_plural ""
+msgstr[0] "1 entrée RSS par :type"
+msgstr[1] ""
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+msgid "Previous :type"
+msgid_plural ""
+msgstr[0] ":type précédent"
+msgstr[1] "Jour précédent"
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
+msgid "All links of one :type in a single page."
+msgid_plural ""
+msgstr[0] "Tous les liens d'un :type sur une page."
+msgstr[1] "Tous les liens d'un jour sur une page."
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+msgid "Next :type"
+msgid_plural ""
+msgstr[0] ":type suivant"
+msgstr[1] ""
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
 msgid "Edit Shaare"
 msgstr "Modifier le Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
 msgid "New Shaare"
 msgstr "Nouveau Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
 msgid "Created:"
 msgstr "Création :"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 msgid "URL"
 msgstr "URL"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 msgid "Title"
 msgstr "Titre"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
@@ -1096,32 +1273,39 @@ msgstr "Titre"
 msgid "Description"
 msgstr "Description"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
-msgid "Tags"
-msgstr "Tags"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-msgid "Private"
-msgstr "Privé"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
 msgid "Description will be rendered with"
 msgstr "La description sera générée avec"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
 msgid "Markdown syntax documentation"
 msgstr "Documentation sur la syntaxe Markdown"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
 msgid "Markdown syntax"
 msgstr "la syntaxe Markdown"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:115
+msgid "Cancel"
+msgstr "Annuler"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
 msgid "Apply Changes"
 msgstr "Appliquer les changements"
 
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:126
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
+msgid "Delete"
+msgstr "Supprimer"
+
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+msgid "Save all"
+msgstr "Tout enregistrer"
+
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
 msgid "Export Database"
 msgstr "Exporter les données"
@@ -1179,10 +1363,6 @@ msgstr "Les doublons s'appuient sur les URL"
 msgid "Add default tags"
 msgstr "Ajouter des tags par défaut"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Install Shaarli"
-msgstr "Installation de Shaarli"
-
 #: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
 msgid "It looks like it's the first time you run Shaarli. Please configure it."
 msgstr ""
@@ -1215,6 +1395,10 @@ msgstr "Mes liens"
 msgid "Install"
 msgstr "Installer"
 
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190
+msgid "Server requirements"
+msgstr "Pré-requis serveur"
+
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
 msgid "shaare"
@@ -1288,8 +1472,8 @@ msgid "without any tag"
 msgstr "sans tag"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:41
 msgid "Fold"
 msgstr "Replier"
 
@@ -1313,6 +1497,10 @@ msgstr "Changer statut épinglé"
 msgid "Sticky"
 msgstr "Épinglé"
 
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+msgid "Share a private link"
+msgstr "Partager un lien privé"
+
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5
 msgid "Filters"
@@ -1331,7 +1519,7 @@ msgstr "Afficher uniquement les liens publics"
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18
 msgid "Filter untagged links"
-msgstr "Filtrer par liens privés"
+msgstr "Filtrer par liens sans tag"
 
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24
@@ -1342,8 +1530,8 @@ msgstr "Tout sélectionner"
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:29
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:89
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
 msgid "Fold all"
 msgstr "Replier tout"
 
@@ -1359,9 +1547,9 @@ msgid "Remember me"
 msgstr "Rester connecté"
 
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:49
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
 msgid "by the Shaarli community"
 msgstr "par la communauté Shaarli"
 
@@ -1370,21 +1558,26 @@ msgstr "par la communauté Shaarli"
 msgid "Documentation"
 msgstr "Documentation"
 
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
 msgid "Expand"
 msgstr "Déplier"
 
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
 msgid "Expand all"
 msgstr "Déplier tout"
 
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:47
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45
 msgid "Are you sure you want to delete this link?"
 msgstr "Êtes-vous sûr de vouloir supprimer ce lien ?"
 
+#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46
+msgid "Are you sure you want to delete this tag?"
+msgstr "Êtes-vous sûr de vouloir supprimer ce tag ?"
+
 #: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:11
 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:11
 msgid "Menu"
@@ -1511,6 +1704,100 @@ msgstr "Configuration des extensions"
 msgid "No parameter available."
 msgstr "Aucun paramètre disponible."
 
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+msgid "General"
+msgstr "Général"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+msgid "Index URL"
+msgstr "URL de l'index"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Base path"
+msgstr "Chemin de base"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+msgid "Client IP"
+msgstr "IP du client"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
+msgid "Trusted reverse proxies"
+msgstr "Reverse proxies de confiance"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
+msgid "N/A"
+msgstr "N/A"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
+msgid "Visit releases page on Github"
+msgstr "Visiter la page des releases sur Github"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
+msgid "Synchronize all link thumbnails"
+msgstr "Synchroniser toutes les miniatures"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:2
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:2
+msgid "Permissions"
+msgstr "Permissions"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:8
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:8
+msgid "There are permissions that need to be fixed."
+msgstr "Il y a des permissions qui doivent être corrigées."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:23
+msgid "All read/write permissions are properly set."
+msgstr "Toutes les permissions de lecture/écriture sont définies correctement."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:32
+msgid "Running PHP"
+msgstr "Fonctionnant avec PHP"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:36
+msgid "End of life: "
+msgstr "Fin de vie : "
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:48
+msgid "Extension"
+msgstr "Extension"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:49
+msgid "Usage"
+msgstr "Utilisation"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:50
+msgid "Status"
+msgstr "Statut"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:51
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:66
+msgid "Loaded"
+msgstr "Chargé"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Required"
+msgstr "Obligatoire"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Optional"
+msgstr "Optionnel"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:70
+msgid "Not loaded"
+msgstr "Non chargé"
+
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 msgid "tags"
@@ -1525,10 +1812,6 @@ msgstr "Lister tous les liens avec ces tags"
 msgid "Tag list"
 msgstr "Liste des tags"
 
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
-msgid "Rename tag"
-msgstr "Renommer le tag"
-
 #: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3
 #: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3
 msgid "Sort by:"
@@ -1565,15 +1848,19 @@ msgstr "Configurer Shaarli"
 msgid "Enable, disable and configure plugins"
 msgstr "Activer, désactiver et configurer les extensions"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
+msgid "Check instance's server configuration"
+msgstr "Vérifier la configuration serveur de l'instance"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
 msgid "Change your password"
 msgstr "Modifier le mot de passe"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 msgid "Rename or delete a tag in all links"
 msgstr "Renommer ou supprimer un tag dans tous les liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 msgid ""
 "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
 "delicious...)"
@@ -1581,11 +1868,11 @@ msgstr ""
 "Importer des marques pages au format Netscape HTML (comme exportés depuis "
 "Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 msgid "Import links"
 msgstr "Importer des liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
 msgid ""
 "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
 "Opera, delicious...)"
@@ -1593,15 +1880,11 @@ msgstr ""
 "Exporter les marques pages au format Netscape HTML (comme exportés depuis "
 "Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54
 msgid "Export database"
 msgstr "Exporter les données"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:55
-msgid "Synchronize all link thumbnails"
-msgstr "Synchroniser toutes les miniatures"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
 msgid ""
 "Drag one of these button to your bookmarks toolbar or right-click it and "
 "\"Bookmark This Link\""
@@ -1609,13 +1892,13 @@ msgstr ""
 "Glisser un de ces boutons dans votre barre de favoris ou cliquer droit "
 "dessus et « Ajouter aux favoris »"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
 msgid "then click on the bookmarklet in any page you want to share."
 msgstr ""
 "puis cliquer sur le marque-page depuis un site que vous souhaitez partager."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
 msgid ""
 "Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
 "Link"
@@ -1623,40 +1906,40 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « "
 "Ajouter aux favoris »"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
 msgid "then click ✚Shaare link button in any page you want to share"
 msgstr "puis cliquer sur ✚Shaare depuis un site que vous souhaitez partager"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
 msgid "The selected text is too long, it will be truncated."
 msgstr "Le texte sélectionné est trop long, il sera tronqué."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "Shaare link"
 msgstr "Shaare"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:107
 msgid ""
 "Then click ✚Add Note button anytime to start composing a private Note (text "
 "post) to your Shaarli"
 msgstr ""
 "Puis cliquer sur ✚Add Note pour commencer à rédiger une Note sur Shaarli"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
 msgid "Add Note"
 msgstr "Ajouter une Note"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:132
 msgid "3rd party"
 msgstr "Applications tierces"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:140
 msgid "plugin"
 msgstr "extension"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
 msgid ""
 "Drag this link to your bookmarks toolbar, or right-click it and choose "
 "Bookmark This Link"
@@ -1664,6 +1947,12 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « "
 "Ajouter aux favoris »"
 
+#~ msgid "Display:"
+#~ msgstr "Afficher :"
+
+#~ msgid "The Daily Shaarli"
+#~ msgstr "Le Quotidien Shaarli"
+
 #, fuzzy
 #~| msgid "Selection"
 #~ msgid ".ui-selecting"
index b420bb519d4caa105bedadc01ae12e238df04abc..57f42fc2af44bb44145e279d3ce82de5b230a2f3 100644 (file)
@@ -2,15 +2,15 @@ msgid ""
 msgstr ""
 "Project-Id-Version: Shaarli\n"
 "Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-02-11 09:31+0900\n"
-"PO-Revision-Date: 2020-02-11 10:54+0900\n"
+"POT-Creation-Date: 2020-10-19 10:19+0900\n"
+"PO-Revision-Date: 2020-10-19 10:25+0900\n"
 "Last-Translator: yude <yudesleepy@gmail.com>\n"
 "Language-Team: Shaarli\n"
 "Language: ja\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"X-Generator: Poedit 2.3\n"
+"X-Generator: Poedit 2.2.3\n"
 "X-Poedit-Basepath: ../../../..\n"
 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
 "X-Poedit-SourceCharset: UTF-8\n"
@@ -19,7 +19,7 @@ msgstr ""
 "X-Poedit-SearchPathExcluded-0: node_modules\n"
 "X-Poedit-SearchPathExcluded-1: vendor\n"
 
-#: application/ApplicationUtils.php:153
+#: application/ApplicationUtils.php:161
 #, php-format
 msgid ""
 "Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
@@ -30,200 +30,250 @@ msgstr ""
 "が必要です。 現在使用している PHP のバージョンには脆弱性があり、できるだけ速"
 "やかにアップデートするべきです。"
 
-#: application/ApplicationUtils.php:183 application/ApplicationUtils.php:195
+#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204
 msgid "directory is not readable"
 msgstr "ディレクトリを読み込めません"
 
-#: application/ApplicationUtils.php:198
+#: application/ApplicationUtils.php:207
 msgid "directory is not writable"
 msgstr "ディレクトリに書き込めません"
 
-#: application/ApplicationUtils.php:216
+#: application/ApplicationUtils.php:225
 msgid "file is not readable"
 msgstr "ファイルを読み取る権限がありません"
 
-#: application/ApplicationUtils.php:219
+#: application/ApplicationUtils.php:228
 msgid "file is not writable"
 msgstr "ファイルを書き込む権限がありません"
 
-#: application/Cache.php:16
-#, php-format
-msgid "Cannot purge %s: no directory"
-msgstr "%s を削除できません: ディレクトリが存在しません"
-
-#: application/FeedBuilder.php:151
-msgid "Direct link"
-msgstr "ダイレクトリンク"
-
-#: application/FeedBuilder.php:153
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:178
-msgid "Permalink"
-msgstr "パーマリンク"
-
-#: application/History.php:174
+#: application/History.php:179
 msgid "History file isn't readable or writable"
 msgstr "履歴ファイルを読み込む、または書き込むための権限がありません"
 
-#: application/History.php:185
+#: application/History.php:190
 msgid "Could not parse history file"
 msgstr "履歴ファイルを正常に復元できませんでした"
 
-#: application/Languages.php:177
+#: application/Languages.php:181
 msgid "Automatic"
 msgstr "自動"
 
-#: application/Languages.php:178
+#: application/Languages.php:182
+msgid "German"
+msgstr "ドイツ語"
+
+#: application/Languages.php:183
 msgid "English"
 msgstr "英語"
 
-#: application/Languages.php:179
+#: application/Languages.php:184
 msgid "French"
 msgstr "フランス語"
 
-#: application/Languages.php:180
-msgid "German"
-msgstr "ドイツ語"
-
-#: application/LinkDB.php:136
-msgid "You are not authorized to add a link."
-msgstr "リンクを追加するには、ログインする必要があります。"
-
-#: application/LinkDB.php:139
-msgid "Internal Error: A link should always have an id and URL."
-msgstr "エラー: リンクにはIDとURLを登録しなければなりません。"
-
-#: application/LinkDB.php:142
-msgid "You must specify an integer as a key."
-msgstr "正常なキーの値ではありません。"
-
-#: application/LinkDB.php:145
-msgid "Array offset and link ID must be equal."
-msgstr "Array オフセットとリンクのIDは同じでなければなりません。"
-
-#: application/LinkDB.php:251
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
-msgid ""
-"The personal, minimalist, super-fast, database free, bookmarking service"
-msgstr ""
-"個人向けの、ミニマムで高速でかつデータベースのいらないブックマークサービス"
-
-#: application/LinkDB.php:253
-msgid ""
-"Welcome to Shaarli! This is your first public bookmark. To edit or delete "
-"me, you must first login.\n"
-"\n"
-"To learn how to use Shaarli, consult the link \"Documentation\" at the "
-"bottom of this page.\n"
-"\n"
-"You use the community supported version of the original Shaarli project, by "
-"Sebastien Sauvage."
-msgstr ""
-"Shaarli へようこそ! これはあなたの最初の公開ブックマークです。これを編集した"
-"り削除したりするには、ログインする必要があります。\n"
-"\n"
-"Shaarli の使い方を知るには、このページの下にある「ドキュメント」のリンクを開"
-"いてください。\n"
-"\n"
-"あなたは Sebastien Sauvage による、コミュニティーサポートのあるバージョンのオ"
-"リジナルのShaarli プロジェクトを使用しています。"
-
-#: application/LinkDB.php:267
-msgid "My secret stuff... - Pastebin.com"
-msgstr "わたしのひ💗み💗つ💗 - Pastebin.com"
-
-#: application/LinkDB.php:269
-msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
-msgstr ""
-"シーッ! これはあなたしか見られないプライベートリンクです。消すこともできま"
-"す。"
-
-#: application/LinkFilter.php:452
-msgid "The link you are trying to reach does not exist or has been deleted."
-msgstr "開こうとしたリンクは存在しないか、削除されています。"
-
-#: application/NetscapeBookmarkUtils.php:35
-msgid "Invalid export selection:"
-msgstr "不正なエクスポートの選択:"
-
-#: application/NetscapeBookmarkUtils.php:81
-#, php-format
-msgid "File %s (%d bytes) "
-msgstr "ファイル %s (%d バイト) "
-
-#: application/NetscapeBookmarkUtils.php:83
-msgid "has an unknown file format. Nothing was imported."
-msgstr "は不明なファイル形式です。インポートは中止されました。"
+#: application/Languages.php:185
+msgid "Japanese"
+msgstr "日本語"
 
-#: application/NetscapeBookmarkUtils.php:86
-#, php-format
+#: application/Thumbnailer.php:62
 msgid ""
-"was successfully processed in %d seconds: %d links imported, %d links "
-"overwritten, %d links skipped."
+"php-gd extension must be loaded to use thumbnails. Thumbnails are now "
+"disabled. Please reload the page."
 msgstr ""
-"が %d 秒で処理され、%d 件のリンクがインポートされ、%d 件のリンクが上書きさ"
-"れ、%d 件のリンクがスキップされました。"
-
-#: application/PageBuilder.php:168
-msgid "The page you are trying to reach does not exist or has been deleted."
-msgstr "あなたが開こうとしたページは存在しないか、削除されています。"
-
-#: application/PageBuilder.php:170
-msgid "404 Not Found"
-msgstr "404 ページが存在しません"
-
-#: application/PluginManager.php:243
-#, php-format
-msgid "Plugin \"%s\" files not found."
-msgstr "プラグイン「%s」のファイルが存在しません。"
-
-#: application/Updater.php:76
-msgid "Couldn't retrieve Updater class methods."
-msgstr "アップデーターのクラスメゾットを受信できませんでした。"
-
-#: application/Updater.php:532
-msgid "An error occurred while running the update "
-msgstr "更新中に問題が発生しました "
-
-#: application/Updater.php:572
-msgid "Updates file path is not set, can't write updates."
-msgstr "更新するファイルのパスが指定されていないため、更新を書き込めません。"
+"サムネイルを使用するには、php-gd エクステンションが読み込まれている必要があり"
+"ます。サムネイルは無効化されました。ページを再読込してください。"
 
-#: application/Updater.php:577
-msgid "Unable to write updates in "
-msgstr "更新を次の項目に書き込めませんでした: "
-
-#: application/Utils.php:376 tests/UtilsTest.php:340
+#: application/Utils.php:383 tests/UtilsTest.php:343
 msgid "Setting not set"
 msgstr "未設定"
 
-#: application/Utils.php:383 tests/UtilsTest.php:338 tests/UtilsTest.php:339
+#: application/Utils.php:390 tests/UtilsTest.php:341 tests/UtilsTest.php:342
 msgid "Unlimited"
 msgstr "無制限"
 
-#: application/Utils.php:386 tests/UtilsTest.php:335 tests/UtilsTest.php:336
-#: tests/UtilsTest.php:350
+#: application/Utils.php:393 tests/UtilsTest.php:338 tests/UtilsTest.php:339
+#: tests/UtilsTest.php:353
 msgid "B"
 msgstr "B"
 
-#: application/Utils.php:386 tests/UtilsTest.php:329 tests/UtilsTest.php:330
-#: tests/UtilsTest.php:337
+#: application/Utils.php:393 tests/UtilsTest.php:332 tests/UtilsTest.php:333
+#: tests/UtilsTest.php:340
 msgid "kiB"
 msgstr "kiB"
 
-#: application/Utils.php:386 tests/UtilsTest.php:331 tests/UtilsTest.php:332
-#: tests/UtilsTest.php:348 tests/UtilsTest.php:349
+#: application/Utils.php:393 tests/UtilsTest.php:334 tests/UtilsTest.php:335
+#: tests/UtilsTest.php:351 tests/UtilsTest.php:352
 msgid "MiB"
 msgstr "MiB"
 
-#: application/Utils.php:386 tests/UtilsTest.php:333 tests/UtilsTest.php:334
+#: application/Utils.php:393 tests/UtilsTest.php:336 tests/UtilsTest.php:337
 msgid "GiB"
 msgstr "GiB"
 
-#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:121
+#: application/bookmark/BookmarkFileService.php:180
+#: application/bookmark/BookmarkFileService.php:202
+#: application/bookmark/BookmarkFileService.php:224
+#: application/bookmark/BookmarkFileService.php:238
+msgid "You're not authorized to alter the datastore"
+msgstr "設定を変更する権限がありません"
+
+#: application/bookmark/BookmarkFileService.php:205
+msgid "This bookmarks already exists"
+msgstr "このブックマークは既に存在します。"
+
+#: application/bookmark/BookmarkInitializer.php:39
+msgid "(private bookmark with thumbnail demo)"
+msgstr "(サムネイルデモが付属しているプライベートブックマーク)"
+
+#: application/bookmark/BookmarkInitializer.php:42
+msgid ""
+"Shaarli will automatically pick up the thumbnail for links to a variety of "
+"websites.\n"
+"\n"
+"Explore your new Shaarli instance by trying out controls and menus.\n"
+"Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the "
+"documentation](https://shaarli.readthedocs.io/en/master/) to learn more "
+"about Shaarli.\n"
+"\n"
+"Now you can edit or delete the default shaares.\n"
+msgstr ""
+"Shaarli は自動的に多様なウェブサイトのサムネイルを取得します。\n"
+"\n"
+"あなたの新しい Shaarli インスタンスをコントロールやメニューを試したりして、探"
+"検してください。\n"
+" [Github](https://github.com/shaarli/Shaarli) または [the documentation]"
+"(https://shaarli.readthedocs.io/en/master/) でプロジェクトを訪問して、"
+"Shaarli をもっとよく知ることができます。\n"
+"\n"
+"今から、既定の shaares を編集したり、削除したりすることができます。\n"
+
+#: application/bookmark/BookmarkInitializer.php:55
+msgid "Note: Shaare descriptions"
+msgstr "説明: Shaare の概要"
+
+#: application/bookmark/BookmarkInitializer.php:57
+msgid ""
+"Adding a shaare without entering a URL creates a text-only \"note\" post "
+"such as this one.\n"
+"This note is private, so you are the only one able to see it while logged "
+"in.\n"
+"\n"
+"You can use this to keep notes, post articles, code snippets, and much "
+"more.\n"
+"\n"
+"The Markdown formatting setting allows you to format your notes and bookmark "
+"description:\n"
+"\n"
+"### Title headings\n"
+"\n"
+"#### Multiple headings levels\n"
+"  * bullet lists\n"
+"  * _italic_ text\n"
+"  * **bold** text\n"
+"  * ~~strike through~~ text\n"
+"  * `code` blocks\n"
+"  * images\n"
+"  * [links](https://en.wikipedia.org/wiki/Markdown)\n"
+"\n"
+"Markdown also supports tables:\n"
+"\n"
+"| Name    | Type      | Color  | Qty   |\n"
+"| ------- | --------- | ------ | ----- |\n"
+"| Orange  | Fruit     | Orange | 126   |\n"
+"| Apple   | Fruit     | Any    | 62    |\n"
+"| Lemon   | Fruit     | Yellow | 30    |\n"
+"| Carrot  | Vegetable | Red    | 14    |\n"
+msgstr ""
+"URL を追加せずに shaare を作成すると、テキストのみのこのような \"ノート\" が"
+"作成されます。\n"
+"このノートはプライベートなので、ログイン中のあなたしか見ることはできませ"
+"ん。\n"
+"\n"
+"あなたはこれをメモ帳として使ったり、記事を投稿したり、コード スニペットとした"
+"りするなどといったことに使えます。\n"
+"\n"
+"Markdown フォーマットの設定により、ノートやブックマークの概要を以下のように"
+"フォーマットできます:\n"
+"\n"
+"### タイトル ヘッダー\n"
+"\n"
+"#### 複数の見出し\n"
+" * 箇条書きリスト\n"
+" * _イタリック_ 文字\n"
+" * **ボールド** 文字\n"
+" * ~~打ち消し~~ 文字\n"
+" * `コード` ブロック\n"
+" * 画像\n"
+" * [リンク](https://en.wikipedia.org/wiki/Markdown)\n"
+"\n"
+"Markdown は表もサポートします:\n"
+"\n"
+"| 名前    | 種類      | 色  | 数量   |\n"
+"| ------- | --------- | ------ | ----- |\n"
+"| オレンジ  | 果物     | 橙 | 126   |\n"
+"| リンゴ   | 果物     | 任意    | 62    |\n"
+"| レモン   | 果物     | 黄 | 30    |\n"
+"| 人参  | 野菜 | 赤    | 14    |\n"
+
+#: application/bookmark/BookmarkInitializer.php:91
+#: application/legacy/LegacyLinkDB.php:246
+msgid ""
+"The personal, minimalist, super-fast, database free, bookmarking service"
+msgstr ""
+"個人向けの、ミニマムで高速でかつデータベースのいらないブックマークサービス"
+
+#: application/bookmark/BookmarkInitializer.php:94
+msgid ""
+"Welcome to Shaarli!\n"
+"\n"
+"Shaarli allows you to bookmark your favorite pages, and share them with "
+"others or store them privately.\n"
+"You can add a description to your bookmarks, such as this one, and tag "
+"them.\n"
+"\n"
+"Create a new shaare by clicking the `+Shaare` button, or using any of the "
+"recommended tools (browser extension, mobile app, bookmarklet, REST API, "
+"etc.).\n"
+"\n"
+"You can easily retrieve your links, even with thousands of them, using the "
+"internal search engine, or search through tags (e.g. this Shaare is tagged "
+"with `shaarli` and `help`).\n"
+"Hashtags such as #shaarli #help are also supported.\n"
+"You can also filter the available [RSS feed](/feed/atom) and picture wall by "
+"tag or plaintext search.\n"
+"\n"
+"We hope that you will enjoy using Shaarli, maintained with ❤️ by the "
+"community!\n"
+"Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if "
+"you have a suggestion or encounter an issue.\n"
+msgstr ""
+"Shaarli へようこそ!\n"
+"\n"
+"Shaarli では、あなたのお気に入りのページをブックマークしたり、それを他の人と"
+"共有するか、またはプライベートなものとして保管することができます。\n"
+"加えて、あなたのブックマークにこの項目のように概要を追加したり、タグ付けした"
+"りすることができます。\n"
+"\n"
+"`+Shaare` ボタンをクリックすることで新しい shaare を作成できます。また、推奨"
+"されたツールを使うこともできます (ブラウザー 拡張機能、モバイル アプリ、ブッ"
+"クマークレット、REST API など...)。\n"
+"\n"
+"また、簡単にあなたのリンクを取得できます。それが何千と登る数であっても、内部"
+"の検索エンジンや、タグを使って検索できます (例えば、この Shaare は `shaarli` "
+"と `help` というタグが付いています)。\n"
+"#shaarli や #help といったハッシュタグもサポートされています。\n"
+"タグやテキスト検索による [RSS フィード](/feed/atom) や ピクチャー ウォール で"
+"項目を絞ることもできます。\n"
+"\n"
+"私たちはあなたが Shaarli を楽しんでくれることを願っています。Shaarli はコミュ"
+"ニティーによって ♡ と共にメンテナンスされています!\n"
+"何か問題に遭遇したり、提案があれば、気軽に  [Issue](https://github.com/"
+"shaarli/Shaarli/issues) を開いてください。\n"
+
+#: application/bookmark/exception/BookmarkNotFoundException.php:13
+msgid "The link you are trying to reach does not exist or has been deleted."
+msgstr "開こうとしたリンクは存在しないか、削除されています。"
+
+#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:129
 msgid ""
 "Shaarli could not create the config file. Please make sure Shaarli has the "
 "right to write in the folder is it installed in."
@@ -232,7 +282,8 @@ msgstr ""
 "ていて、インストールされているディレクトリに書き込みできることを確認してくだ"
 "さい。"
 
-#: application/config/ConfigManager.php:135
+#: application/config/ConfigManager.php:136
+#: application/config/ConfigManager.php:163
 msgid "Invalid setting key parameter. String expected, got: "
 msgstr ""
 "不正なキーの値です。文字列が想定されていますが、次のように入力されました: "
@@ -250,159 +301,185 @@ msgstr "プラグインの読込順を変更する際にエラーが発生しま
 msgid "You are not authorized to alter config."
 msgstr "設定を変更する権限がありません。"
 
-#: application/exceptions/IOException.php:19
+#: application/exceptions/IOException.php:22
 msgid "Error accessing"
 msgstr "読込中にエラーが発生しました"
 
-#: index.php:142
-msgid "Shared links on "
-msgstr "次において共有されたリンク:"
+#: application/feed/FeedBuilder.php:179
+msgid "Direct link"
+msgstr "ダイレクトリンク"
 
-#: index.php:164
-msgid "Insufficient permissions:"
-msgstr "権限がありません:"
+#: application/feed/FeedBuilder.php:181
+msgid "Permalink"
+msgstr "パーマリンク"
 
-#: index.php:303
-msgid "I said: NO. You are banned for the moment. Go away."
-msgstr "あなたはこのサーバーからBANされています。"
+#: application/front/controller/admin/ConfigureController.php:54
+msgid "Configure"
+msgstr "設定"
 
-#: index.php:368
-msgid "Wrong login/password."
-msgstr "不正なユーザー名、またはパスワードです。"
+#: application/front/controller/admin/ConfigureController.php:102
+#: application/legacy/LegacyUpdater.php:537
+msgid "You have enabled or changed thumbnails mode."
+msgstr "サムネイルのモードを有効化、または変更しました。"
 
-#: index.php:576 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:42
-msgid "Daily"
-msgstr "ã\83\87ã\82¤ã\83ªã\83¼"
+#: application/front/controller/admin/ConfigureController.php:103
+#: application/legacy/LegacyUpdater.php:538
+msgid "Please synchronize them."
+msgstr "ã\81\9dã\82\8cã\82\89ã\82\92å\90\8cæ\9c\9fã\81\97ã\81¦ã\81\8fã\81 ã\81\95ã\81\84ã\80\82"
 
-#: index.php:681 tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:95
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:71
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:95
-msgid "Login"
-msgstr "ログイン"
+#: application/front/controller/admin/ConfigureController.php:113
+#: application/front/controller/visitor/InstallController.php:136
+msgid "Error while writing config file after configuration update."
+msgstr "設定ファイルを更新した後の書き込みに失敗しました。"
 
-#: index.php:722 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:39
-msgid "Picture wall"
-msgstr "ピクチャウォール"
+#: application/front/controller/admin/ConfigureController.php:122
+msgid "Configuration was saved."
+msgstr "設定は保存されました。"
 
-#: index.php:770 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag cloud"
-msgstr "タグクラウド"
+#: application/front/controller/admin/ExportController.php:26
+msgid "Export"
+msgstr "エクスポート"
 
-#: index.php:803 tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag list"
-msgstr "ã\82¿ã\82°ä¸\80覧"
+#: application/front/controller/admin/ExportController.php:42
+msgid "Please select an export mode."
+msgstr "ã\82¨ã\82¯ã\82¹ã\83\9dã\83¼ã\83\88 ã\83¢ã\83¼ã\83\89ã\82\92æ\8c\87å®\9aã\81\97ã\81¦ã\81\8fã\81 ã\81\95ã\81\84ã\80\82"
 
-#: index.php:1028 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:31
-msgid "Tools"
-msgstr "ツール"
+#: application/front/controller/admin/ImportController.php:41
+msgid "Import"
+msgstr "インポート"
 
-#: index.php:1037
-msgid "You are not supposed to change a password on an Open Shaarli."
+#: application/front/controller/admin/ImportController.php:55
+msgid "No import file provided."
+msgstr "何のインポート元ファイルも指定されませんでした。"
+
+#: application/front/controller/admin/ImportController.php:66
+#, php-format
+msgid ""
+"The file you are trying to upload is probably bigger than what this "
+"webserver can accept (%s). Please upload in smaller chunks."
 msgstr ""
-"公開されている Shaarli において、パスワードを変更することは想定されていませ"
-"ん。"
+"あなたがアップロードしようとしているファイルは、サーバーが許可しているファイ"
+"ルサイズ (%s) よりも大きいです。もう少し小さいものをアップロードしてくださ"
+"い。"
 
-#: index.php:1042 index.php:1084 index.php:1160 index.php:1191 index.php:1291
-msgid "Wrong token."
-msgstr "不正なトークンです。"
+#: application/front/controller/admin/ManageShaareController.php:29
+msgid "Shaare a new link"
+msgstr "新しいリンクを追加"
 
-#: index.php:1047
-msgid "The old password is not correct."
-msgstr "元のパスワードが正しくありません。"
+#: application/front/controller/admin/ManageShaareController.php:78
+msgid "Note: "
+msgstr "注: "
 
-#: index.php:1067
-msgid "Your password has been changed"
-msgstr "あなたのパスワードは変更されました"
+#: application/front/controller/admin/ManageShaareController.php:109
+#: application/front/controller/admin/ManageShaareController.php:206
+#: application/front/controller/admin/ManageShaareController.php:275
+#: application/front/controller/admin/ManageShaareController.php:315
+#, php-format
+msgid "Bookmark with identifier %s could not be found."
+msgstr "%s という識別子を持ったブックマークは見つかりませんでした。"
 
-#: index.php:1072
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Change password"
-msgstr "パスワードを変更"
+#: application/front/controller/admin/ManageShaareController.php:194
+#: application/front/controller/admin/ManageShaareController.php:252
+msgid "Invalid bookmark ID provided."
+msgstr "不正なブックマーク ID が入力されました。"
 
-#: index.php:1120
-msgid "Configuration was saved."
-msgstr "設定は保存されました。"
+#: application/front/controller/admin/ManageShaareController.php:260
+msgid "Invalid visibility provided."
+msgstr "不正な公開設定が入力されました。"
 
-#: index.php:1143 tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "Configure"
-msgstr "設定"
+#: application/front/controller/admin/ManageShaareController.php:363
+msgid "Edit"
+msgstr "共有"
+
+#: application/front/controller/admin/ManageShaareController.php:366
+msgid "Shaare"
+msgstr "Shaare"
 
-#: index.php:1154 tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: application/front/controller/admin/ManageTagController.php:29
 msgid "Manage tags"
 msgstr "タグを設定"
 
-#: index.php:1172
+#: application/front/controller/admin/ManageTagController.php:48
+msgid "Invalid tags provided."
+msgstr "不正なタグが入力されました。"
+
+#: application/front/controller/admin/ManageTagController.php:72
 #, php-format
-msgid "The tag was removed from %d link."
-msgid_plural "The tag was removed from %d links."
+msgid "The tag was removed from %d bookmark."
+msgid_plural "The tag was removed from %d bookmarks."
 msgstr[0] "%d 件のリンクからタグが削除されました。"
-msgstr[1] "The tag was removed from %d links."
+msgstr[1] "%d 件のリンクからタグが削除されました。"
 
-#: index.php:1173
+#: application/front/controller/admin/ManageTagController.php:77
 #, php-format
-msgid "The tag was renamed in %d link."
-msgid_plural "The tag was renamed in %d links."
-msgstr[0] "ã\82¿ã\82°ã\81\8c %d 件のリンクにおいて、名前が変更されました。"
-msgstr[1] "ã\82¿ã\82°ã\81\8c %d 件のリンクにおいて、名前が変更されました。"
+msgid "The tag was renamed in %d bookmark."
+msgid_plural "The tag was renamed in %d bookmarks."
+msgstr[0] "ã\81\93ã\81®ã\82¿ã\82°ã\82\92æ\8c\81ã\81¤ %d 件のリンクにおいて、名前が変更されました。"
+msgstr[1] "ã\81\93ã\81®ã\82¿ã\82°ã\82\92æ\8c\81ã\81¤ %d 件のリンクにおいて、名前が変更されました。"
 
-#: index.php:1181 tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-msgid "Shaare a new link"
-msgstr "新しいリンクを追加"
+#: application/front/controller/admin/PasswordController.php:28
+msgid "Change password"
+msgstr "パスワードを変更"
 
-#: index.php:1351 tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170
-msgid "Edit"
-msgstr "共有"
+#: application/front/controller/admin/PasswordController.php:55
+msgid "You must provide the current and new password to change it."
+msgstr ""
+"パスワードを変更するには、現在のパスワードと、新しいパスワードを入力する必要"
+"があります。"
 
-#: index.php:1351 index.php:1421
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:26
-msgid "Shaare"
-msgstr "Shaare"
+#: application/front/controller/admin/PasswordController.php:71
+msgid "The old password is not correct."
+msgstr "元のパスワードが正しくありません。"
 
-#: index.php:1390
-msgid "Note: "
-msgstr "注: "
+#: application/front/controller/admin/PasswordController.php:97
+msgid "Your password has been changed"
+msgstr "あなたのパスワードは変更されました"
 
-#: index.php:1430 tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
-msgid "Export"
-msgstr "ã\82¨ã\82¯ã\82¹ã\83\9dã\83¼ã\83\88"
+#: application/front/controller/admin/PluginsController.php:45
+msgid "Plugin Administration"
+msgstr "ã\83\97ã\83©ã\82°ã\82¤ã\83³ç®¡ç\90\86"
 
-#: index.php:1492 tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
-msgid "Import"
-msgstr "インポート"
+#: application/front/controller/admin/PluginsController.php:76
+msgid "Setting successfully saved."
+msgstr "設定が正常に保存されました。"
 
-#: index.php:1502
-#, php-format
-msgid ""
-"The file you are trying to upload is probably bigger than what this "
-"webserver can accept (%s). Please upload in smaller chunks."
-msgstr ""
-"あなたがアップロードしようとしているファイルは、サーバーが許可しているファイ"
-"ルサイズ (%s) よりも大きいです。もう少し小さいものをアップロードしてくださ"
-"い。"
+#: application/front/controller/admin/PluginsController.php:79
+msgid "Error while saving plugin configuration: "
+msgstr "プラグインの設定ファイルを保存するときにエラーが発生しました: "
 
-#: index.php:1541 tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Plugin administration"
-msgstr "プラグイン管理"
+#: application/front/controller/admin/ThumbnailsController.php:37
+msgid "Thumbnails update"
+msgstr "サムネイルの更新"
+
+#: application/front/controller/admin/ToolsController.php:31
+msgid "Tools"
+msgstr "ツール"
 
-#: index.php:1706
+#: application/front/controller/visitor/BookmarkListController.php:116
 msgid "Search: "
 msgstr "検索: "
 
-#: index.php:1933
+#: application/front/controller/visitor/DailyController.php:45
+msgid "Today"
+msgstr "今日"
+
+#: application/front/controller/visitor/DailyController.php:47
+msgid "Yesterday"
+msgstr "昨日"
+
+#: application/front/controller/visitor/DailyController.php:85
+msgid "Daily"
+msgstr "デイリー"
+
+#: application/front/controller/visitor/ErrorController.php:36
+msgid "An unexpected error occurred."
+msgstr "予期しないエラーが発生しました。"
+
+#: application/front/controller/visitor/ErrorNotFoundController.php:25
+msgid "Requested page could not be found."
+msgstr "リクエストされたページは存在しません。"
+
+#: application/front/controller/visitor/InstallController.php:73
 #, php-format
 msgid ""
 "<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
@@ -420,32 +497,205 @@ msgstr ""
 "ります。IP アドレスや完全なドメイン名でサーバーにアクセスすることをおすすめし"
 "ます。<br>"
 
-#: index.php:1943
-msgid "Click to try again."
-msgstr "クリックして再度試します。"
+#: application/front/controller/visitor/InstallController.php:144
+msgid ""
+"Shaarli is now configured. Please login and start shaaring your bookmarks!"
+msgstr ""
+"Shaarli の設定が完了しました。ログインして、あなたのブックマークを登録しま"
+"しょう!"
+
+#: application/front/controller/visitor/InstallController.php:158
+msgid "Insufficient permissions:"
+msgstr "権限がありません:"
+
+#: application/front/controller/visitor/LoginController.php:46
+msgid "Login"
+msgstr "ログイン"
+
+#: application/front/controller/visitor/LoginController.php:78
+msgid "Wrong login/password."
+msgstr "不正なユーザー名、またはパスワードです。"
+
+#: application/front/controller/visitor/PictureWallController.php:29
+msgid "Picture wall"
+msgstr "ピクチャウォール"
+
+#: application/front/controller/visitor/TagCloudController.php:88
+msgid "Tag "
+msgstr "タグ "
+
+#: application/front/exceptions/AlreadyInstalledException.php:11
+msgid "Shaarli has already been installed. Login to edit the configuration."
+msgstr "Shaarli がインストールされました。ログインして設定を変更できます。"
+
+#: application/front/exceptions/LoginBannedException.php:11
+msgid ""
+"You have been banned after too many failed login attempts. Try again later."
+msgstr "複数回に渡るログインへの失敗を検出しました。後でまた試してください。"
+
+#: application/front/exceptions/OpenShaarliPasswordException.php:16
+msgid "You are not supposed to change a password on an Open Shaarli."
+msgstr ""
+"公開されている Shaarli において、パスワードを変更することは想定されていませ"
+"ん。"
+
+#: application/front/exceptions/ThumbnailsDisabledException.php:11
+msgid "Picture wall unavailable (thumbnails are disabled)."
+msgstr "ピクチャ ウォールは利用できません (サムネイルが無効化されています)。"
+
+#: application/front/exceptions/WrongTokenException.php:16
+msgid "Wrong token."
+msgstr "不正なトークンです。"
+
+#: application/legacy/LegacyLinkDB.php:131
+msgid "You are not authorized to add a link."
+msgstr "リンクを追加するには、ログインする必要があります。"
+
+#: application/legacy/LegacyLinkDB.php:134
+msgid "Internal Error: A link should always have an id and URL."
+msgstr "エラー: リンクにはIDとURLを登録しなければなりません。"
+
+#: application/legacy/LegacyLinkDB.php:137
+msgid "You must specify an integer as a key."
+msgstr "正常なキーの値ではありません。"
+
+#: application/legacy/LegacyLinkDB.php:140
+msgid "Array offset and link ID must be equal."
+msgstr "Array オフセットとリンクのIDは同じでなければなりません。"
+
+#: application/legacy/LegacyLinkDB.php:249
+msgid ""
+"Welcome to Shaarli! This is your first public bookmark. To edit or delete "
+"me, you must first login.\n"
+"\n"
+"To learn how to use Shaarli, consult the link \"Documentation\" at the "
+"bottom of this page.\n"
+"\n"
+"You use the community supported version of the original Shaarli project, by "
+"Sebastien Sauvage."
+msgstr ""
+"Shaarli へようこそ! これはあなたの最初の公開ブックマークです。これを編集した"
+"り削除したりするには、ログインする必要があります。\n"
+"\n"
+"Shaarli の使い方を知るには、このページの下にある「ドキュメント」のリンクを開"
+"いてください。\n"
+"\n"
+"あなたは Sebastien Sauvage による、コミュニティーサポートのあるバージョンのオ"
+"リジナルのShaarli プロジェクトを使用しています。"
+
+#: application/legacy/LegacyLinkDB.php:266
+msgid "My secret stuff... - Pastebin.com"
+msgstr "わたしのひ💗み💗つ💗 - Pastebin.com"
+
+#: application/legacy/LegacyLinkDB.php:268
+msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
+msgstr ""
+"シーッ! これはあなたしか見られないプライベートリンクです。消すこともできま"
+"す。"
+
+#: application/legacy/LegacyUpdater.php:104
+#, fuzzy
+#| msgid "Couldn't retrieve Updater class methods."
+msgid "Couldn't retrieve updater class methods."
+msgstr "アップデーターのクラスメゾットを受信できませんでした。"
+
+#: application/legacy/LegacyUpdater.php:538
+msgid "<a href=\"./admin/thumbnails\">"
+msgstr "<a href=\"./admin/thumbnails\">"
+
+#: application/netscape/NetscapeBookmarkUtils.php:63
+msgid "Invalid export selection:"
+msgstr "不正なエクスポートの選択:"
+
+#: application/netscape/NetscapeBookmarkUtils.php:215
+#, php-format
+msgid "File %s (%d bytes) "
+msgstr "ファイル %s (%d バイト) "
+
+#: application/netscape/NetscapeBookmarkUtils.php:217
+msgid "has an unknown file format. Nothing was imported."
+msgstr "は不明なファイル形式です。インポートは中止されました。"
+
+#: application/netscape/NetscapeBookmarkUtils.php:221
+#, fuzzy, php-format
+#| msgid ""
+#| "was successfully processed in %d seconds: %d links imported, %d links "
+#| "overwritten, %d links skipped."
+msgid ""
+"was successfully processed in %d seconds: %d bookmarks imported, %d "
+"bookmarks overwritten, %d bookmarks skipped."
+msgstr ""
+"が %d 秒で処理され、%d 件のリンクがインポートされ、%d 件のリンクが上書きさ"
+"れ、%d 件のリンクがスキップされました。"
+
+#: application/plugin/PluginManager.php:124
+msgid " [plugin incompatibility]: "
+msgstr "[非対応のプラグイン]: "
+
+#: application/plugin/exception/PluginFileNotFoundException.php:21
+#, php-format
+msgid "Plugin \"%s\" files not found."
+msgstr "プラグイン「%s」のファイルが存在しません。"
+
+#: application/render/PageCacheManager.php:32
+#, php-format
+msgid "Cannot purge %s: no directory"
+msgstr "%s を削除できません: ディレクトリが存在しません"
+
+#: application/updater/exception/UpdaterException.php:51
+msgid "An error occurred while running the update "
+msgstr "更新中に問題が発生しました "
+
+#: index.php:65
+msgid "Shared bookmarks on "
+msgstr "次において共有されたリンク "
 
-#: plugins/addlink_toolbar/addlink_toolbar.php:29
+#: plugins/addlink_toolbar/addlink_toolbar.php:31
 msgid "URI"
 msgstr "URI"
 
-#: plugins/addlink_toolbar/addlink_toolbar.php:33
-#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+#: plugins/addlink_toolbar/addlink_toolbar.php:35
 msgid "Add link"
 msgstr "リンクを追加"
 
-#: plugins/addlink_toolbar/addlink_toolbar.php:50
+#: plugins/addlink_toolbar/addlink_toolbar.php:52
 msgid "Adds the addlink input on the linklist page."
 msgstr "リンク一覧のページに、リンクを追加するためのフォームを表示する。"
 
-#: plugins/archiveorg/archiveorg.php:23
+#: plugins/archiveorg/archiveorg.php:28
 msgid "View on archive.org"
 msgstr "archive.org 上で表示する"
 
-#: plugins/archiveorg/archiveorg.php:36
+#: plugins/archiveorg/archiveorg.php:41
 msgid "For each link, add an Archive.org icon."
 msgstr "それぞれのリンクに、Archive.org のアイコンを追加する。"
 
-#: plugins/demo_plugin/demo_plugin.php:465
+#: plugins/default_colors/default_colors.php:38
+msgid ""
+"Default colors plugin error: This plugin is active and no custom color is "
+"configured."
+msgstr ""
+"既定の色のプラグインにおけるエラー: このプラグインは有効なので、カスタム カ"
+"ラーは適用されません。"
+
+#: plugins/default_colors/default_colors.php:113
+msgid "Override default theme colors. Use any CSS valid color."
+msgstr ""
+"既定のテーマの色を上書きします。どのような CSS カラーコードでも使えます。"
+
+#: plugins/default_colors/default_colors.php:114
+msgid "Main color (navbar green)"
+msgstr "メイン カラー (ナビバーの緑)"
+
+#: plugins/default_colors/default_colors.php:115
+msgid "Background color (light grey)"
+msgstr "背景色 (灰色)"
+
+#: plugins/default_colors/default_colors.php:116
+msgid "Dark main color (e.g. visited links)"
+msgstr "暗い方の メイン カラー (例: 閲覧済みリンク)"
+
+#: plugins/demo_plugin/demo_plugin.php:477
 msgid ""
 "A demo plugin covering all use cases for template designers and plugin "
 "developers."
@@ -453,7 +703,15 @@ msgstr ""
 "テンプレートのデザイナーや、プラグインの開発者のためのすべての状況に対応でき"
 "るデモプラグインです。"
 
-#: plugins/isso/isso.php:20
+#: plugins/demo_plugin/demo_plugin.php:478
+msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
+msgstr "これはデモプラグイン専用のパラメーターです。末尾に追加されます。"
+
+#: plugins/demo_plugin/demo_plugin.php:479
+msgid "Other demo parameter"
+msgstr "他のデモ パラメーター"
+
+#: plugins/isso/isso.php:22
 msgid ""
 "Isso plugin error: Please define the \"ISSO_SERVER\" setting in the plugin "
 "administration page."
@@ -461,45 +719,17 @@ msgstr ""
 "Isso プラグインエラー: \"ISSO_SERVER\" の値をプラグイン管理ページにて指定して"
 "ください。"
 
-#: plugins/isso/isso.php:63
+#: plugins/isso/isso.php:92
 msgid "Let visitor comment your shaares on permalinks with Isso."
 msgstr ""
 "Isso を使って、あなたのパーマリンク上のリンクに第三者がコメントを残すことがで"
 "きます。"
 
-#: plugins/isso/isso.php:64
+#: plugins/isso/isso.php:93
 msgid "Isso server URL (without 'http://')"
 msgstr "Isso server URL ('http://' 抜き)"
 
-#: plugins/markdown/markdown.php:158
-msgid "Description will be rendered with"
-msgstr "説明は次の方法で描画されます:"
-
-#: plugins/markdown/markdown.php:159
-msgid "Markdown syntax documentation"
-msgstr "マークダウン形式のドキュメント"
-
-#: plugins/markdown/markdown.php:160
-msgid "Markdown syntax"
-msgstr "マークダウン形式"
-
-#: plugins/markdown/markdown.php:339
-msgid ""
-"Render shaare description with Markdown syntax.<br><strong>Warning</"
-"strong>:\n"
-"If your shaared descriptions contained HTML tags before enabling the "
-"markdown plugin,\n"
-"enabling it might break your page.\n"
-"See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a>."
-msgstr ""
-"リンクの説明をマークダウン形式で表示します。<br><strong>警告</strong>:\n"
-"リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n"
-"正常にページを表示できなくなるかもしれません。\n"
-"詳しくは <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a> をご覧ください。"
-
-#: plugins/piwik/piwik.php:21
+#: plugins/piwik/piwik.php:23
 msgid ""
 "Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
 "administration page."
@@ -507,27 +737,27 @@ msgstr ""
 "Piwik プラグインエラー: PIWIK_URL と PIWIK_SITEID の値をプラグイン管理ページ"
 "で指定してください。"
 
-#: plugins/piwik/piwik.php:70
+#: plugins/piwik/piwik.php:72
 msgid "A plugin that adds Piwik tracking code to Shaarli pages."
 msgstr "Piwik のトラッキングコードをShaarliに追加するプラグインです。"
 
-#: plugins/piwik/piwik.php:71
+#: plugins/piwik/piwik.php:73
 msgid "Piwik URL"
 msgstr "Piwik URL"
 
-#: plugins/piwik/piwik.php:72
+#: plugins/piwik/piwik.php:74
 msgid "Piwik site ID"
 msgstr "Piwik サイトID"
 
-#: plugins/playvideos/playvideos.php:22
+#: plugins/playvideos/playvideos.php:25
 msgid "Video player"
 msgstr "動画プレイヤー"
 
-#: plugins/playvideos/playvideos.php:25
+#: plugins/playvideos/playvideos.php:28
 msgid "Play Videos"
 msgstr "動画を再生"
 
-#: plugins/playvideos/playvideos.php:56
+#: plugins/playvideos/playvideos.php:59
 msgid "Add a button in the toolbar allowing to watch all videos."
 msgstr "すべての動画を閲覧するボタンをツールバーに追加します。"
 
@@ -535,26 +765,26 @@ msgstr "すべての動画を閲覧するボタンをツールバーに追加し
 msgid "plugins/playvideos/jquery-1.11.2.min.js"
 msgstr "plugins/playvideos/jquery-1.11.2.min.js"
 
-#: plugins/pubsubhubbub/pubsubhubbub.php:69
+#: plugins/pubsubhubbub/pubsubhubbub.php:72
 #, php-format
 msgid "Could not publish to PubSubHubbub: %s"
 msgstr "PubSubHubbub に登録できませんでした: %s"
 
-#: plugins/pubsubhubbub/pubsubhubbub.php:95
+#: plugins/pubsubhubbub/pubsubhubbub.php:99
 #, php-format
 msgid "Could not post to %s"
 msgstr "%s に登録できませんでした"
 
-#: plugins/pubsubhubbub/pubsubhubbub.php:99
+#: plugins/pubsubhubbub/pubsubhubbub.php:103
 #, php-format
 msgid "Bad response from the hub %s"
 msgstr "ハブ %s からの不正なレスポンス"
 
-#: plugins/pubsubhubbub/pubsubhubbub.php:110
+#: plugins/pubsubhubbub/pubsubhubbub.php:114
 msgid "Enable PubSubHubbub feed publishing."
 msgstr "PubSubHubbub へのフィードを公開する。"
 
-#: plugins/qrcode/qrcode.php:69 plugins/wallabag/wallabag.php:68
+#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70
 msgid "For each link, add a QRCode icon."
 msgstr "それぞれのリンクについて、QRコードのアイコンを追加する。"
 
@@ -570,724 +800,534 @@ msgstr ""
 msgid "Save to wallabag"
 msgstr "Wallabag に保存"
 
-#: plugins/wallabag/wallabag.php:69
+#: plugins/wallabag/wallabag.php:71
 msgid "Wallabag API URL"
 msgstr "Wallabag のAPIのURL"
 
-#: plugins/wallabag/wallabag.php:70
+#: plugins/wallabag/wallabag.php:72
 msgid "Wallabag API version (1 or 2)"
 msgstr "Wallabag のAPIのバージョン (1 または 2)"
 
 #: tests/LanguagesTest.php:214 tests/LanguagesTest.php:227
-#: tests/languages/fr/LanguagesFrTest.php:160
-#: tests/languages/fr/LanguagesFrTest.php:173
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:81
+#: tests/languages/fr/LanguagesFrTest.php:159
+#: tests/languages/fr/LanguagesFrTest.php:172
 msgid "Search"
 msgid_plural "Search"
 msgstr[0] "検索"
 msgstr[1] "検索"
 
-#: tmp/404.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
-msgid "Sorry, nothing to see here."
-msgstr "すみませんが、ここには何もありません。"
-
-#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "URL or leave empty to post a note"
-msgstr "URL を入力するか、空欄にするとノートを投稿します"
-
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Current password"
-msgstr "現在のパスワード"
-
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "New password"
-msgstr "新しいパスワード"
-
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
-msgid "Change"
-msgstr "変更"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
-msgid "Tag"
-msgstr "タグ"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "New name"
-msgstr "変更先の名前"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-msgid "Case sensitive"
-msgstr "大文字と小文字を区別"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-msgid "Rename"
-msgstr "名前を変更"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:172
-msgid "Delete"
-msgstr "削除"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
-msgid "You can also edit tags in the"
-msgstr "次に含まれるタグを編集することもできます:"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
-msgid "tag list"
-msgstr "タグ一覧"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "title"
-msgstr "タイトル"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-msgid "Home link"
-msgstr "ホームのリンク先"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "Default value"
-msgstr "既定の値"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
-msgid "Theme"
-msgstr "テーマ"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
-msgid "Language"
-msgstr "言語"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:116
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
-msgid "Timezone"
-msgstr "タイムゾーン"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
-msgid "Continent"
-msgstr "大陸"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
-msgid "City"
-msgstr "町"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:164
-msgid "Disable session cookie hijacking protection"
-msgstr "不正ログイン防止のためのセッションクッキーを無効化"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:166
-msgid "Check this if you get disconnected or if your IP address changes often"
-msgstr ""
-"あなたが切断されたり、IPアドレスが頻繁に変わる環境下であるならチェックを入れ"
-"てください"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
-msgid "Private links by default"
-msgstr "既定でプライベートリンク"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:184
-msgid "All new links are private by default"
-msgstr "すべての新規リンクをプライベートで作成"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
-msgid "RSS direct links"
-msgstr "RSS 直リンク"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:200
-msgid "Check this to use direct URL instead of permalink in feeds"
-msgstr "フィードでパーマリンクの代わりに直リンクを使う"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:215
-msgid "Hide public links"
-msgstr "公開リンクを隠す"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:216
-msgid "Do not show any links if the user is not logged in"
-msgstr "ログインしていないユーザーには何のリンクも表示しない"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:231
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
-msgid "Check updates"
-msgstr "更新を確認"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:232
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
-msgid "Notify me when a new release is ready"
-msgstr "新しいバージョンがリリースされたときに通知"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:247
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-msgid "Enable REST API"
-msgstr "REST API を有効化"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:248
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170
-msgid "Allow third party software to use Shaarli such as mobile application"
-msgstr ""
-"モバイルアプリといったサードパーティーのソフトウェアにShaarliを使用することを"
-"許可"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:263
-msgid "API secret"
-msgstr "API シークレット"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
-msgid "Save"
-msgstr "保存"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid "The Daily Shaarli"
-msgstr "デイリーSharli"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
-msgid "1 RSS entry per day"
-msgstr "各日1つずつのRSS項目"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
-msgid "Previous day"
-msgstr "前日"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "All links of one day in a single page."
-msgstr "1日に作成されたすべてのリンクです。"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
-msgid "Next day"
-msgstr "翌日"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
-msgid "Created:"
-msgstr "作成:"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-msgid "URL"
-msgstr "URL"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-msgid "Title"
-msgstr "タイトル"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124
-msgid "Description"
-msgstr "説明"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-msgid "Tags"
-msgstr "タグ"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
-msgid "Private"
-msgstr "プライベート"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-msgid "Apply Changes"
-msgstr "変更を適用"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Export Database"
-msgstr "データベースをエクスポート"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "Selection"
-msgstr "選択済み"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-msgid "All"
-msgstr "すべて"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-msgid "Public"
-msgstr "公開"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
-msgid "Prepend note permalinks with this Shaarli instance's URL"
-msgstr "この Shaarli のインスタンスのURL にノートへのパーマリンクを付け加える"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
-msgid "Useful to import bookmarks in a web browser"
-msgstr "ウェブブラウザーのリンクをインポートするのに有効です"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Import Database"
-msgstr "データベースをインポート"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
-msgid "Maximum size allowed:"
-msgstr "最大サイズ:"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Visibility"
-msgstr "可視性"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-msgid "Use values from the imported file, default to public"
-msgstr "インポート元のファイルの値を使用 (既定は公開リンクとなります)"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-msgid "Import all bookmarks as private"
-msgstr "すべてのブックマーク項目をプライベートリンクとしてインポート"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-msgid "Import all bookmarks as public"
-msgstr "すべてのブックマーク項目を公開リンクとしてインポート"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57
-msgid "Overwrite existing bookmarks"
-msgstr "既に存在しているブックマークを上書き"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
-msgid "Duplicates based on URL"
-msgstr "URL による重複"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
-msgid "Add default tags"
-msgstr "既定のタグを追加"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Install Shaarli"
-msgstr "Shaarli をインストール"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
-msgid "It looks like it's the first time you run Shaarli. Please configure it."
-msgstr "どうやら Shaarli を初めて起動しているようです。設定してください。"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
-msgid "Username"
-msgstr "ユーザー名"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:148
-msgid "Password"
-msgstr "パスワード"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
-msgid "Shaarli title"
-msgstr "Shaarli のタイトル"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
-msgid "My links"
-msgstr "自分のリンク"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182
-msgid "Install"
-msgstr "インストール"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:80
-msgid "shaare"
-msgid_plural "shaares"
-msgstr[0] "共有"
-msgstr[1] "共有"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
-msgid "private link"
-msgid_plural "private links"
-msgstr[0] "プライベートリンク"
-msgstr[1] "プライベートリンク"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:117
-msgid "Search text"
-msgstr "文字列で検索"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:124
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-msgid "Filter by tag"
-msgstr "タグによって分類"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111
-msgid "Nothing found."
-msgstr "何も見つかりませんでした。"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:119
-#, php-format
-msgid "%s result"
-msgid_plural "%s results"
-msgstr[0] "%s 件の結果"
-msgstr[1] "%s 件の結果"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
-msgid "for"
-msgstr "for"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130
-msgid "tagged"
-msgstr "タグ付けされた"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
-msgid "Remove tag"
-msgstr "タグを削除"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143
-msgid "with status"
-msgstr "with status"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154
-msgid "without any tag"
-msgstr "タグなし"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:174
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
-msgid "Fold"
-msgstr "畳む"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176
-msgid "Edited: "
-msgstr "編集済み: "
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:180
-msgid "permalink"
-msgstr "パーマリンク"
+#~ msgid "The page you are trying to reach does not exist or has been deleted."
+#~ msgstr "あなたが開こうとしたページは存在しないか、削除されています。"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182
-msgid "Add tag"
-msgstr "タグを追加"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:7
-msgid "Filters"
-msgstr "分類"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:12
-msgid "Only display private links"
-msgstr "プライベートリンクのみを表示"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:15
-msgid "Only display public links"
-msgstr "公開リンクのみを表示"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:20
-msgid "Filter untagged links"
-msgstr "タグ付けされていないリンクで分類"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:76
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
-msgid "Fold all"
-msgstr "すべて畳む"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:69
-msgid "Links per page"
-msgstr "各ページをリンク"
-
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid ""
-"You have been banned after too many failed login attempts. Try again later."
-msgstr "複数回に渡るログインへの失敗を検出しました。後でまた試してください。"
+#~ msgid "404 Not Found"
+#~ msgstr "404 ページが存在しません"
 
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:151
-msgid "Remember me"
-msgstr "パスワードを保存"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
-msgid "by the Shaarli community"
-msgstr "by Shaarli コミュニティ"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
-msgid "Documentation"
-msgstr "ドキュメント"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
-msgid "Expand"
-msgstr "展開する"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45
-msgid "Expand all"
-msgstr "すべて展開する"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46
-msgid "Are you sure you want to delete this link?"
-msgstr "本当にこのリンクを削除しますか?"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:61
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:86
-msgid "RSS Feed"
-msgstr "RSS フィード"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:66
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:102
-msgid "Logout"
-msgstr "ログアウト"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:169
-msgid "is available"
-msgstr "が利用可能"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:176
-msgid "Error"
-msgstr "エラー"
-
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Picture Wall"
-msgstr "ピクチャーウォール"
-
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "pics"
-msgstr "画像"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid "You need to enable Javascript to change plugin loading order."
-msgstr ""
-"プラグインを読み込む順番を変更するには、Javascriptを有効にする必要がありま"
-"す。"
+#~ msgid "Updates file path is not set, can't write updates."
+#~ msgstr ""
+#~ "更新するファイルのパスが指定されていないため、更新を書き込めません。"
 
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Enabled Plugins"
-msgstr "有効なプラグイン"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
-msgid "No plugin enabled."
-msgstr "有効なプラグインはありません。"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73
-msgid "Disable"
-msgstr "無効化"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:98
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
-msgid "Name"
-msgstr "名前"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
-msgid "Order"
-msgstr "順序"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-msgid "Disabled Plugins"
-msgstr "無効なプラグイン"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
-msgid "No plugin disabled."
-msgstr "無効なプラグインはありません。"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:97
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
-msgid "Enable"
-msgstr "有効化"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
-msgid "More plugins available"
-msgstr "さらに利用できるプラグインがあります"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136
-msgid "in the documentation"
-msgstr "ドキュメント内"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
-msgid "Plugin configuration"
-msgstr "プラグイン設定"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:195
-msgid "No parameter available."
-msgstr "利用可能な設定項目はありません。"
-
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "tags"
-msgstr "タグ"
-
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "List all links with those tags"
-msgstr "このタグが付いているリンクをリスト化する"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3
-msgid "Sort by:"
-msgstr "分類:"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:5
-msgid "Cloud"
-msgstr "クラウド"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:6
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:6
-msgid "Most used"
-msgstr "もっとも使われた"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:7
-msgid "Alphabetical"
-msgstr "アルファベット順"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-msgid "Settings"
-msgstr "設定"
+#~ msgid "Unable to write updates in "
+#~ msgstr "更新を次の項目に書き込めませんでした: "
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Change Shaarli settings: title, timezone, etc."
-msgstr "Shaarli の設定を変更: タイトル、タイムゾーンなど。"
+#~ msgid "I said: NO. You are banned for the moment. Go away."
+#~ msgstr "あなたはこのサーバーからBANされています。"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
-msgid "Configure your Shaarli"
-msgstr "あなたの Shaarli を設定"
+#~ msgid "Tag cloud"
+#~ msgstr "タグクラウド"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
-msgid "Enable, disable and configure plugins"
-msgstr "プラグインを有効化、無効化、設定する"
+#~ msgid "Click to try again."
+#~ msgstr "クリックして再度試します。"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-msgid "Change your password"
-msgstr "パスワードを変更"
+#~ msgid "Description will be rendered with"
+#~ msgstr "説明は次の方法で描画されます:"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-msgid "Rename or delete a tag in all links"
-msgstr "すべてのリンクのタグの名前を変更する、または削除する"
+#~ msgid "Markdown syntax documentation"
+#~ msgstr "マークダウン形式のドキュメント"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-msgid ""
-"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
-"delicious...)"
-msgstr ""
-"Netscape HTML 形式のブックマークをインポートする (Firefox、Chrome、Operaと"
-"いったブラウザーが含まれます)"
+#~ msgid "Markdown syntax"
+#~ msgstr "マークダウン形式"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-msgid "Import links"
-msgstr "リンクをインポート"
+#~ msgid ""
+#~ "Render shaare description with Markdown syntax.<br><strong>Warning</"
+#~ "strong>:\n"
+#~ "If your shaared descriptions contained HTML tags before enabling the "
+#~ "markdown plugin,\n"
+#~ "enabling it might break your page.\n"
+#~ "See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a>."
+#~ msgstr ""
+#~ "リンクの説明をマークダウン形式で表示します。<br><strong>警告</strong>:\n"
+#~ "リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n"
+#~ "正常にページを表示できなくなるかもしれません。\n"
+#~ "詳しくは <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a> をご覧ください。"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
-msgid ""
-"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
-"Opera, delicious...)"
-msgstr ""
-"Netscape HTML 形式のブックマークをエクスポートする (Firefox、Chrome、Operaと"
-"いったブラウザーが含まれます)"
+#~ msgid "Sorry, nothing to see here."
+#~ msgstr "すみませんが、ここには何もありません。"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-msgid "Export database"
-msgstr "リンクをエクスポート"
+#~ msgid "URL or leave empty to post a note"
+#~ msgstr "URL を入力するか、空欄にするとノートを投稿します"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71
-msgid ""
-"Drag one of these button to your bookmarks toolbar or right-click it and "
-"\"Bookmark This Link\""
-msgstr ""
-"これらのボタンのうち1つををブックマークバーにドラッグするか、右クリックして"
-"「このリンクをブックマークに追加」してください"
+#~ msgid "Current password"
+#~ msgstr "現在のパスワード"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
-msgid "then click on the bookmarklet in any page you want to share."
-msgstr "共有したいページでブックマークレットをクリックしてください。"
+#~ msgid "New password"
+#~ msgstr "新しいパスワード"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:100
-msgid ""
-"Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
-"Link"
-msgstr ""
-"このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
-"ブックマークに追加」してください"
+#~ msgid "Change"
+#~ msgstr "変更"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
-msgid "then click ✚Shaare link button in any page you want to share"
-msgstr "✚リンクを共有 ボタンをクリックすることで、どこでもリンクを共有できます"
+#~ msgid "Tag"
+#~ msgstr "タグ"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108
-msgid "The selected text is too long, it will be truncated."
-msgstr "選択された文字列は長すぎるので、一部が切り捨てられます。"
+#~ msgid "New name"
+#~ msgstr "変更先の名前"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
-msgid "Shaare link"
-msgstr "共有リンク"
+#~ msgid "Case sensitive"
+#~ msgstr "大文字と小文字を区別"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
-msgid ""
-"Then click ✚Add Note button anytime to start composing a private Note (text "
-"post) to your Shaarli"
-msgstr ""
-"✚ノートを追加 ボタンをクリックすることで、いつでもプライベートノート(テキスト"
-"形式)をShaarli上に作成できます"
+#~ msgid "Rename"
+#~ msgstr "名前を変更"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-msgid "Add Note"
-msgstr "ノートを追加"
+#~ msgid "Delete"
+#~ msgstr "削除"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129
-msgid ""
-"You need to browse your Shaarli over <strong>HTTPS</strong> to use this "
-"functionality."
-msgstr ""
-"この機能を使用するには、<strong>HTTPS</strong> 経由でShaarliに接続してくださ"
-"い。"
+#~ msgid "You can also edit tags in the"
+#~ msgstr "次に含まれるタグを編集することもできます:"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
-msgid "Add to"
-msgstr "次に追加:"
+#~ msgid "tag list"
+#~ msgstr "タグ一覧"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:145
-msgid "3rd party"
-msgstr "サードパーティー"
+#~ msgid "title"
+#~ msgstr "タイトル"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:153
-msgid "Plugin"
-msgstr "プラグイン"
+#~ msgid "Home link"
+#~ msgstr "ホームのリンク先"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154
-msgid "plugin"
-msgstr "プラグイン"
+#~ msgid "Default value"
+#~ msgstr "既定の値"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
-msgid ""
-"Drag this link to your bookmarks toolbar, or right-click it and choose "
-"Bookmark This Link"
-msgstr ""
-"このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
-"ブックマークに追加」してください"
+#~ msgid "Theme"
+#~ msgstr "テーマ"
+
+#~ msgid "Language"
+#~ msgstr "言語"
+
+#~ msgid "Timezone"
+#~ msgstr "タイムゾーン"
+
+#~ msgid "Continent"
+#~ msgstr "大陸"
+
+#~ msgid "City"
+#~ msgstr "町"
+
+#~ msgid "Disable session cookie hijacking protection"
+#~ msgstr "不正ログイン防止のためのセッションクッキーを無効化"
+
+#~ msgid ""
+#~ "Check this if you get disconnected or if your IP address changes often"
+#~ msgstr ""
+#~ "あなたが切断されたり、IPアドレスが頻繁に変わる環境下であるならチェックを入"
+#~ "れてください"
+
+#~ msgid "Private links by default"
+#~ msgstr "既定でプライベートリンク"
+
+#~ msgid "All new links are private by default"
+#~ msgstr "すべての新規リンクをプライベートで作成"
+
+#~ msgid "RSS direct links"
+#~ msgstr "RSS 直リンク"
+
+#~ msgid "Check this to use direct URL instead of permalink in feeds"
+#~ msgstr "フィードでパーマリンクの代わりに直リンクを使う"
+
+#~ msgid "Hide public links"
+#~ msgstr "公開リンクを隠す"
+
+#~ msgid "Do not show any links if the user is not logged in"
+#~ msgstr "ログインしていないユーザーには何のリンクも表示しない"
+
+#~ msgid "Check updates"
+#~ msgstr "更新を確認"
+
+#~ msgid "Notify me when a new release is ready"
+#~ msgstr "新しいバージョンがリリースされたときに通知"
+
+#~ msgid "Enable REST API"
+#~ msgstr "REST API を有効化"
+
+#~ msgid "Allow third party software to use Shaarli such as mobile application"
+#~ msgstr ""
+#~ "モバイルアプリといったサードパーティーのソフトウェアにShaarliを使用するこ"
+#~ "とを許可"
+
+#~ msgid "API secret"
+#~ msgstr "API シークレット"
+
+#~ msgid "Save"
+#~ msgstr "保存"
+
+#~ msgid "The Daily Shaarli"
+#~ msgstr "デイリーSharli"
+
+#~ msgid "1 RSS entry per day"
+#~ msgstr "各日1つずつのRSS項目"
+
+#~ msgid "Previous day"
+#~ msgstr "前日"
+
+#~ msgid "All links of one day in a single page."
+#~ msgstr "1日に作成されたすべてのリンクです。"
+
+#~ msgid "Next day"
+#~ msgstr "翌日"
+
+#~ msgid "Created:"
+#~ msgstr "作成:"
+
+#~ msgid "URL"
+#~ msgstr "URL"
+
+#~ msgid "Title"
+#~ msgstr "タイトル"
+
+#~ msgid "Description"
+#~ msgstr "説明"
+
+#~ msgid "Tags"
+#~ msgstr "タグ"
+
+#~ msgid "Private"
+#~ msgstr "プライベート"
+
+#~ msgid "Apply Changes"
+#~ msgstr "変更を適用"
+
+#~ msgid "Export Database"
+#~ msgstr "データベースをエクスポート"
+
+#~ msgid "Selection"
+#~ msgstr "選択済み"
+
+#~ msgid "All"
+#~ msgstr "すべて"
+
+#~ msgid "Public"
+#~ msgstr "公開"
+
+#~ msgid "Prepend note permalinks with this Shaarli instance's URL"
+#~ msgstr ""
+#~ "この Shaarli のインスタンスのURL にノートへのパーマリンクを付け加える"
+
+#~ msgid "Useful to import bookmarks in a web browser"
+#~ msgstr "ウェブブラウザーのリンクをインポートするのに有効です"
+
+#~ msgid "Import Database"
+#~ msgstr "データベースをインポート"
+
+#~ msgid "Maximum size allowed:"
+#~ msgstr "最大サイズ:"
+
+#~ msgid "Visibility"
+#~ msgstr "可視性"
+
+#~ msgid "Use values from the imported file, default to public"
+#~ msgstr "インポート元のファイルの値を使用 (既定は公開リンクとなります)"
+
+#~ msgid "Import all bookmarks as public"
+#~ msgstr "すべてのブックマーク項目を公開リンクとしてインポート"
+
+#~ msgid "Overwrite existing bookmarks"
+#~ msgstr "既に存在しているブックマークを上書き"
+
+#~ msgid "Duplicates based on URL"
+#~ msgstr "URL による重複"
+
+#~ msgid "Add default tags"
+#~ msgstr "既定のタグを追加"
+
+#~ msgid "Install Shaarli"
+#~ msgstr "Shaarli をインストール"
+
+#~ msgid ""
+#~ "It looks like it's the first time you run Shaarli. Please configure it."
+#~ msgstr "どうやら Shaarli を初めて起動しているようです。設定してください。"
+
+#~ msgid "Username"
+#~ msgstr "ユーザー名"
+
+#~ msgid "Password"
+#~ msgstr "パスワード"
+
+#~ msgid "Shaarli title"
+#~ msgstr "Shaarli のタイトル"
+
+#~ msgid "My links"
+#~ msgstr "自分のリンク"
+
+#~ msgid "Install"
+#~ msgstr "インストール"
+
+#~ msgid "shaare"
+#~ msgid_plural "shaares"
+#~ msgstr[0] "共有"
+#~ msgstr[1] "共有"
+
+#~ msgid "private link"
+#~ msgid_plural "private links"
+#~ msgstr[0] "プライベートリンク"
+#~ msgstr[1] "プライベートリンク"
+
+#~ msgid "Search text"
+#~ msgstr "文字列で検索"
+
+#~ msgid "Filter by tag"
+#~ msgstr "タグによって分類"
+
+#~ msgid "Nothing found."
+#~ msgstr "何も見つかりませんでした。"
+
+#~ msgid "%s result"
+#~ msgid_plural "%s results"
+#~ msgstr[0] "%s 件の結果"
+#~ msgstr[1] "%s 件の結果"
+
+#~ msgid "for"
+#~ msgstr "for"
+
+#~ msgid "tagged"
+#~ msgstr "タグ付けされた"
+
+#~ msgid "Remove tag"
+#~ msgstr "タグを削除"
+
+#~ msgid "with status"
+#~ msgstr "with status"
+
+#~ msgid "without any tag"
+#~ msgstr "タグなし"
+
+#~ msgid "Fold"
+#~ msgstr "畳む"
+
+#~ msgid "Edited: "
+#~ msgstr "編集済み: "
+
+#~ msgid "permalink"
+#~ msgstr "パーマリンク"
+
+#~ msgid "Add tag"
+#~ msgstr "タグを追加"
+
+#~ msgid "Filters"
+#~ msgstr "分類"
+
+#~ msgid "Only display private links"
+#~ msgstr "プライベートリンクのみを表示"
+
+#~ msgid "Only display public links"
+#~ msgstr "公開リンクのみを表示"
+
+#~ msgid "Filter untagged links"
+#~ msgstr "タグ付けされていないリンクで分類"
+
+#~ msgid "Fold all"
+#~ msgstr "すべて畳む"
+
+#~ msgid "Links per page"
+#~ msgstr "各ページをリンク"
+
+#~ msgid "Remember me"
+#~ msgstr "パスワードを保存"
+
+#~ msgid "by the Shaarli community"
+#~ msgstr "by Shaarli コミュニティ"
+
+#~ msgid "Documentation"
+#~ msgstr "ドキュメント"
+
+#~ msgid "Expand"
+#~ msgstr "展開する"
+
+#~ msgid "Expand all"
+#~ msgstr "すべて展開する"
+
+#~ msgid "Are you sure you want to delete this link?"
+#~ msgstr "本当にこのリンクを削除しますか?"
+
+#~ msgid "RSS Feed"
+#~ msgstr "RSS フィード"
+
+#~ msgid "Logout"
+#~ msgstr "ログアウト"
+
+#~ msgid "is available"
+#~ msgstr "が利用可能"
+
+#~ msgid "Error"
+#~ msgstr "エラー"
+
+#~ msgid "Picture Wall"
+#~ msgstr "ピクチャーウォール"
+
+#~ msgid "pics"
+#~ msgstr "画像"
+
+#~ msgid "You need to enable Javascript to change plugin loading order."
+#~ msgstr ""
+#~ "プラグインを読み込む順番を変更するには、Javascriptを有効にする必要がありま"
+#~ "す。"
+
+#~ msgid "Enabled Plugins"
+#~ msgstr "有効なプラグイン"
+
+#~ msgid "No plugin enabled."
+#~ msgstr "有効なプラグインはありません。"
+
+#~ msgid "Disable"
+#~ msgstr "無効化"
+
+#~ msgid "Name"
+#~ msgstr "名前"
+
+#~ msgid "Order"
+#~ msgstr "順序"
+
+#~ msgid "Disabled Plugins"
+#~ msgstr "無効なプラグイン"
+
+#~ msgid "No plugin disabled."
+#~ msgstr "無効なプラグインはありません。"
+
+#~ msgid "Enable"
+#~ msgstr "有効化"
+
+#~ msgid "More plugins available"
+#~ msgstr "さらに利用できるプラグインがあります"
+
+#~ msgid "in the documentation"
+#~ msgstr "ドキュメント内"
+
+#~ msgid "No parameter available."
+#~ msgstr "利用可能な設定項目はありません。"
+
+#~ msgid "tags"
+#~ msgstr "タグ"
+
+#~ msgid "List all links with those tags"
+#~ msgstr "このタグが付いているリンクをリスト化する"
+
+#~ msgid "Sort by:"
+#~ msgstr "分類:"
+
+#~ msgid "Cloud"
+#~ msgstr "クラウド"
+
+#~ msgid "Most used"
+#~ msgstr "もっとも使われた"
+
+#~ msgid "Alphabetical"
+#~ msgstr "アルファベット順"
+
+#~ msgid "Settings"
+#~ msgstr "設定"
+
+#~ msgid "Change Shaarli settings: title, timezone, etc."
+#~ msgstr "Shaarli の設定を変更: タイトル、タイムゾーンなど。"
+
+#~ msgid "Configure your Shaarli"
+#~ msgstr "あなたの Shaarli を設定"
+
+#~ msgid "Enable, disable and configure plugins"
+#~ msgstr "プラグインを有効化、無効化、設定する"
+
+#~ msgid "Change your password"
+#~ msgstr "パスワードを変更"
+
+#~ msgid "Rename or delete a tag in all links"
+#~ msgstr "すべてのリンクのタグの名前を変更する、または削除する"
+
+#~ msgid ""
+#~ "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
+#~ "delicious...)"
+#~ msgstr ""
+#~ "Netscape HTML 形式のブックマークをインポートする (Firefox、Chrome、Operaと"
+#~ "いったブラウザーが含まれます)"
+
+#~ msgid "Import links"
+#~ msgstr "リンクをインポート"
+
+#~ msgid ""
+#~ "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
+#~ "Opera, delicious...)"
+#~ msgstr ""
+#~ "Netscape HTML 形式のブックマークをエクスポートする (Firefox、Chrome、Opera"
+#~ "といったブラウザーが含まれます)"
+
+#~ msgid "Export database"
+#~ msgstr "リンクをエクスポート"
+
+#~ msgid ""
+#~ "Drag one of these button to your bookmarks toolbar or right-click it and "
+#~ "\"Bookmark This Link\""
+#~ msgstr ""
+#~ "これらのボタンのうち1つををブックマークバーにドラッグするか、右クリックし"
+#~ "て「このリンクをブックマークに追加」してください"
+
+#~ msgid "then click on the bookmarklet in any page you want to share."
+#~ msgstr "共有したいページでブックマークレットをクリックしてください。"
+
+#~ msgid ""
+#~ "Drag this link to your bookmarks toolbar or right-click it and Bookmark "
+#~ "This Link"
+#~ msgstr ""
+#~ "このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
+#~ "ブックマークに追加」してください"
+
+#~ msgid "then click ✚Shaare link button in any page you want to share"
+#~ msgstr ""
+#~ "✚リンクを共有 ボタンをクリックすることで、どこでもリンクを共有できます"
+
+#~ msgid "The selected text is too long, it will be truncated."
+#~ msgstr "選択された文字列は長すぎるので、一部が切り捨てられます。"
+
+#~ msgid "Shaare link"
+#~ msgstr "共有リンク"
+
+#~ msgid ""
+#~ "Then click ✚Add Note button anytime to start composing a private Note "
+#~ "(text post) to your Shaarli"
+#~ msgstr ""
+#~ "✚ノートを追加 ボタンをクリックすることで、いつでもプライベートノート(テキ"
+#~ "スト形式)をShaarli上に作成できます"
+
+#~ msgid "Add Note"
+#~ msgstr "ノートを追加"
+
+#~ msgid ""
+#~ "You need to browse your Shaarli over <strong>HTTPS</strong> to use this "
+#~ "functionality."
+#~ msgstr ""
+#~ "この機能を使用するには、<strong>HTTPS</strong> 経由でShaarliに接続してくだ"
+#~ "さい。"
+
+#~ msgid "Add to"
+#~ msgstr "次に追加:"
+
+#~ msgid "3rd party"
+#~ msgstr "サードパーティー"
+
+#~ msgid "Plugin"
+#~ msgstr "プラグイン"
+
+#~ msgid "plugin"
+#~ msgstr "プラグイン"
+
+#~ msgid ""
+#~ "Drag this link to your bookmarks toolbar, or right-click it and choose "
+#~ "Bookmark This Link"
+#~ msgstr ""
+#~ "このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
+#~ "ブックマークに追加」してください"
index b10397dda2d079cb785caa6fddef262d77331503..1eb7659af859a2a785a5f913c6be5b6b5f80c966 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,12 @@ 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\Security\BanManager;
 use Shaarli\Security\CookieManager;
 use Shaarli\Security\LoginManager;
 use Shaarli\Security\SessionManager;
@@ -48,10 +52,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 +78,16 @@ if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
 new Languages(setlocale(LC_MESSAGES, 0), $conf);
 
 $conf->setEmpty('general.timezone', date_default_timezone_get());
-$conf->setEmpty('general.title', t('Shared bookmarks on '). escape(index_url($_SERVER)));
+$conf->setEmpty('general.title', t('Shared bookmarks on ') . escape(index_url($_SERVER)));
 
-RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory
+RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl') . '/' . $conf->get('resource.theme') . '/'; // template directory
 RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory
 
 date_default_timezone_set($conf->get('general.timezone', 'UTC'));
 
 $loginManager->checkLoginState(client_ip_id($_SERVER));
 
-$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager);
+$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager, $logger);
 $container = $containerBuilder->build();
 $app = new App($container);
 
@@ -110,13 +126,16 @@ $app->group('/admin', function () {
     $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save');
     $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index');
     $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save');
-    $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:addShaare');
-    $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayCreateForm');
-    $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayEditForm');
-    $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:save');
-    $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ManageShaareController:deleteBookmark');
-    $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ManageShaareController:changeVisibility');
-    $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark');
+    $this->post('/tags/change-separator', '\Shaarli\Front\Controller\Admin\ManageTagController:changeSeparator');
+    $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare');
+    $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm');
+    $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm');
+    $this->get('/shaare/private/{hash}', '\Shaarli\Front\Controller\Admin\ShaareManageController:sharePrivate');
+    $this->post('/shaare-batch', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateBatchForms');
+    $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:save');
+    $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ShaareManageController:deleteBookmark');
+    $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ShaareManageController:changeVisibility');
+    $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ShaareManageController:pinBookmark');
     $this->patch(
         '/shaare/{id:[0-9]+}/update-thumbnail',
         '\Shaarli\Front\Controller\Admin\ThumbnailsController:ajaxUpdate'
@@ -128,8 +147,10 @@ $app->group('/admin', function () {
     $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index');
     $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
     $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
+    $this->get('/server', '\Shaarli\Front\Controller\Admin\ServerController:index');
+    $this->get('/clear-cache', '\Shaarli\Front\Controller\Admin\ServerController:clearCache');
     $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
-
+    $this->get('/metadata', '\Shaarli\Front\Controller\Admin\MetadataController:ajaxRetrieveTitle');
     $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
 })->add('\Shaarli\Front\ShaarliAdminMiddleware');
 
@@ -151,6 +172,12 @@ $app->group('/api/v1', function () {
     $this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory');
 })->add('\Shaarli\Api\ApiMiddleware');
 
-$response = $app->run(true);
-
-$app->respond($response);
+try {
+    $response = $app->run(true);
+    $app->respond($response);
+} catch (Throwable $e) {
+    die(nl2br(
+        'An unexpected error happened, and the error template could not be displayed.' . PHP_EOL . PHP_EOL .
+        exception2text($e)
+    ));
+}
index f0b8436806bde75bc374b58f5dcb91c81784946c..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
@@ -60,6 +60,7 @@ ini_set('session.use_only_cookies', 1);
 ini_set('session.use_trans_sid', false);
 
 define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
+define('SHAARLI_MUTEX_FILE', __FILE__);
 
 session_name('shaarli');
 // Start session if needed (Some server auto-start sessions).
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..c559e35da97bb4d8ec7afb229c7845fab983767f 100644 (file)
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -5,13 +5,18 @@
   <file>index.php</file>
   <file>application</file>
   <file>plugins</file>
-  <file>tests</file>
+<!--  <file>tests</file>-->
 
   <exclude-pattern>*/*.css</exclude-pattern>
   <exclude-pattern>*/*.js</exclude-pattern>
 
   <arg name="colors"/>
 
-  <rule ref="PSR1"/>
-  <rule ref="PSR2"/>
+  <rule ref="PSR12"/>
+  <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
+
+  <rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
+    <!--  index.php bootstraps everything, so yes mixed symbols with side effects  -->
+    <exclude-pattern>index.php</exclude-pattern>
+  </rule>
 </ruleset>
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 922b5966fe3340b203b8c98cc6f970555e4b43a5..88f2b65339b2ca41c0c0f9571d801857751435bf 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Plugin Archive.org.
  *
@@ -17,7 +18,7 @@ use Shaarli\Plugin\PluginManager;
 function hook_archiveorg_render_linklist($data)
 {
     $archive_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/archiveorg/archiveorg.html');
-    $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
+    $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
 
     foreach ($data['links'] as &$value) {
         $isNote = startsWith($value['real_url'], '/shaare/');
index e1fd5cfbdb19daf547519af2d7f41482c88f7b96..574a0bd4df3170decd74967988d8d1c8fb8bb2cb 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];
     }
@@ -56,7 +56,7 @@ function default_colors_init($conf)
 function hook_default_colors_render_includes($data)
 {
     $file = PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css';
-    if (file_exists($file )) {
+    if (file_exists($file)) {
         $data['css_files'][] = $file ;
     }
 
@@ -75,7 +75,7 @@ function default_colors_generate_css_file($params): void
     $content = '';
     foreach (DEFAULT_COLORS_PLACEHOLDERS as $rule) {
         $content .= !empty($params[$rule])
-            ? default_colors_format_css_rule($params, $rule) .';'. PHP_EOL
+            ? default_colors_format_css_rule($params, $rule) . ';' . PHP_EOL
             : '';
     }
 
@@ -99,8 +99,8 @@ function default_colors_format_css_rule($data, $parameter)
     }
 
     $key = str_replace('DEFAULT_COLORS_', '', $parameter);
-    $key = str_replace('_', '-', strtolower($key)) .'-color';
-    return '  --'. $key .': '. $data[$parameter];
+    $key = str_replace('_', '-', strtolower($key)) . '-color';
+    return '  --' . $key . ': ' . $data[$parameter];
 }
 
 
index defb01f7e4457d05f95f0f95e939297300033904..22d27b6827f306ad3a3230ffd8361cd543a5bd6b 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Demo Plugin.
  *
@@ -82,14 +83,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 +116,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 +188,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 +230,13 @@ function hook_demo_plugin_render_linklist($data)
      * and a mandatory `html` key, which contains its value.
      * It's also recommended to add key 'on' or 'off' for theme rendering.
      */
-    $action = array(
-        'attr' => array(
+    $action = [
+        'attr' => [
             'href' => '?up',
             'title' => 'Uppercase!',
-        ),
+        ],
         'html' => '←',
-    );
+    ];
 
     if (isset($_GET['up'])) {
         // Manipulate link data
@@ -275,7 +276,7 @@ function hook_demo_plugin_render_linklist($data)
 function hook_demo_plugin_render_editlink($data)
 {
     // Load HTML into a string
-    $html = file_get_contents(PluginManager::$PLUGINS_PATH .'/demo_plugin/field.html');
+    $html = file_get_contents(PluginManager::$PLUGINS_PATH . '/demo_plugin/field.html');
 
     // Replace value in HTML if it exists in $data
     if (!empty($data['link']['stuff'])) {
index 79e7380b66f543ade34bb080e027cba26990a7bf..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="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 95499e39f5a9618cfec8bcb4f0a28ed8922537ca..2ae10476fcb9618f99e1792e7ccd96db95ba44f4 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * Plugin qrcode
  * Add QRCode containing URL for each links.
@@ -19,7 +20,7 @@ function hook_qrcode_render_linklist($data)
 {
     $qrcode_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.html');
 
-    $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
+    $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
     foreach ($data['links'] as &$value) {
         $qrcode = sprintf(
             $qrcode_html,
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 805c1ad986aa9bc4c37b2531ca521f891e6cffd6..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;
     }
 
@@ -45,13 +47,13 @@ function hook_wallabag_render_linklist($data, $conf)
     $wallabagHtml = file_get_contents(PluginManager::$PLUGINS_PATH . '/wallabag/wallabag.html');
 
     $linkTitle = t('Save to wallabag');
-    $path = ($data['_BASE_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
+    $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
 
     foreach ($data['links'] as &$value) {
         $wallabag = sprintf(
             $wallabagHtml,
             $wallabagInstance->getWallabagUrl(),
-            urlencode($value['url']),
+            urlencode(unescape($value['url'])),
             $path,
             $linkTitle
         );
index 6dc0e5b7aa112e19f3dd7cbc6f820356885f3088..e810104eae23fe3bb3467403b504b9b3622010c8 100644 (file)
@@ -89,14 +89,6 @@ class HistoryTest extends \Shaarli\TestCase
         $this->assertEquals(History::CREATED, $actual['event']);
         $this->assertTrue(new DateTime('-2 seconds') < $actual['datetime']);
         $this->assertEquals(1, $actual['id']);
-
-        $history = new History(self::$historyFilePath);
-        $bookmark = (new Bookmark())->setId('str');
-        $history->addLink($bookmark);
-        $actual = $history->getHistory()[0];
-        $this->assertEquals(History::CREATED, $actual['event']);
-        $this->assertTrue(new DateTime('-2 seconds') < $actual['datetime']);
-        $this->assertEquals('str', $actual['id']);
     }
 
 //    /**
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 1598e1e8ac8e8edec612eb79cbe969ed22f1676f..10b29ab2530bdf9c9752aef9c149ba7c6feeebed 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
@@ -49,6 +50,7 @@ class InfoTest extends TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -58,7 +60,7 @@ class InfoTest extends TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Info($this->container);
index cf9464f07877e1613ae0b5704ed2a523da23c497..805c9be33be49c0523ef23311d930c356b252c11 100644 (file)
@@ -3,6 +3,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
@@ -53,11 +54,15 @@ class DeleteLinkTest extends \Shaarli\TestCase
      */
     protected $controller;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Before each test, instantiate a new Api with its config, plugins and bookmarks.
      */
     protected function setUp(): void
     {
+        $this->mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -65,7 +70,7 @@ class DeleteLinkTest extends \Shaarli\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -100,7 +105,7 @@ class DeleteLinkTest extends \Shaarli\TestCase
         $this->assertEquals(204, $response->getStatusCode());
         $this->assertEmpty((string) $response->getBody());
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->assertFalse($this->bookmarkService->exists($id));
 
         $historyEntry = $this->history->getHistory()[0];
index 99dc606fbf95c1d6bae7ef716a4fa335a55491e4..1ec56ef3cd97c870fc52b7bbb54875178c52a6ea 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
@@ -57,6 +58,7 @@ class GetLinkIdTest extends \Shaarli\TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -65,7 +67,7 @@ class GetLinkIdTest extends \Shaarli\TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Links($this->container);
index ca1bfc6362d550de19805938f397938344f409f7..b1c46ee2816853b45a5a8abfcac28f8c71f4b973 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
@@ -57,6 +58,7 @@ class GetLinksTest extends \Shaarli\TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -65,7 +67,7 @@ class GetLinksTest extends \Shaarli\TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Links($this->container);
@@ -396,7 +398,7 @@ class GetLinksTest extends \Shaarli\TestCase
         $response = $this->controller->getLinks($request, new Response());
         $this->assertEquals(200, $response->getStatusCode());
         $data = json_decode((string) $response->getBody(), true);
-        $this->assertEquals(4, count($data));
+        $this->assertEquals(5, count($data));
         $this->assertEquals(6, $data[0]['id']);
 
         // wildcard: placeholder at the middle
index fe3de66ff13e2e6718542335e9bd22a797e5bb85..e12f803be3ce99004294b926e4e14dd7b98f0ba1 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
@@ -72,6 +73,7 @@ class PostLinkTest extends TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -79,7 +81,7 @@ class PostLinkTest extends TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -90,8 +92,8 @@ class PostLinkTest extends TestCase
 
         $mock = $this->createMock(Router::class);
         $mock->expects($this->any())
-             ->method('relativePathFor')
-             ->willReturn('api/v1/bookmarks/1');
+             ->method('pathFor')
+             ->willReturn('/api/v1/bookmarks/1');
 
         // affect @property-read... seems to work
         $this->controller->getCi()->router = $mock;
@@ -126,7 +128,7 @@ class PostLinkTest extends TestCase
 
         $response = $this->controller->postLink($request, new Response());
         $this->assertEquals(201, $response->getStatusCode());
-        $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]);
+        $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
         $data = json_decode((string) $response->getBody(), true);
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
@@ -160,6 +162,8 @@ class PostLinkTest extends TestCase
             'description' => 'shaare description',
             'tags' => ['one', 'two'],
             'private' => true,
+            'created' => '2015-05-05T12:30:00+03:00',
+            'updated' => '2016-06-05T14:32:10+03:00',
         ];
         $env = Environment::mock([
             'REQUEST_METHOD' => 'POST',
@@ -171,7 +175,7 @@ class PostLinkTest extends TestCase
         $response = $this->controller->postLink($request, new Response());
 
         $this->assertEquals(201, $response->getStatusCode());
-        $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]);
+        $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
         $data = json_decode((string) $response->getBody(), true);
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
@@ -181,10 +185,8 @@ class PostLinkTest extends TestCase
         $this->assertEquals($link['description'], $data['description']);
         $this->assertEquals($link['tags'], $data['tags']);
         $this->assertEquals(true, $data['private']);
-        $this->assertTrue(
-            new \DateTime('2 seconds ago') < \DateTime::createFromFormat(\DateTime::ATOM, $data['created'])
-        );
-        $this->assertEquals('', $data['updated']);
+        $this->assertSame($link['created'], $data['created']);
+        $this->assertSame($link['updated'], $data['updated']);
     }
 
     /**
index a2e87c5986304938662c33d05d21d7cbb313f60e..240ee323a345cff812cc9ad529ea4ebe25f1ca7a 100644 (file)
@@ -3,6 +3,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
@@ -64,6 +65,7 @@ class PutLinkTest extends \Shaarli\TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -71,7 +73,7 @@ class PutLinkTest extends \Shaarli\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
index 1326eb47aa7751f20885cb37df764f860097e0f7..37f0722978c2e2ee4fc2a2a45589b4fd28f1ffe0 100644 (file)
@@ -3,6 +3,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
@@ -54,11 +55,15 @@ class DeleteTagTest extends \Shaarli\TestCase
      */
     protected $controller;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Before each test, instantiate a new Api with its config, plugins and bookmarks.
      */
     protected function setUp(): void
     {
+        $this->mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -66,7 +71,7 @@ class DeleteTagTest extends \Shaarli\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -102,7 +107,7 @@ class DeleteTagTest extends \Shaarli\TestCase
         $this->assertEquals(204, $response->getStatusCode());
         $this->assertEmpty((string) $response->getBody());
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $tags = $this->bookmarkService->bookmarksCountPerTag();
         $this->assertFalse(isset($tags[$tagName]));
 
@@ -136,7 +141,7 @@ class DeleteTagTest extends \Shaarli\TestCase
         $this->assertEquals(204, $response->getStatusCode());
         $this->assertEmpty((string) $response->getBody());
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $tags = $this->bookmarkService->bookmarksCountPerTag();
         $this->assertFalse(isset($tags[$tagName]));
         $this->assertTrue($tags[strtolower($tagName)] > 0);
index 9c05954b4e8131f4a22664f59761cb40ee6f1324..878de5a4202d2dbb5a2e54ec2f29a719cc8a5412 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
@@ -55,6 +56,7 @@ class GetTagNameTest extends \Shaarli\TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -63,7 +65,7 @@ class GetTagNameTest extends \Shaarli\TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Tags($this->container);
index 3459fdfae361deb737f9e40786e6102a367d223b..b565a8c4d3620eba8282d871c3c63b5094815d3b 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
@@ -59,13 +60,14 @@ class GetTagsTest extends \Shaarli\TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
         $this->refDB->write(self::$testDatastore);
         $history = new History('sandbox/history.php');
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
index 74edde787b745dbddeda6308e51d94c8bd8df61f..c73f6d3beaec758d1318599c46b9b477bdd66189 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Api\Exceptions\ApiBadParametersException;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
@@ -64,6 +65,7 @@ class PutTagTest extends \Shaarli\TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -71,7 +73,7 @@ class PutTagTest extends \Shaarli\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
index ebed9bfcaf2d071faa25e8e05e6e404d1f0f5d38..1953078cd985a0c75aaa39a6464b7e833d8d2912 100644 (file)
@@ -90,19 +90,6 @@ class BookmarkArrayTest extends TestCase
         $array['nope'] = $bookmark;
     }
 
-    /**
-     * Test adding a bad entry: invalid ID type
-     */
-    public function testArrayAccessAddBadEntryIdType()
-    {
-        $this->expectException(\Shaarli\Bookmark\Exception\InvalidBookmarkException::class);
-
-        $array = new BookmarkArray();
-        $bookmark = (new Bookmark())->setId('nope');
-        $bookmark->validate();
-        $array[] = $bookmark;
-    }
-
     /**
      * Test adding a bad entry: ID/offset not consistent
      */
index c399822b5801520b78c511d91cff5d312ba2b693..f619aff3f7865d7aebddcd4fb06622b52521b2e0 100644 (file)
@@ -6,6 +6,7 @@
 namespace Shaarli\Bookmark;
 
 use DateTime;
+use malkusch\lock\mutex\NoMutex;
 use ReferenceLinkDB;
 use ReflectionClass;
 use Shaarli;
@@ -52,6 +53,9 @@ class BookmarkFileServiceTest extends TestCase
      */
     protected $privateLinkDB = null;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Instantiates public and private LinkDBs with test data
      *
@@ -68,6 +72,8 @@ class BookmarkFileServiceTest extends TestCase
      */
     protected function setUp(): void
     {
+        $this->mutex = new NoMutex();
+
         if (file_exists(self::$testDatastore)) {
             unlink(self::$testDatastore);
         }
@@ -87,8 +93,8 @@ class BookmarkFileServiceTest extends TestCase
         $this->refDB = new \ReferenceLinkDB();
         $this->refDB->write(self::$testDatastore);
         $this->history = new History('sandbox/history.php');
-        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, false);
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
     }
 
     /**
@@ -105,7 +111,7 @@ class BookmarkFileServiceTest extends TestCase
         $db = self::getMethod('migrate');
         $db->invokeArgs($this->privateLinkDB, []);
 
-        $db = new \FakeBookmarkService($this->conf, $this->history, true);
+        $db = new \FakeBookmarkService($this->conf, $this->history, $this->mutex, true);
         $this->assertInstanceOf(BookmarkArray::class, $db->getBookmarks());
         $this->assertEquals($this->refDB->countLinks(), $db->count());
     }
@@ -174,7 +180,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($updated, $bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
@@ -212,7 +218,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertNull($bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
@@ -242,7 +248,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals(43, $bookmark->getId());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->privateLinkDB->get(43);
     }
@@ -258,17 +264,6 @@ class BookmarkFileServiceTest extends TestCase
         $this->publicLinkDB->add(new Bookmark());
     }
 
-    /**
-     * Test add() method with an entry which is not a bookmark instance
-     */
-    public function testAddNotABookmark()
-    {
-        $this->expectException(\Exception::class);
-        $this->expectExceptionMessage('Provided data is invalid');
-
-        $this->privateLinkDB->add(['title' => 'hi!']);
-    }
-
     /**
      * Test add() method with a Bookmark already containing an ID
      */
@@ -314,7 +309,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertTrue(new \DateTime('5 seconds ago') < $bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -355,7 +350,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertTrue(new \DateTime('5 seconds ago') < $bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -388,7 +383,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($title, $bookmark->getTitle());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -406,17 +401,6 @@ class BookmarkFileServiceTest extends TestCase
         $this->publicLinkDB->set(new Bookmark());
     }
 
-    /**
-     * Test set() method with an entry which is not a bookmark instance
-     */
-    public function testSetNotABookmark()
-    {
-        $this->expectException(\Exception::class);
-        $this->expectExceptionMessage('Provided data is invalid');
-
-        $this->privateLinkDB->set(['title' => 'hi!']);
-    }
-
     /**
      * Test set() method with a Bookmark without an ID defined.
      */
@@ -452,7 +436,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals(43, $bookmark->getId());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
@@ -472,7 +456,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($title, $bookmark->getTitle());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -490,17 +474,6 @@ class BookmarkFileServiceTest extends TestCase
         $this->publicLinkDB->addOrSet(new Bookmark());
     }
 
-    /**
-     * Test addOrSet() method with an entry which is not a bookmark instance
-     */
-    public function testAddOrSetNotABookmark()
-    {
-        $this->expectException(\Exception::class);
-        $this->expectExceptionMessage('Provided data is invalid');
-
-        $this->privateLinkDB->addOrSet(['title' => 'hi!']);
-    }
-
     /**
      * Test addOrSet() method for a bookmark without any field set and without writing the data store
      */
@@ -515,7 +488,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($title, $bookmark->getTitle());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -541,7 +514,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertInstanceOf(BookmarkNotFoundException::class, $exception);
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->privateLinkDB->get(42);
     }
@@ -558,17 +531,6 @@ class BookmarkFileServiceTest extends TestCase
         $this->publicLinkDB->remove($bookmark);
     }
 
-    /**
-     * Test remove() method with an entry which is not a bookmark instance
-     */
-    public function testRemoveNotABookmark()
-    {
-        $this->expectException(\Exception::class);
-        $this->expectExceptionMessage('Provided data is invalid');
-
-        $this->privateLinkDB->remove(['title' => 'hi!']);
-    }
-
     /**
      * Test remove() method with a Bookmark with an unknown ID
      */
@@ -645,7 +607,7 @@ class BookmarkFileServiceTest extends TestCase
 
         $conf = new ConfigManager('tests/utils/config/configJson');
         $conf->set('resource.datastore', 'null/store.db');
-        new BookmarkFileService($conf, $this->history, true);
+        new BookmarkFileService($conf, $this->history, $this->mutex, true);
     }
 
     /**
@@ -655,7 +617,7 @@ class BookmarkFileServiceTest extends TestCase
     {
         unlink(self::$testDatastore);
         $this->assertFileNotExists(self::$testDatastore);
-        new BookmarkFileService($this->conf, $this->history, true);
+        new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->assertFileExists(self::$testDatastore);
 
         // ensure the correct data has been written
@@ -669,7 +631,7 @@ class BookmarkFileServiceTest extends TestCase
     {
         unlink(self::$testDatastore);
         $this->assertFileNotExists(self::$testDatastore);
-        $db = new \FakeBookmarkService($this->conf, $this->history, false);
+        $db = new \FakeBookmarkService($this->conf, $this->history, $this->mutex, false);
         $this->assertFileNotExists(self::$testDatastore);
         $this->assertInstanceOf(BookmarkArray::class, $db->getBookmarks());
         $this->assertCount(0, $db->getBookmarks());
@@ -702,13 +664,13 @@ class BookmarkFileServiceTest extends TestCase
      */
     public function testSave()
     {
-        $testDB = new BookmarkFileService($this->conf, $this->history, true);
+        $testDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $dbSize = $testDB->count();
 
         $bookmark = new Bookmark();
         $testDB->add($bookmark);
 
-        $testDB = new BookmarkFileService($this->conf, $this->history, true);
+        $testDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->assertEquals($dbSize + 1, $testDB->count());
     }
 
@@ -718,27 +680,11 @@ class BookmarkFileServiceTest extends TestCase
     public function testCountHiddenPublic()
     {
         $this->conf->set('privacy.hide_public_links', true);
-        $linkDB = new BookmarkFileService($this->conf, $this->history, false);
+        $linkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
 
         $this->assertEquals(0, $linkDB->count());
     }
 
-    /**
-     * List the days for which bookmarks have been posted
-     */
-    public function testDays()
-    {
-        $this->assertEquals(
-            ['20100309', '20100310', '20121206', '20121207', '20130614', '20150310'],
-            $this->publicLinkDB->days()
-        );
-
-        $this->assertEquals(
-            ['20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'],
-            $this->privateLinkDB->days()
-        );
-    }
-
     /**
      * The URL corresponds to an existing entry in the DB
      */
@@ -786,6 +732,10 @@ class BookmarkFileServiceTest extends TestCase
                 // They need to be grouped with the first case found - order by date DESC: `sTuff`.
                 'sTuff' => 2,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ],
             $this->publicLinkDB->bookmarksCountPerTag()
         );
@@ -814,6 +764,10 @@ class BookmarkFileServiceTest extends TestCase
                 'tag3' => 1,
                 'tag4' => 1,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ],
             $this->privateLinkDB->bookmarksCountPerTag()
         );
@@ -927,6 +881,37 @@ class BookmarkFileServiceTest extends TestCase
         $this->publicLinkDB->findByHash('');
     }
 
+    /**
+     * Test filterHash() on a private bookmark while logged out.
+     */
+    public function testFilterHashPrivateWhileLoggedOut()
+    {
+        $this->expectException(BookmarkNotFoundException::class);
+        $this->expectExceptionMessage('The link you are trying to reach does not exist or has been deleted');
+
+        $hash = smallHash('20141125_084734' . 6);
+
+        $this->publicLinkDB->findByHash($hash);
+    }
+
+    /**
+     * Test filterHash() with private key.
+     */
+    public function testFilterHashWithPrivateKey()
+    {
+        $hash = smallHash('20141125_084734' . 6);
+        $privateKey = 'this is usually auto generated';
+
+        $bookmark = $this->privateLinkDB->findByHash($hash);
+        $bookmark->addAdditionalContentEntry('private_key', $privateKey);
+        $this->privateLinkDB->save();
+
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+        $bookmark = $this->privateLinkDB->findByHash($hash, $privateKey);
+
+        static::assertSame(6, $bookmark->getId());
+    }
+
     /**
      * Test linksCountPerTag all tags without filter.
      * Equal occurrences should be sorted alphabetically.
@@ -956,6 +941,10 @@ class BookmarkFileServiceTest extends TestCase
             'tag4' => 1,
             'ut' => 1,
             'w3c' => 1,
+            'assurance' => 1,
+            'coding-style' => 1,
+            'quality' => 1,
+            'standards' => 1,
         ];
         $tags = $this->privateLinkDB->bookmarksCountPerTag();
 
@@ -1054,6 +1043,10 @@ class BookmarkFileServiceTest extends TestCase
             'stallman' => 1,
             'ut' => 1,
             'w3c' => 1,
+            'assurance' => 1,
+            'coding-style' => 1,
+            'quality' => 1,
+            'standards' => 1,
         ];
         $bookmark = new Bookmark();
         $bookmark->setTags(['newTagToCount', BookmarkMarkdownFormatter::NO_MD_TAG]);
@@ -1065,33 +1058,105 @@ class BookmarkFileServiceTest extends TestCase
     }
 
     /**
-     * Test filterDay while logged in
+     * Test find by dates in the middle of the datastore (sorted by dates) with a single bookmark as a result.
      */
-    public function testFilterDayLoggedIn(): void
+    public function testFilterByDateMidTimePeriodSingleBookmark(): void
     {
-        $bookmarks = $this->privateLinkDB->filterDay('20121206');
-        $expectedIds = [4, 9, 1, 0];
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_160000'),
+            $before,
+            $after
+        );
 
-        static::assertCount(4, $bookmarks);
-        foreach ($bookmarks as $bookmark) {
-            $i = ($i ?? -1) + 1;
-            static::assertSame($expectedIds[$i], $bookmark->getId());
-        }
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(9, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_172539'), $after);
     }
 
     /**
-     * Test filterDay while logged out
+     * Test find by dates in the middle of the datastore (sorted by dates) with a multiple bookmarks as a result.
      */
-    public function testFilterDayLoggedOut(): void
+    public function testFilterByDateMidTimePeriodMultipleBookmarks(): void
     {
-        $bookmarks = $this->publicLinkDB->filterDay('20121206');
-        $expectedIds = [4, 9, 1];
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_180000'),
+            $before,
+            $after
+        );
 
-        static::assertCount(3, $bookmarks);
-        foreach ($bookmarks as $bookmark) {
-            $i = ($i ?? -1) + 1;
-            static::assertSame($expectedIds[$i], $bookmark->getId());
-        }
+        static::assertCount(2, $bookmarks);
+
+        static::assertSame(1, $bookmarks[0]->getId());
+        static::assertSame(9, $bookmarks[1]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_182539'), $after);
+    }
+
+    /**
+     * Test find by dates at the end of the datastore (sorted by dates).
+     */
+    public function testFilterByDateLastTimePeriod(): void
+    {
+        $after = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20150310_114640'),
+            DateTime::createFromFormat('Ymd_His', '20450101_010101'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(41, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20150310_114633'), $before);
+        static::assertNull($after);
+    }
+
+    /**
+     * Test find by dates at the beginning of the datastore (sorted by dates).
+     */
+    public function testFilterByDateFirstTimePeriod(): void
+    {
+        $before = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20000101_101010'),
+            DateTime::createFromFormat('Ymd_His', '20100309_110000'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(11, $bookmarks[0]->getId());
+        static::assertNull($before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20100310_101010'), $after);
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestWithSticky(): void
+    {
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertSame(41, $bookmark->getId());
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestEmptyDatastore(): void
+    {
+        unlink($this->conf->get('resource.datastore'));
+        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertNull($bookmark);
     }
 
     /**
index 48c7f8247bc02ca32900886e7f7d86aac90027c8..835674f2d6df2900efeba0c4d8a1022fae2a95b9 100644 (file)
@@ -2,7 +2,7 @@
 
 namespace Shaarli\Bookmark;
 
-use Exception;
+use malkusch\lock\mutex\NoMutex;
 use ReferenceLinkDB;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
@@ -37,13 +37,14 @@ class BookmarkFilterTest extends TestCase
      */
     public static function setUpBeforeClass(): void
     {
+        $mutex = new NoMutex();
         $conf = new ConfigManager('tests/utils/config/configJson');
         $conf->set('resource.datastore', self::$testDatastore);
         self::$refDB = new \ReferenceLinkDB();
         self::$refDB->write(self::$testDatastore);
         $history = new History('sandbox/history.php');
-        self::$bookmarkService = new \FakeBookmarkService($conf, $history, true);
-        self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks());
+        self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true);
+        self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks(), $conf);
     }
 
     /**
@@ -523,4 +524,43 @@ class BookmarkFilterTest extends TestCase
             ))
         );
     }
+
+    /**
+     * Test search result highlights in every field of bookmark reference #9.
+     */
+    public function testFullTextSearchHighlight(): void
+    {
+        $bookmarks = self::$linkFilter->filter(
+            BookmarkFilter::$FILTER_TEXT,
+            '"psr-2" coding guide http fig "psr-2/" "This guide" basic standard. coding-style quality assurance'
+        );
+
+        static::assertCount(1, $bookmarks);
+        static::assertArrayHasKey(9, $bookmarks);
+
+        $bookmark = $bookmarks[9];
+        $expectedHighlights = [
+            'title' => [
+                ['start' => 0, 'end' => 5], // "psr-2"
+                ['start' => 7, 'end' => 13], // coding
+                ['start' => 20, 'end' => 25], // guide
+            ],
+            'description' => [
+                ['start' => 0, 'end' => 10], // "This guide"
+                ['start' => 45, 'end' => 50], // basic
+                ['start' => 58, 'end' => 67], // standard.
+            ],
+            'url' => [
+                ['start' => 0, 'end' => 4], // http
+                ['start' => 15, 'end' => 18], // fig
+                ['start' => 27, 'end' => 33], // "psr-2/"
+            ],
+            'tags' => [
+                ['start' => 0, 'end' => 12], // coding-style
+                ['start' => 23, 'end' => 30], // quality
+                ['start' => 31, 'end' => 40], // assurance
+            ],
+        ];
+        static::assertSame($expectedHighlights, $bookmark->getAdditionalContentEntry('search_highlight'));
+    }
 }
index 25704004e8909f9d1d8e3878d1e2043e571f66ce..0c8420ce5ffceac84e4dd471cdc8f8fa567113d1 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Bookmark;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
 use Shaarli\TestCase;
@@ -34,11 +35,15 @@ class BookmarkInitializerTest extends TestCase
     /** @var BookmarkInitializer instance */
     protected $initializer;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Initialize an empty BookmarkFileService
      */
     public function setUp(): void
     {
+        $this->mutex = new NoMutex();
         if (file_exists(self::$testDatastore)) {
             unlink(self::$testDatastore);
         }
@@ -47,7 +52,7 @@ class BookmarkInitializerTest extends TestCase
         $this->conf = new ConfigManager(self::$testConf);
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->history = new History('sandbox/history.php');
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->initializer = new BookmarkInitializer($this->bookmarkService);
     }
@@ -59,7 +64,7 @@ class BookmarkInitializerTest extends TestCase
     {
         $refDB = new \ReferenceLinkDB();
         $refDB->write(self::$testDatastore);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->initializer = new BookmarkInitializer($this->bookmarkService);
 
         $this->initializer->initialize();
@@ -90,7 +95,7 @@ class BookmarkInitializerTest extends TestCase
         $this->bookmarkService->save();
 
         // Reload from file
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->assertEquals($refDB->countLinks() + 3, $this->bookmarkService->count());
 
         $bookmark = $this->bookmarkService->get(43);
@@ -121,7 +126,7 @@ class BookmarkInitializerTest extends TestCase
     public function testInitializeNonExistentDataStore(): void
     {
         $this->conf->set('resource.datastore', static::$testDatastore . '_empty');
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->initializer->initialize();
 
index afec24403e75bb0c34255e9dff54db987ecdc3e4..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
      */
@@ -153,25 +170,6 @@ class BookmarkTest extends TestCase
         $this->assertContainsPolyfill('- ID: '. PHP_EOL, $exception->getMessage());
     }
 
-    /**
-     * Test validate() with a a bookmark with a non integer ID.
-     */
-    public function testValidateNotValidStringId()
-    {
-        $bookmark = new Bookmark();
-        $bookmark->setId('str');
-        $bookmark->setShortUrl('abc');
-        $bookmark->setCreated(\DateTime::createFromFormat('Ymd_His', '20190514_200102'));
-        $exception = null;
-        try {
-            $bookmark->validate();
-        } catch (InvalidBookmarkException $e) {
-            $exception = $e;
-        }
-        $this->assertNotNull($exception);
-        $this->assertContainsPolyfill('- ID: str'. PHP_EOL, $exception->getMessage());
-    }
-
     /**
      * Test validate() with a a bookmark without short url.
      */
@@ -210,25 +208,6 @@ class BookmarkTest extends TestCase
         $this->assertContainsPolyfill('- Created: '. PHP_EOL, $exception->getMessage());
     }
 
-    /**
-     * Test validate() with a a bookmark with a bad created datetime.
-     */
-    public function testValidateNotValidBadCreated()
-    {
-        $bookmark = new Bookmark();
-        $bookmark->setId(1);
-        $bookmark->setShortUrl('abc');
-        $bookmark->setCreated('hi!');
-        $exception = null;
-        try {
-            $bookmark->validate();
-        } catch (InvalidBookmarkException $e) {
-            $exception = $e;
-        }
-        $this->assertNotNull($exception);
-        $this->assertContainsPolyfill('- Created: Not a DateTime object'. PHP_EOL, $exception->getMessage());
-    }
-
     /**
      * Test setId() and make sure that default fields are generated.
      */
@@ -290,7 +269,7 @@ class BookmarkTest extends TestCase
     {
         $bookmark = new Bookmark();
 
-        $str = 'tag1    tag2 tag3.tag3-2, tag4   ,  -tag5   ';
+        $str = 'tag1    tag2 tag3.tag3-2 tag4     -tag5   ';
         $bookmark->setTagsString($str);
         $this->assertEquals(
             [
@@ -314,9 +293,9 @@ class BookmarkTest extends TestCase
         $array = [
             'tag1    ',
             '     tag2',
-            'tag3.tag3-2,',
-            ',  tag4',
-            ',  ',
+            'tag3.tag3-2',
+            '  tag4',
+            '  ',
             '-tag5   ',
         ];
         $bookmark->setTags($array);
@@ -385,4 +364,48 @@ class BookmarkTest extends TestCase
         $bookmark->deleteTag('nope');
         $this->assertEquals(['tag1', 'tag2', 'chair'], $bookmark->getTags());
     }
+
+    /**
+     * Test shouldUpdateThumbnail() with bookmarks needing an update.
+     */
+    public function testShouldUpdateThumbnail(): void
+    {
+        $bookmark = (new Bookmark())->setUrl('http://domain.tld/with-image');
+
+        static::assertTrue($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('http://domain.tld/with-image')
+            ->setThumbnail('unknown file')
+        ;
+
+        static::assertTrue($bookmark->shouldUpdateThumbnail());
+    }
+
+    /**
+     * Test shouldUpdateThumbnail() with bookmarks that should not update.
+     */
+    public function testShouldNotUpdateThumbnail(): void
+    {
+        $bookmark = (new Bookmark());
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('ftp://domain.tld/other-protocol', ['ftp'])
+        ;
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('http://domain.tld/with-image')
+            ->setThumbnail(__FILE__)
+        ;
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())->setUrl('/shaare/abcdef');
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+    }
 }
index ef00b92f8c3bce554b2dd56e518fa27bb4578a59..ddab4e3caebd0e7070273738054f9f58d0821aa0 100644 (file)
@@ -94,8 +94,108 @@ class LinkUtilsTest extends TestCase
     public function testHtmlExtractExistentNameTag()
     {
         $description = 'Bob and Alice share cookies.';
+
+        // Simple one line
         $html = '<html><meta>stuff2</meta><meta name="description" content="' . $description . '"/></html>';
         $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // Simple OpenGraph
+        $html = '<meta property="og:description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // Simple reversed OpenGraph
+        $html = '<meta content="' . $description . '" property="og:description">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // ItemProp OpenGraph
+        $html = '<meta itemprop="og:description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph without quotes
+        $html = '<meta property=og:description content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed without quotes
+        $html = '<meta content="' . $description . '" property=og:description>';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph with noise
+        $html = '<meta tag1="content1" property="og:description" tag2="content2" content="' .
+            $description . '" tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed with noise
+        $html = '<meta tag1="content1" content="' . $description . '" ' .
+            'tag3="content3" tag2="content2" property="og:description">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties start
+        $html = '<meta property="unrelated og:description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties end
+        $html = '<meta property="og:description unrelated" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties both end
+        $html = '<meta property="og:unrelated1 og:description og:unrelated2" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties both end with noise
+        $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
+            'tag2="content2" content="' . $description . '" tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties start
+        $html = '<meta content="' . $description . '" property="unrelated og:description">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties end
+        $html = '<meta content="' . $description . '" property="og:description unrelated">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties both end
+        $html = '<meta content="' . $description . '" property="og:unrelated1 og:description og:unrelated2">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties both end with noise
+        $html = '<meta tag1="content1" content="' . $description . '" tag2="content2" '.
+            'property="og:unrelated1 og:description og:unrelated2" tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // Suggestion from #1375
+        $html = '<meta property="og:description" name="description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+    }
+
+    /**
+     * Test html_extract_tag() with double quoted content containing single quote, and the opposite.
+     */
+    public function testHtmlExtractExistentNameTagWithMixedQuotes(): void
+    {
+        $description = 'Bob and Alice share M&M\'s.';
+
+        $html = '<meta property="og:description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
+            'tag2="content2" content="' . $description . '" tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        $html = '<meta property="og:description" name="description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        $description = 'Bob and Alice share "cookies".';
+
+        $html = '<meta property="og:description" content=\'' . $description . '\'>';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
+            'tag2="content2" content=\'' . $description . '\' tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        $html = '<meta property="og:description" name="description" content=\'' . $description . '\'>';
+        $this->assertEquals($description, html_extract_tag('description', $html));
     }
 
     /**
@@ -105,6 +205,25 @@ class LinkUtilsTest extends TestCase
     {
         $html = '<html><meta>stuff2</meta><meta name="image" content="img"/></html>';
         $this->assertFalse(html_extract_tag('description', $html));
+
+        // Partial meta tag
+        $html = '<meta content="Brief description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta property="og:description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta tag1="content1" property="og:description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta property="og:description" tag1="content1">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta tag1="content1" content="Brief description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta content="Brief description" tag1="content1">';
+        $this->assertFalse(html_extract_tag('description', $html));
     }
 
     /**
@@ -126,61 +245,94 @@ class LinkUtilsTest extends TestCase
         $this->assertFalse(html_extract_tag('description', $html));
     }
 
+    /**
+     * Test the header callback with valid value
+     */
+    public function testCurlHeaderCallbackOk(): void
+    {
+        $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_ok');
+        $data = [
+            'HTTP/1.1 200 OK',
+            'Server: GitHub.com',
+            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
+            'Content-Type: text/html; charset=utf-8',
+            'Status: 200 OK',
+        ];
+
+        foreach ($data as $chunk) {
+            static::assertIsInt($callback(null, $chunk));
+        }
+
+        static::assertSame('utf-8', $charset);
+    }
+
     /**
      * Test the download callback with valid value
      */
-    public function testCurlDownloadCallbackOk()
+    public function testCurlDownloadCallbackOk(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
             false,
-            'ut_curl_getinfo_ok'
+            ' '
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
-            'end' => 'th=device-width">'
+            'th=device-width">'
                 . '<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',
@@ -188,10 +340,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);
@@ -201,18 +354,19 @@ class LinkUtilsTest extends TestCase
     /**
      * Test the download callback with valid values and no charset
      */
-    public function testCurlDownloadCallbackOkHtmlCharset()
+    public function testCurlDownloadCallbackOkHtmlCharset(): void
     {
+        $charset = null;
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
             false,
-            'ut_curl_getinfo_no_charset'
+            ' '
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
             '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />',
             'end' => 'th=device-width">'
                 . '<title>Refactoring · GitHub</title>'
@@ -221,14 +375,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);
@@ -238,25 +388,27 @@ class LinkUtilsTest extends TestCase
     /**
      * Test the download callback with valid values and no title
      */
-    public function testCurlDownloadCallbackOkNoTitle()
+    public function testCurlDownloadCallbackOkNoTitle(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
             false,
-            'ut_curl_getinfo_ok'
+            ' '
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
             'end' => 'th=device-width">Refactoring · GitHub<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);
@@ -264,81 +416,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',
@@ -346,14 +473,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);
@@ -364,8 +488,9 @@ class LinkUtilsTest extends TestCase
      * Test the download callback with valid value, and retrieve_description option enabled,
      * but no desc or keyword defined in the page.
      */
-    public function testCurlDownloadCallbackOkWithDescNotFound()
+    public function testCurlDownloadCallbackOkWithDescNotFound(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
@@ -375,24 +500,16 @@ class LinkUtilsTest extends TestCase
             'ut_curl_getinfo_ok'
         );
         $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
             'th=device-width">'
                 . '<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);
@@ -492,6 +609,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 2d675c9a006d9ab24f1e4e522df8f5a7e7de476e..3508a7b170d1572b017120b1966f911ed9f7892f 100644 (file)
@@ -30,3 +30,7 @@ require_once 'tests/utils/ReferenceLinkDB.php';
 require_once 'tests/utils/ReferenceSessionIdHashes.php';
 
 \ReferenceSessionIdHashes::genAllHashes();
+
+if (!defined('SHAARLI_MUTEX_FILE')) {
+    define('SHAARLI_MUTEX_FILE', __FILE__);
+}
index 5d52daefd5f7cf0e8d62559ee19cd8853c9e8e6a..3d43c34470d098dd69a18e5a78c874187c0ca83d 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;
@@ -54,7 +56,8 @@ class ContainerBuilderTest extends TestCase
             $this->conf,
             $this->sessionManager,
             $this->cookieManager,
-            $this->loginManager
+            $this->loginManager,
+            $this->createMock(LoggerInterface::class)
         );
     }
 
@@ -72,6 +75,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 c29e8ef3b01753b42f3cb37544cb49c165439a67..6b9204eb64949cb17ada2c26bf605e7f9a3373a4 100644 (file)
@@ -3,6 +3,7 @@
 namespace Shaarli\Feed;
 
 use DateTime;
+use malkusch\lock\mutex\NoMutex;
 use ReferenceLinkDB;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
@@ -47,6 +48,7 @@ class FeedBuilderTest extends TestCase
      */
     public static function setUpBeforeClass(): void
     {
+        $mutex = new NoMutex();
         $conf = new ConfigManager('tests/utils/config/configJson');
         $conf->set('resource.datastore', self::$testDatastore);
         $refLinkDB = new \ReferenceLinkDB();
@@ -54,7 +56,7 @@ class FeedBuilderTest extends TestCase
         $history = new History('sandbox/history.php');
         $factory = new FormatterFactory($conf, true);
         self::$formatter = $factory->getFormatter();
-        self::$bookmarkService = new BookmarkFileService($conf, $history, true);
+        self::$bookmarkService = new BookmarkFileService($conf, $history, $mutex, true);
 
         self::$serverInfo = array(
             'HTTPS' => 'Off',
index 9534436e3f7e7150c19a50022be476a36754f6b2..4fcc5dd19cc35356498f6acc7270b80ad4aef9bc 100644 (file)
@@ -174,4 +174,139 @@ class BookmarkDefaultFormatterTest extends TestCase
         $this->assertSame($tags, $link['taglist']);
         $this->assertSame(implode(' ', $tags), $link['tags']);
     }
+
+    /**
+     * Test formatTitleHtml with search result highlight.
+     */
+    public function testFormatTitleHtmlWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setTitle('PSR-2: Coding Style Guide');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['title' => [
+                ['start' => 0, 'end' => 5], // "psr-2"
+                ['start' => 7, 'end' => 13], // coding
+                ['start' => 20, 'end' => 25], // guide
+            ]]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            '<span class="search-highlight">PSR-2</span>: ' .
+            '<span class="search-highlight">Coding</span> Style ' .
+            '<span class="search-highlight">Guide</span>',
+            $link['title_html']
+        );
+    }
+
+    /**
+     * Test formatDescription with search result highlight.
+     */
+    public function testFormatDescriptionWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setDescription('This guide extends and expands on PSR-1, the basic coding standard.');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['description' => [
+                ['start' => 0, 'end' => 10], // "This guide"
+                ['start' => 45, 'end' => 50], // basic
+                ['start' => 58, 'end' => 67], // standard.
+            ]]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            '<span class="search-highlight">This guide</span> extends and expands on PSR-1, the ' .
+            '<span class="search-highlight">basic</span> coding ' .
+            '<span class="search-highlight">standard.</span>',
+            $link['description']
+        );
+    }
+
+    /**
+     * Test formatUrlHtml with search result highlight.
+     */
+    public function testFormatUrlHtmlWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setUrl('http://www.php-fig.org/psr/psr-2/');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['url' => [
+                ['start' => 0, 'end' => 4], // http
+                ['start' => 15, 'end' => 18], // fig
+                ['start' => 27, 'end' => 33], // "psr-2/"
+            ]]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            '<span class="search-highlight">http</span>://www.php-' .
+            '<span class="search-highlight">fig</span>.org/psr/' .
+            '<span class="search-highlight">psr-2/</span>',
+            $link['url_html']
+        );
+    }
+
+    /**
+     * Test formatTagListHtml with search result highlight.
+     */
+    public function testFormatTagListHtmlWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setTagsString('coding-style standards quality assurance');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['tags' => [
+                ['start' => 0, 'end' => 12], // coding-style
+                ['start' => 23, 'end' => 30], // quality
+                ['start' => 31, 'end' => 40], // assurance
+            ],]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            [
+                '<span class="search-highlight">coding-style</span>',
+                'standards',
+                '<span class="search-highlight">quality</span>',
+                '<span class="search-highlight">assurance</span>',
+            ],
+            $link['taglist_html']
+        );
+    }
+
+    /**
+     * Test default formatting with formatter_settings.autolink set to false:
+     *   URLs and hashtags should not be transformed
+     */
+    public function testFormatDescriptionWithoutLinkification(): void
+    {
+        $this->conf->set('formatter_settings.autolink', false);
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setDescription('Hi!' . PHP_EOL . 'https://thisisaurl.tld  #hashtag');
+
+        $link = $this->formatter->format($bookmark);
+
+        static::assertSame(
+            'Hi!<br />' . PHP_EOL . 'https://thisisaurl.tld &nbsp;#hashtag',
+            $link['description']
+        );
+    }
 }
diff --git a/tests/formatter/BookmarkMarkdownExtraFormatterTest.php b/tests/formatter/BookmarkMarkdownExtraFormatterTest.php
new file mode 100644 (file)
index 0000000..d4941ef
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+
+namespace Shaarli\Formatter;
+
+use DateTime;
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkMarkdownExtraFormatterTest
+ * @package Shaarli\Formatter
+ */
+class BookmarkMarkdownExtraFormatterTest extends TestCase
+{
+    /** @var string Path of test config file */
+    protected static $testConf = 'sandbox/config';
+
+    /** @var BookmarkFormatter */
+    protected $formatter;
+
+    /** @var ConfigManager instance */
+    protected $conf;
+
+    /**
+     * Initialize formatter instance.
+     */
+    public function setUp(): void
+    {
+        copy('tests/utils/config/configJson.json.php', self::$testConf .'.json.php');
+        $this->conf = new ConfigManager(self::$testConf);
+        $this->formatter = new BookmarkMarkdownExtraFormatter($this->conf, true);
+    }
+
+    /**
+     * Test formatting a bookmark with all its attribute filled.
+     */
+    public function testFormatExtra(): void
+    {
+        $bookmark = new Bookmark();
+        $bookmark->setId($id = 11);
+        $bookmark->setShortUrl($short = 'abcdef');
+        $bookmark->setUrl('https://sub.domain.tld?query=here&for=real#hash');
+        $bookmark->setTitle($title = 'This is a <strong>bookmark</strong>');
+        $bookmark->setDescription('<h2>Content</h2><p>`Here is some content</p>');
+        $bookmark->setTags($tags = ['tag1', 'bookmark', 'other', '<script>alert("xss");</script>']);
+        $bookmark->setThumbnail('http://domain2.tdl2/?type=img&name=file.png');
+        $bookmark->setSticky(true);
+        $bookmark->setCreated($created = DateTime::createFromFormat('Ymd_His', '20190521_190412'));
+        $bookmark->setUpdated($updated = DateTime::createFromFormat('Ymd_His', '20190521_191213'));
+        $bookmark->setPrivate(true);
+
+        $link = $this->formatter->format($bookmark);
+        $this->assertEquals($id, $link['id']);
+        $this->assertEquals($short, $link['shorturl']);
+        $this->assertEquals('https://sub.domain.tld?query=here&amp;for=real#hash', $link['url']);
+        $this->assertEquals(
+            'https://sub.domain.tld?query=here&amp;for=real#hash',
+            $link['real_url']
+        );
+        $this->assertEquals('This is a &lt;strong&gt;bookmark&lt;/strong&gt;', $link['title']);
+        $this->assertEquals(
+            '<div class="markdown"><p>'.
+                '&lt;h2&gt;Content&lt;/h2&gt;&lt;p&gt;`Here is some content&lt;/p&gt;'.
+            '</p></div>',
+            $link['description']
+        );
+        $tags[3] = '&lt;script&gt;alert(&quot;xss&quot;);&lt;/script&gt;';
+        $this->assertEquals($tags, $link['taglist']);
+        $this->assertEquals(implode(' ', $tags), $link['tags']);
+        $this->assertEquals(
+            'http://domain2.tdl2/?type=img&amp;name=file.png',
+            $link['thumbnail']
+        );
+        $this->assertEquals($created, $link['created']);
+        $this->assertEquals($created->getTimestamp(), $link['timestamp']);
+        $this->assertEquals($updated, $link['updated']);
+        $this->assertEquals($updated->getTimestamp(), $link['updated_timestamp']);
+        $this->assertTrue($link['private']);
+        $this->assertTrue($link['sticky']);
+        $this->assertEquals('private', $link['class']);
+    }
+
+    /**
+     * Test formatting a bookmark with all its attribute filled.
+     */
+    public function testFormatExtraMinimal(): void
+    {
+        $bookmark = new Bookmark();
+
+        $link = $this->formatter->format($bookmark);
+        $this->assertEmpty($link['id']);
+        $this->assertEmpty($link['shorturl']);
+        $this->assertEmpty($link['url']);
+        $this->assertEmpty($link['real_url']);
+        $this->assertEmpty($link['title']);
+        $this->assertEmpty($link['description']);
+        $this->assertEmpty($link['taglist']);
+        $this->assertEmpty($link['tags']);
+        $this->assertEmpty($link['thumbnail']);
+        $this->assertEmpty($link['created']);
+        $this->assertEmpty($link['timestamp']);
+        $this->assertEmpty($link['updated']);
+        $this->assertEmpty($link['updated_timestamp']);
+        $this->assertFalse($link['private']);
+        $this->assertFalse($link['sticky']);
+        $this->assertEmpty($link['class']);
+    }
+
+    /**
+     * Make sure that the description is properly formatted by the default formatter.
+     */
+    public function testFormatExtrraDescription(): void
+    {
+        $description = 'This a <strong>description</strong>'. PHP_EOL;
+        $description .= 'text https://sub.domain.tld?query=here&for=real#hash more text'. PHP_EOL;
+        $description .= 'Also, there is an #hashtag added'. PHP_EOL;
+        $description .= '    A  N  D KEEP     SPACES    !   '. PHP_EOL;
+        $description .= '# Header {.class}'. PHP_EOL;
+
+        $bookmark = new Bookmark();
+        $bookmark->setDescription($description);
+        $link = $this->formatter->format($bookmark);
+
+        $description = '<div class="markdown"><p>';
+        $description .= 'This a &lt;strong&gt;description&lt;/strong&gt;<br />'. PHP_EOL;
+        $url = 'https://sub.domain.tld?query=here&amp;for=real#hash';
+        $description .= 'text <a href="'. $url .'">'. $url .'</a> more text<br />'. PHP_EOL;
+        $description .= 'Also, there is an <a href="./add-tag/hashtag">#hashtag</a> added<br />'. PHP_EOL;
+        $description .= 'A  N  D KEEP     SPACES    !   </p>' . PHP_EOL;
+        $description .= '<h1 class="class">Header</h1>';
+        $description .= '</div>';
+
+        $this->assertEquals($description, $link['description']);
+    }
+
+    /**
+     * Test formatting URL with an index_url set
+     * It should prepend relative links.
+     */
+    public function testFormatExtraNoteWithIndexUrl(): void
+    {
+        $bookmark = new Bookmark();
+        $bookmark->setUrl($short = '?abcdef');
+        $description = 'Text #hashtag more text';
+        $bookmark->setDescription($description);
+
+        $this->formatter->addContextData('index_url', $root = 'https://domain.tld/hithere/');
+
+        $description = '<div class="markdown"><p>';
+        $description .= 'Text <a href="'. $root .'./add-tag/hashtag">#hashtag</a> more text';
+        $description .= '</p></div>';
+
+        $link = $this->formatter->format($bookmark);
+        $this->assertEquals($root . $short, $link['url']);
+        $this->assertEquals($root . $short, $link['real_url']);
+        $this->assertEquals(
+            $description,
+            $link['description']
+        );
+    }
+}
index aca6cff310394979c1677f22b41789d1f2571c9d..d82db0a7c3e4692b4fb9bd9be81bdde4def86a11 100644 (file)
@@ -51,7 +51,7 @@ class ConfigureControllerTest extends TestCase
         static::assertSame('general.title', $assignedVariables['title']);
         static::assertSame('resource.theme', $assignedVariables['theme']);
         static::assertEmpty($assignedVariables['theme_available']);
-        static::assertSame(['default', 'markdown'], $assignedVariables['formatter_available']);
+        static::assertSame(['default', 'markdown', 'markdownExtra'], $assignedVariables['formatter_available']);
         static::assertNotEmpty($assignedVariables['continents']);
         static::assertNotEmpty($assignedVariables['cities']);
         static::assertSame('general.retrieve_description', $assignedVariables['retrieve_description']);
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php b/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php
deleted file mode 100644 (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 94%
rename from tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php
rename to tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php
index ba774e2119137248bd5d23797137bbc006327f0f..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'));
     }
 
     /**
@@ -356,6 +360,10 @@ class DeleteBookmarkTest extends TestCase
         ;
         $response = new Response();
 
+        $this->container->bookmarkService->method('get')->with('123')->willReturn(
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')
+        );
+
         $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
         $this->container->formatterFactory
             ->expects(static::once())
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 80%
rename from tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php
rename to tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php
index f7a68226148e4b9434649b8fb409956745322416..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);
     }
 
     /**
@@ -66,23 +66,27 @@ class SaveBookmarkTest extends TestCase
         $this->container->bookmarkService
             ->expects(static::once())
             ->method('addOrSet')
-            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
                 static::assertFalse($save);
 
                 $checkBookmark($bookmark);
 
                 $bookmark->setId($id);
+
+                return $bookmark;
             })
         ;
         $this->container->bookmarkService
             ->expects(static::once())
             ->method('set')
-            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
                 static::assertTrue($save);
 
                 $checkBookmark($bookmark);
 
                 static::assertSame($id, $bookmark->getId());
+
+                return $bookmark;
             })
         ;
 
@@ -155,21 +159,25 @@ class SaveBookmarkTest extends TestCase
         $this->container->bookmarkService
             ->expects(static::once())
             ->method('addOrSet')
-            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
                 static::assertFalse($save);
 
                 $checkBookmark($bookmark);
+
+                return $bookmark;
             })
         ;
         $this->container->bookmarkService
             ->expects(static::once())
             ->method('set')
-            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): void {
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
                 static::assertTrue($save);
 
                 $checkBookmark($bookmark);
 
                 static::assertSame($id, $bookmark->getId());
+
+                return $bookmark;
             })
         ;
 
@@ -201,7 +209,7 @@ class SaveBookmarkTest extends TestCase
     /**
      * Test save a bookmark - try to retrieve the thumbnail
      */
-    public function testSaveBookmarkWithThumbnail(): void
+    public function testSaveBookmarkWithThumbnailSync(): void
     {
         $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
 
@@ -216,7 +224,13 @@ class SaveBookmarkTest extends TestCase
 
         $this->container->conf = $this->createMock(ConfigManager::class);
         $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
-            return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default;
+            if ($key === 'thumbnails.mode') {
+                return Thumbnailer::MODE_ALL;
+            } elseif ($key === 'general.enable_async_metadata') {
+                return false;
+            }
+
+            return $default;
         });
 
         $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
@@ -230,8 +244,10 @@ class SaveBookmarkTest extends TestCase
         $this->container->bookmarkService
             ->expects(static::once())
             ->method('addOrSet')
-            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($thumb): void {
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($thumb): Bookmark {
                 static::assertSame($thumb, $bookmark->getThumbnail());
+
+                return $bookmark;
             })
         ;
 
@@ -264,6 +280,51 @@ class SaveBookmarkTest extends TestCase
         static::assertSame(302, $result->getStatusCode());
     }
 
+    /**
+     * Test save a bookmark - do not attempt to retrieve thumbnails if async mode is enabled.
+     */
+    public function testSaveBookmarkWithThumbnailAsync(): void
+    {
+        $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'thumbnails.mode') {
+                return Thumbnailer::MODE_ALL;
+            } elseif ($key === 'general.enable_async_metadata') {
+                return true;
+            }
+
+            return $default;
+        });
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer->expects(static::never())->method('get');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark): Bookmark {
+                static::assertNull($bookmark->getThumbnail());
+
+                return $bookmark;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+    }
+
     /**
      * Change the password with a wrong existing password
      */
index f4a8acffbdc822790bc6f5a30de0934022719d55..e5749654bec896f8866e4daee2b79a692cbe2d80 100644 (file)
@@ -89,8 +89,10 @@ class ThumbnailsControllerTest extends TestCase
         $this->container->bookmarkService
             ->expects(static::once())
             ->method('set')
-            ->willReturnCallback(function (Bookmark $bookmark) use ($thumb) {
+            ->willReturnCallback(function (Bookmark $bookmark) use ($thumb): Bookmark {
                 static::assertSame($thumb, $bookmark->getThumbnail());
+
+                return $bookmark;
             })
         ;
 
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..5255b7b
--- /dev/null
@@ -0,0 +1,262 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Helper;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+
+class DailyPageHelperTest extends TestCase
+{
+    /**
+     * @dataProvider getRequestedTypes
+     */
+    public function testExtractRequestedType(array $queryParams, string $expectedType): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function ($key) use ($queryParams): ?string {
+            return $queryParams[$key] ?? null;
+        });
+
+        $type = DailyPageHelper::extractRequestedType($request);
+
+        static::assertSame($type, $expectedType);
+    }
+
+    /**
+     * @dataProvider getRequestedDateTimes
+     */
+    public function testExtractRequestedDateTime(
+        string $type,
+        string $input,
+        ?Bookmark $bookmark,
+        \DateTimeInterface $expectedDateTime,
+        string $compareFormat = 'Ymd'
+    ): void {
+        $dateTime = DailyPageHelper::extractRequestedDateTime($type, $input, $bookmark);
+
+        static::assertSame($dateTime->format($compareFormat), $expectedDateTime->format($compareFormat));
+    }
+
+    public function testExtractRequestedDateTimeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::extractRequestedDateTime('nope', null, null);
+    }
+
+    /**
+     * @dataProvider getFormatsByType
+     */
+    public function testGetFormatByType(string $type, string $expectedFormat): void
+    {
+        $format = DailyPageHelper::getFormatByType($type);
+
+        static::assertSame($expectedFormat, $format);
+    }
+
+    public function testGetFormatByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getFormatByType('nope');
+    }
+
+    /**
+     * @dataProvider getStartDatesByType
+     */
+    public function testGetStartDatesByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        \DateTimeInterface $expectedDateTime
+    ): void {
+        $startDateTime = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
+
+        static::assertEquals($expectedDateTime, $startDateTime);
+    }
+
+    public function testGetStartDatesByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getStartDateTimeByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getEndDatesByType
+     */
+    public function testGetEndDatesByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        \DateTimeInterface $expectedDateTime
+    ): void {
+        $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
+
+        static::assertEquals($expectedDateTime, $endDateTime);
+    }
+
+    public function testGetEndDatesByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getEndDateTimeByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getDescriptionsByType
+     */
+    public function testGeDescriptionsByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        string $expectedDescription
+    ): void {
+        $description = DailyPageHelper::getDescriptionByType($type, $dateTime);
+
+        static::assertEquals($expectedDescription, $description);
+    }
+
+    public function getDescriptionByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getDescriptionByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getRssLengthsByType
+     */
+    public function testGeRssLengthsByType(string $type): void {
+        $length = DailyPageHelper::getRssLengthByType($type);
+
+        static::assertIsInt($length);
+    }
+
+    public function testGeRssLengthsByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getRssLengthByType('nope');
+    }
+
+    /**
+     * Data provider for testExtractRequestedType() test method.
+     */
+    public function getRequestedTypes(): array
+    {
+        return [
+            [['month' => null], DailyPageHelper::DAY],
+            [['month' => ''], DailyPageHelper::MONTH],
+            [['month' => 'content'], DailyPageHelper::MONTH],
+            [['week' => null], DailyPageHelper::DAY],
+            [['week' => ''], DailyPageHelper::WEEK],
+            [['week' => 'content'], DailyPageHelper::WEEK],
+            [['day' => null], DailyPageHelper::DAY],
+            [['day' => ''], DailyPageHelper::DAY],
+            [['day' => 'content'], DailyPageHelper::DAY],
+        ];
+    }
+
+    /**
+     * Data provider for testExtractRequestedDateTime() test method.
+     */
+    public function getRequestedDateTimes(): array
+    {
+        return [
+            [DailyPageHelper::DAY, '20201013', null, new \DateTime('2020-10-13')],
+            [
+                DailyPageHelper::DAY,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                $date,
+            ],
+            [DailyPageHelper::DAY, '', null, new \DateTime()],
+            [DailyPageHelper::WEEK, '202030', null, new \DateTime('2020-07-20')],
+            [
+                DailyPageHelper::WEEK,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                new \DateTime('2020-10-13'),
+            ],
+            [DailyPageHelper::WEEK, '', null, new \DateTime(), 'Ym'],
+            [DailyPageHelper::MONTH, '202008', null, new \DateTime('2020-08-01'), 'Ym'],
+            [
+                DailyPageHelper::MONTH,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                new \DateTime('2020-10-13'),
+                'Ym'
+            ],
+            [DailyPageHelper::MONTH, '', null, new \DateTime(), 'Ym'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetFormatByType() test method.
+     */
+    public function getFormatsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, 'Ymd'],
+            [DailyPageHelper::WEEK, 'YW'],
+            [DailyPageHelper::MONTH, 'Ym'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetStartDatesByType() test method.
+     */
+    public function getStartDatesByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 00:00:00')],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-05 00:00:00')],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-01 00:00:00')],
+        ];
+    }
+
+    /**
+     * Data provider for testGetEndDatesByType() test method.
+     */
+    public function getEndDatesByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 23:59:59')],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-11 23:59:59')],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-31 23:59:59')],
+        ];
+    }
+
+    /**
+     * Data provider for testGetDescriptionsByType() test method.
+     */
+    public function getDescriptionsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, $date = new \DateTimeImmutable(), 'Today - ' . $date->format('F j, Y')],
+            [DailyPageHelper::DAY, $date = new \DateTimeImmutable('-1 day'), 'Yesterday - ' . $date->format('F j, Y')],
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October 9, 2020'],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), 'Week 41 (October 5, 2020)'],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October, 2020'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetDescriptionsByType() test method.
+     */
+    public function getRssLengthsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY],
+            [DailyPageHelper::WEEK],
+            [DailyPageHelper::MONTH],
+        ];
+    }
+}
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..3c9eaa0
--- /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' => $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 df2cad62295594145843eac725bff46bec8068e3..5c3fd425f968c856b31d1e76dc96c47666be55d7 100644 (file)
@@ -296,6 +296,10 @@ class LegacyLinkDBTest extends \Shaarli\TestCase
                 // They need to be grouped with the first case found - order by date DESC: `sTuff`.
                 'sTuff' => 2,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ),
             self::$publicLinkDB->linksCountPerTag()
         );
@@ -324,6 +328,10 @@ class LegacyLinkDBTest extends \Shaarli\TestCase
                 'tag3' => 1,
                 'tag4' => 1,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ),
             self::$privateLinkDB->linksCountPerTag()
         );
@@ -544,6 +552,10 @@ class LegacyLinkDBTest extends \Shaarli\TestCase
             'tag4' => 1,
             'ut' => 1,
             'w3c' => 1,
+            'assurance' => 1,
+            'coding-style' => 1,
+            'quality' => 1,
+            'standards' => 1,
         ];
         $tags = self::$privateLinkDB->linksCountPerTag();
 
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 9b95ccc9eaf9338fbbed007ef63b1f300450ed6a..ad288f78ef802835f238e8d40f4c6c8d9bd2059d 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Netscape;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Formatter\BookmarkFormatter;
@@ -56,12 +57,13 @@ class BookmarkExportTest extends TestCase
      */
     public static function setUpBeforeClass(): void
     {
+        $mutex = new NoMutex();
         static::$conf = new ConfigManager('tests/utils/config/configJson');
         static::$conf->set('resource.datastore', static::$testDatastore);
         static::$refDb = new \ReferenceLinkDB();
         static::$refDb->write(static::$testDatastore);
         static::$history = new History('sandbox/history.php');
-        static::$bookmarkService = new BookmarkFileService(static::$conf, static::$history, true);
+        static::$bookmarkService = new BookmarkFileService(static::$conf, static::$history, $mutex, true);
         $factory = new FormatterFactory(static::$conf, true);
         static::$formatter = $factory->getFormatter('raw');
     }
index c1e49b5f454584fe0b29272e455e991656d3e8e5..6856ebcafebdecc18070638daa6430c6c71386da 100644 (file)
@@ -3,6 +3,7 @@
 namespace Shaarli\Netscape;
 
 use DateTime;
+use malkusch\lock\mutex\NoMutex;
 use Psr\Http\Message\UploadedFileInterface;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
@@ -87,6 +88,7 @@ class BookmarkImportTest extends TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         if (file_exists(self::$testDatastore)) {
             unlink(self::$testDatastore);
         }
@@ -97,7 +99,7 @@ class BookmarkImportTest extends TestCase
         $this->conf->set('resource.page_cache', $this->pagecache);
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->history = new History(self::$historyFilePath);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
         $this->netscapeBookmarkUtils = new NetscapeBookmarkUtils($this->bookmarkService, $this->conf, $this->history);
     }
 
@@ -529,7 +531,7 @@ class BookmarkImportTest extends TestCase
     {
         $post = array(
             'privacy' => 'public',
-            'default_tags' => 'tag1,tag2 tag3'
+            'default_tags' => 'tag1 tag2 tag3'
         );
         $files = file2array('netscape_basic.htm');
         $this->assertStringMatchesFormat(
@@ -550,7 +552,7 @@ class BookmarkImportTest extends TestCase
     {
         $post = array(
             'privacy' => 'public',
-            'default_tags' => 'tag1&,tag2 "tag3"'
+            'default_tags' => 'tag1& tag2 "tag3"'
         );
         $files = file2array('netscape_basic.htm');
         $this->assertStringMatchesFormat(
@@ -570,6 +572,43 @@ class BookmarkImportTest extends TestCase
         );
     }
 
+    /**
+     * Add user-specified tags to all imported bookmarks
+     */
+    public function testSetDefaultTagsWithCustomSeparator()
+    {
+        $separator = '@';
+        $this->conf->set('general.tags_separator', $separator);
+        $post = [
+            'privacy' => 'public',
+            'default_tags' => 'tag1@tag2@tag3@multiple words tag'
+        ];
+        $files = file2array('netscape_basic.htm');
+        $this->assertStringMatchesFormat(
+            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
+            .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
+            $this->netscapeBookmarkUtils->import($post, $files)
+        );
+        $this->assertEquals(2, $this->bookmarkService->count());
+        $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
+        $this->assertEquals(
+            'tag1@tag2@tag3@multiple words tag@private@secret',
+            $this->bookmarkService->get(0)->getTagsString($separator)
+        );
+        $this->assertEquals(
+            ['tag1', 'tag2', 'tag3', 'multiple words tag', 'private', 'secret'],
+            $this->bookmarkService->get(0)->getTags()
+        );
+        $this->assertEquals(
+            'tag1@tag2@tag3@multiple words tag@public@hello@world',
+            $this->bookmarkService->get(1)->getTagsString($separator)
+        );
+        $this->assertEquals(
+            ['tag1', 'tag2', 'tag3', 'multiple words tag', 'public', 'hello', 'world'],
+            $this->bookmarkService->get(1)->getTags()
+        );
+    }
+
     /**
      * Ensure each imported bookmark has a unique id
      *
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 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 a6280b8c9b911a8d3b33ac0d4af2684304a49256..cadd826538f2e76f08bffe27bb3d105abcf43375 100644 (file)
@@ -2,6 +2,7 @@
 namespace Shaarli\Updater;
 
 use Exception;
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
@@ -44,12 +45,13 @@ class UpdaterTest extends TestCase
      */
     protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->refDB = new \ReferenceLinkDB();
         $this->refDB->write(self::$testDatastore);
 
         copy('tests/utils/config/configJson.json.php', self::$configFile .'.json.php');
         $this->conf = new ConfigManager(self::$configFile);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->createMock(History::class), true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->createMock(History::class), $mutex, true);
         $this->updater = new Updater([], $this->bookmarkService, $this->conf, true);
     }
 
@@ -58,10 +60,10 @@ class UpdaterTest extends TestCase
      */
     public function testReadEmptyUpdatesFile()
     {
-        $this->assertEquals(array(), UpdaterUtils::read_updates_file(''));
+        $this->assertEquals(array(), UpdaterUtils::readUpdatesFile(''));
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         touch($updatesFile);
-        $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile));
+        $this->assertEquals(array(), UpdaterUtils::readUpdatesFile($updatesFile));
         unlink($updatesFile);
     }
 
@@ -73,14 +75,14 @@ class UpdaterTest extends TestCase
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         $updatesMethods = array('m1', 'm2', 'm3');
 
-        UpdaterUtils::write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = UpdaterUtils::read_updates_file($updatesFile);
+        UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
+        $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
         $this->assertEquals($readMethods, $updatesMethods);
 
         // Update
         $updatesMethods[] = 'm4';
-        UpdaterUtils::write_updates_file($updatesFile, $updatesMethods);
-        $readMethods = UpdaterUtils::read_updates_file($updatesFile);
+        UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
+        $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
         $this->assertEquals($readMethods, $updatesMethods);
         unlink($updatesFile);
     }
@@ -93,7 +95,7 @@ class UpdaterTest extends TestCase
         $this->expectException(\Exception::class);
         $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
 
-        UpdaterUtils::write_updates_file('', array('test'));
+        UpdaterUtils::writeUpdatesFile('', array('test'));
     }
 
     /**
@@ -108,7 +110,7 @@ class UpdaterTest extends TestCase
         touch($updatesFile);
         chmod($updatesFile, 0444);
         try {
-            @UpdaterUtils::write_updates_file($updatesFile, array('test'));
+            @UpdaterUtils::writeUpdatesFile($updatesFile, array('test'));
         } catch (Exception $e) {
             unlink($updatesFile);
             throw $e;
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 fc3cb1094930eb28f8c0aefa3f6b17e75654b3c3..1f53dc3cd60337d2c6cbda01616ef88e7fad014b 100644 (file)
@@ -82,7 +82,7 @@ class ReferenceLinkDB
             'This guide extends and expands on PSR-1, the basic coding standard.',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121206_152312'),
-            ''
+            'coding-style standards quality assurance'
         );
 
         $this->addLink(
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 16c558969a085dd5549cf6a64a53da60177a7f33..13b7f24a61b83188c8d899ad1d78daf3878b096b 100644 (file)
       <div><i class="fa fa-info-circle" aria-hidden="true"></i> {'Case sensitive'|t}</div>
       <input type="hidden" name="token" value="{$token}">
       <div>
-        <input type="submit" value="{'Rename'|t}" name="renametag">
-        <input type="submit" value="{'Delete'|t}" name="deletetag" class="button button-red confirm-delete">
+        <input type="submit" value="{'Rename tag'|t}" name="renametag">
+        <input type="submit" value="{'Delete tag'|t}" name="deletetag"
+               class="button button-red confirm-delete" data-type="tag">
       </div>
     </form>
 
     <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p>
   </div>
 </div>
+
+<div class="pure-g">
+  <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+  <div class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
+    <h2 class="window-title">{"Change tags separator"|t}</h2>
+    <form method="POST" action="{$base_path}/admin/tags/change-separator" name="changeseparator" id="changeseparator">
+      <p>
+        {'Your current tag separator is'|t} <code>{$tags_separator}</code>{if="!empty($tags_separator_desc)"} ({$tags_separator_desc}){/if}.
+      </p>
+      <div>
+        <input type="text" name="separator" placeholder="{'New separator'|t}"
+               id="separator">
+      </div>
+      <input type="hidden" name="token" value="{$token}">
+      <div>
+        <input type="submit" value="{'Save'|t}" name="saveseparator">
+      </div>
+      <p>
+        {'Note that hashtags won\'t fully work with a non-whitespace separator.'|t}
+      </p>
+    </form>
+  </div>
+</div>
 {include="page.footer"}
 </body>
 </html>
index 3ab8053f753280d348a00b31da3cd5c00e4d2f44..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">
@@ -76,7 +86,7 @@
                   </div>
                   {if="$thumbnails_enabled && !empty($link.thumbnail)"}
                     <div class="daily-entry-thumbnail">
-                      <img data-src="{$link.thumbnail}#" class="b-lazy"
+                      <img data-src="{$root_path}/{$link.thumbnail}#" class="b-lazy"
                            src=""
                            alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
                     </div>
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 227f9b52ae805080ec089ff59b1d2e16d0ee0dfd..3e3fb6640a52b366af7c3ad96b23484c5000f9b4 100644 (file)
@@ -8,14 +8,14 @@
 <link href="{$asset_path}/img/favicon.png#" rel="shortcut icon" type="image/png" />
 <link href="{$asset_path}/img/apple-touch-icon.png#" rel="apple-touch-icon" sizes="180x180" />
 <link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css?v={$version_hash}#" />
-{if="$formatter==='markdown'"}
+{if="strpos($formatter, 'markdown') !== false"}
   <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
 {/if}
 {loop="$plugins_includes.css_files"}
-  <link type="text/css" rel="stylesheet" href="{$base_path}/{$value}?v={$version_hash}#"/>
+  <link type="text/css" rel="stylesheet" href="{$root_path}/{$value}?v={$version_hash}#"/>
 {/loop}
 {if="is_file('data/user.css')"}
-  <link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />
+  <link type="text/css" rel="stylesheet" href="{$root_path}/data/user.css#" />
 {/if}
 <link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
       title="Shaarli search - {$shaarlititle}" />
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 b08773d8576162861561f359710d89346a69d509..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">
-                  <img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy"
+                  <img data-src="{$root_path}/{$value.thumbnail}#" class="b-lazy"
                     src=""
                     alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
                   </a>
             </div>
 
             <h2>
-              <a href="{$value.real_url}">
+              <a href="{$value.real_url}" class="linklist-real-url">
                 {if="strpos($value.url, $value.shorturl) === false"}
                   <i class="fa fa-external-link" aria-hidden="true"></i>
                 {else}
                   <i class="fa fa-sticky-note" aria-hidden="true"></i>
                 {/if}
 
-                <span class="linklist-link">{$value.title}</span>
+                <span class="linklist-link">{$value.title_html}</span>
               </a>
             </h2>
           </div>
                 {$tag_counter=count($value.taglist)}
                 {loop="value.taglist"}
                   <span class="label label-tag" title="{$strAddTag}">
-                    <a href="{$base_path}/add-tag/{$value1.urlencoded_taglist.$key2}">{$value}</a>
+                    <a href="{$base_path}/add-tag/{$value1.taglist_urlencoded.$key2}">{$value1.taglist_html.$key2}</a>
                   </span>
                   {if="$tag_counter - 1 != $counter"}&middot;{/if}
                 {/loop}
                   {$strPermalinkLc}
                 </a>
 
+                {if="$is_logged_in && $value.private"}
+                  <a href="{$base_path}/admin/shaare/private/{$value.shorturl}?token={$token}" title="{$strShaarePrivate}">
+                    <i class="fa fa-share-alt"></i>
+                  </a>
+                {/if}
+
                 <div class="pure-u-0 pure-u-lg-visible">
                   {if="isset($value.link_plugin)"}
                     &middot;
                 {ignore}do not add space or line break between these div - Firefox issue{/ignore}
                 class="linklist-item-infos-url pure-u-lg-5-12 pure-u-1">
                 <a href="{$value.real_url}" aria-label="{$value.title}" title="{$value.title}">
-                  <i class="fa fa-link" aria-hidden="true"></i> {$value.url}
+                  <i class="fa fa-link" aria-hidden="true"></i> {$value.url_html}
                 </a>
                 <div class="linklist-item-buttons pure-u-0 pure-u-lg-visible">
                   <a href="#" aria-label="{$strFold}" title="{$strFold}" class="fold-button"><i class="fa fa-chevron-up" aria-hidden="true"></i></a>
 
 {include="page.footer"}
 <script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
+{if="$is_logged_in && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
 </body>
 </html>
index 51bdb2f0b0eb0569f0b42e8d2255ccafb3a334d8..58ca18c5726da2b3519ff37cd3c47e2834322b5c 100644 (file)
@@ -10,7 +10,7 @@
     {/if}
     &middot;
     {'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t} &middot;
-    <a href="{$base_path}/doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
+    <a href="{$root_path}/doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
       {loop="$plugins_footer.text"}
           {$value}
       {/loop}
   <div class="pure-u-2-24"></div>
 </div>
 
-<input type="hidden" name="token" value="{$token}" id="token" />
-
 {loop="$plugins_footer.endofpage"}
     {$value}
 {/loop}
 
 {loop="$plugins_footer.js_files"}
-       <script src="{$base_path}/{$value}#"></script>
+       <script src="{$root_path}/{$value}#"></script>
 {/loop}
 
-<div id="js-translations" class="hidden">
+<div id="js-translations" class="hidden" aria-hidden="true">
   <span id="translation-fold">{'Fold'|t}</span>
   <span id="translation-fold-all">{'Fold all'|t}</span>
   <span id="translation-expand">{'Expand'|t}</span>
   <span id="translation-expand-all">{'Expand all'|t}</span>
   <span id="translation-delete-link">{'Are you sure you want to delete this link?'|t}</span>
+  <span id="translation-delete-tag">{'Are you sure you want to delete this tag?'|t}</span>
   <span id="translation-shaarli-desc">
     {'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t}
   </span>
 </div>
 
 <input type="hidden" name="js_base_path" value="{$base_path}" />
+<input type="hidden" name="token" value="{$token}" id="token" />
+<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
+
 <script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script>
index b7a56c89b17fb56540e1127acff994122286d035..ac613b35d56080bf0c4a0f303324871082d2e9d4 100644 (file)
@@ -31,7 +31,7 @@
       {loop="$linksToDisplay"}
         <div class="picwall-pictureframe" role="listitem">
           {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
-          <img data-src="{$value.thumbnail}#" class="b-lazy"
+          <img data-src="{$root_path}/{$value.thumbnail}#" class="b-lazy"
                src=""
                alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
           <a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
index 05d13556231418de5528e8053a7413f14f239d9d..5c073da645d4dcf0934dfce24773d4990f2dac19 100644 (file)
 
       <div class="center more">
         {"More plugins available"|t}
-        <a href="doc/html/Community-&-Related-software/#third-party-plugins">{"in the documentation"|t}</a>.
+        <a href="{$root_path}/doc/html/Community-&-Related-software/#third-party-plugins">{"in the documentation"|t}</a>.
       </div>
       <div class="center">
         <input type="submit" value="{'Save'|t}" name="save">
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..55bd9827843a20d21bd96e425c69e53af52fa976 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"