aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoryude <yudesleepy@gmail.com>2021-01-04 18:51:10 +0900
committerGitHub <noreply@github.com>2021-01-04 18:51:10 +0900
commite6754f2154a79abd8e5e64bd923f6984aa9ad44b (patch)
treef074119530bb59ef155938ea367f719f1e4b70f1
parent5256b4287021342a9f8868967b2a77e481314331 (diff)
parented4ee8f0297941ac83300389b7de6a293312d20e (diff)
downloadShaarli-e6754f2154a79abd8e5e64bd923f6984aa9ad44b.tar.gz
Shaarli-e6754f2154a79abd8e5e64bd923f6984aa9ad44b.tar.zst
Shaarli-e6754f2154a79abd8e5e64bd923f6984aa9ad44b.zip
Merge pull request #2 from shaarli/master
Merge fork source
-rw-r--r--.docker/nginx.conf38
-rw-r--r--.dockerignore11
-rw-r--r--.htaccess2
-rw-r--r--.travis.yml5
-rw-r--r--AUTHORS5
-rw-r--r--CHANGELOG.md50
-rw-r--r--Dockerfile3
-rw-r--r--Dockerfile.armhf8
-rw-r--r--Makefile5
-rw-r--r--README.md2
-rw-r--r--application/History.php12
-rw-r--r--application/Languages.php18
-rw-r--r--application/Thumbnailer.php13
-rw-r--r--application/TimeZone.php7
-rw-r--r--application/Utils.php78
-rw-r--r--application/api/ApiMiddleware.php4
-rw-r--r--application/api/ApiUtils.php27
-rw-r--r--application/api/controllers/HistoryController.php1
-rw-r--r--application/api/controllers/Info.php4
-rw-r--r--application/api/controllers/Links.php20
-rw-r--r--application/api/exceptions/ApiAuthorizationException.php2
-rw-r--r--application/api/exceptions/ApiException.php2
-rw-r--r--application/bookmark/Bookmark.php64
-rw-r--r--application/bookmark/BookmarkArray.php6
-rw-r--r--application/bookmark/BookmarkFileService.php67
-rw-r--r--application/bookmark/BookmarkFilter.php57
-rw-r--r--application/bookmark/BookmarkIO.php26
-rw-r--r--application/bookmark/BookmarkInitializer.php13
-rw-r--r--application/bookmark/BookmarkServiceInterface.php32
-rw-r--r--application/bookmark/LinkUtils.php74
-rw-r--r--application/bookmark/exception/BookmarkNotFoundException.php1
-rw-r--r--application/bookmark/exception/EmptyDataStoreException.php6
-rw-r--r--application/bookmark/exception/InvalidBookmarkException.php14
-rw-r--r--application/bookmark/exception/NotWritableDataStoreException.php4
-rw-r--r--application/config/ConfigIO.php1
-rw-r--r--application/config/ConfigJson.php6
-rw-r--r--application/config/ConfigManager.php21
-rw-r--r--application/config/ConfigPhp.php28
-rw-r--r--application/config/ConfigPlugin.php8
-rw-r--r--application/config/exception/MissingFieldConfigException.php1
-rw-r--r--application/config/exception/UnauthorizedConfigException.php1
-rw-r--r--application/container/ContainerBuilder.php34
-rw-r--r--application/container/ShaarliContainer.php4
-rw-r--r--application/exceptions/IOException.php1
-rw-r--r--application/feed/CachedPage.php45
-rw-r--r--application/feed/FeedBuilder.php9
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php18
-rw-r--r--application/formatter/BookmarkFormatter.php3
-rw-r--r--application/formatter/BookmarkMarkdownFormatter.php14
-rw-r--r--application/formatter/BookmarkRawFormatter.php4
-rw-r--r--application/formatter/FormatterFactory.php2
-rw-r--r--application/front/ShaarliMiddleware.php6
-rw-r--r--application/front/controller/admin/ConfigureController.php12
-rw-r--r--application/front/controller/admin/ExportController.php4
-rw-r--r--application/front/controller/admin/ImportController.php4
-rw-r--r--application/front/controller/admin/ManageShaareController.php371
-rw-r--r--application/front/controller/admin/ManageTagController.php37
-rw-r--r--application/front/controller/admin/MetadataController.php29
-rw-r--r--application/front/controller/admin/PasswordController.php4
-rw-r--r--application/front/controller/admin/PluginsController.php4
-rw-r--r--application/front/controller/admin/ServerController.php101
-rw-r--r--application/front/controller/admin/SessionFilterController.php2
-rw-r--r--application/front/controller/admin/ShaareAddController.php34
-rw-r--r--application/front/controller/admin/ShaareManageController.php202
-rw-r--r--application/front/controller/admin/ShaarePublishController.php274
-rw-r--r--application/front/controller/admin/ThumbnailsController.php2
-rw-r--r--application/front/controller/admin/ToolsController.php2
-rw-r--r--application/front/controller/visitor/BookmarkListController.php53
-rw-r--r--application/front/controller/visitor/DailyController.php108
-rw-r--r--application/front/controller/visitor/ErrorController.php12
-rw-r--r--application/front/controller/visitor/FeedController.php2
-rw-r--r--application/front/controller/visitor/InstallController.php44
-rw-r--r--application/front/controller/visitor/LoginController.php9
-rw-r--r--application/front/controller/visitor/PictureWallController.php2
-rw-r--r--application/front/controller/visitor/ShaarliVisitorController.php5
-rw-r--r--application/front/controller/visitor/TagCloudController.php12
-rw-r--r--application/front/controller/visitor/TagController.php18
-rw-r--r--application/helper/ApplicationUtils.php (renamed from application/ApplicationUtils.php)144
-rw-r--r--application/helper/DailyPageHelper.php236
-rw-r--r--application/helper/FileUtils.php (renamed from application/FileUtils.php)58
-rw-r--r--application/http/HttpAccess.php20
-rw-r--r--application/http/HttpUtils.php198
-rw-r--r--application/http/MetadataRetriever.php74
-rw-r--r--application/http/Url.php10
-rw-r--r--application/http/UrlUtils.php11
-rw-r--r--application/legacy/LegacyController.php2
-rw-r--r--application/legacy/LegacyLinkDB.php22
-rw-r--r--application/legacy/LegacyLinkFilter.php18
-rw-r--r--application/legacy/LegacyUpdater.php16
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php14
-rw-r--r--application/plugin/PluginManager.php77
-rw-r--r--application/plugin/exception/PluginFileNotFoundException.php1
-rw-r--r--application/plugin/exception/PluginInvalidRouteException.php26
-rw-r--r--application/render/PageBuilder.php34
-rw-r--r--application/render/PageCacheManager.php14
-rw-r--r--application/render/TemplatePage.php1
-rw-r--r--application/render/ThemeUtils.php4
-rw-r--r--application/security/BanManager.php32
-rw-r--r--application/security/LoginManager.php81
-rw-r--r--application/security/SessionManager.php10
-rw-r--r--application/updater/Updater.php10
-rw-r--r--application/updater/UpdaterUtils.php8
-rw-r--r--assets/common/js/metadata.js107
-rw-r--r--assets/common/js/shaare-batch.js121
-rw-r--r--assets/default/js/base.js73
-rw-r--r--assets/default/scss/shaarli.scss195
-rw-r--r--assets/vintage/css/shaarli.css61
-rw-r--r--assets/vintage/js/base.js45
-rw-r--r--composer.json4
-rw-r--r--composer.lock65
-rw-r--r--doc/md/Docker.md7
-rw-r--r--doc/md/REST-API.md2
-rw-r--r--doc/md/Server-configuration.md41
-rw-r--r--doc/md/Shaarli-configuration.md19
-rw-r--r--doc/md/dev/Development.md12
-rw-r--r--doc/md/dev/Plugin-system.md25
-rw-r--r--doc/md/dev/Release-Shaarli.md8
-rw-r--r--docker-compose.yml9
-rw-r--r--inc/languages/fr/LC_MESSAGES/shaarli.po793
-rw-r--r--inc/languages/ru/LC_MESSAGES/shaarli.po1944
-rw-r--r--index.php77
-rw-r--r--init.php2
-rw-r--r--package.json1
-rw-r--r--phpcs.xml12
-rw-r--r--plugins/addlink_toolbar/addlink_toolbar.php20
-rw-r--r--plugins/archiveorg/archiveorg.php1
-rw-r--r--plugins/default_colors/default_colors.php26
-rw-r--r--plugins/demo_plugin/DemoPluginController.php24
-rw-r--r--plugins/demo_plugin/demo_plugin.php64
-rw-r--r--plugins/isso/isso.php8
-rw-r--r--plugins/piwik/piwik.php3
-rw-r--r--plugins/playvideos/playvideos.php11
-rw-r--r--plugins/pubsubhubbub/pubsubhubbub.php14
-rw-r--r--plugins/qrcode/qrcode.php1
-rw-r--r--plugins/wallabag/WallabagInstance.php9
-rw-r--r--plugins/wallabag/wallabag.php10
-rw-r--r--tests/PluginManagerTest.php39
-rw-r--r--tests/UtilsTest.php36
-rw-r--r--tests/api/controllers/links/PostLinkTest.php56
-rw-r--r--tests/api/controllers/links/PutLinkTest.php48
-rw-r--r--tests/bookmark/BookmarkFileServiceTest.php155
-rw-r--r--tests/bookmark/BookmarkFilterTest.php2
-rw-r--r--tests/bookmark/BookmarkTest.php69
-rw-r--r--tests/bookmark/LinkUtilsTest.php367
-rw-r--r--tests/container/ContainerBuilderTest.php12
-rw-r--r--tests/feed/CachedPageTest.php57
-rw-r--r--tests/formatter/BookmarkDefaultFormatterTest.php20
-rw-r--r--tests/front/controller/admin/ConfigureControllerTest.php2
-rw-r--r--tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php47
-rw-r--r--tests/front/controller/admin/ManageTagControllerTest.php136
-rw-r--r--tests/front/controller/admin/ServerControllerTest.php184
-rw-r--r--tests/front/controller/admin/ShaareAddControllerTest.php97
-rw-r--r--tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php (renamed from tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php)8
-rw-r--r--tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php (renamed from tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php)14
-rw-r--r--tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php (renamed from tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php)8
-rw-r--r--tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php139
-rw-r--r--tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php63
-rw-r--r--tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php (renamed from tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php)136
-rw-r--r--tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php (renamed from tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php)10
-rw-r--r--tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php (renamed from tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php)63
-rw-r--r--tests/front/controller/visitor/BookmarkListControllerTest.php94
-rw-r--r--tests/front/controller/visitor/DailyControllerTest.php412
-rw-r--r--tests/front/controller/visitor/ErrorControllerTest.php29
-rw-r--r--tests/front/controller/visitor/FrontControllerMockHelper.php4
-rw-r--r--tests/front/controller/visitor/InstallControllerTest.php9
-rw-r--r--tests/front/controller/visitor/LoginControllerTest.php2
-rw-r--r--tests/front/controller/visitor/TagCloudControllerTest.php12
-rw-r--r--tests/front/controller/visitor/TagControllerTest.php6
-rw-r--r--tests/helper/ApplicationUtilsTest.php (renamed from tests/ApplicationUtilsTest.php)65
-rw-r--r--tests/helper/DailyPageHelperTest.php341
-rw-r--r--tests/helper/FileUtilsTest.php (renamed from tests/FileUtilsTest.php)91
-rw-r--r--tests/http/MetadataRetrieverTest.php154
-rw-r--r--tests/legacy/LegacyUpdaterTest.php16
-rw-r--r--tests/netscape/BookmarkImportTest.php41
-rw-r--r--tests/plugins/PluginDefaultColorsTest.php23
-rw-r--r--tests/plugins/PluginWallabagTest.php35
-rw-r--r--tests/plugins/test/test.php16
-rw-r--r--tests/plugins/test_route_invalid/test_route_invalid.php12
-rw-r--r--tests/security/BanManagerTest.php5
-rw-r--r--tests/security/LoginManagerTest.php51
-rw-r--r--tests/security/SessionManagerTest.php5
-rw-r--r--tests/updater/UpdaterTest.php16
-rw-r--r--tests/utils/FakeApplicationUtils.php2
-rw-r--r--tests/utils/FakeConfigManager.php10
-rw-r--r--tests/utils/ReferenceHistory.php2
-rw-r--r--tpl/default/addlink.html56
-rw-r--r--tpl/default/changetag.html26
-rw-r--r--tpl/default/daily.html32
-rw-r--r--tpl/default/dailyrss.html11
-rw-r--r--tpl/default/editlink.batch.html32
-rw-r--r--tpl/default/editlink.html39
-rw-r--r--tpl/default/error.html8
-rw-r--r--tpl/default/install.html10
-rw-r--r--tpl/default/linklist.html20
-rw-r--r--tpl/default/page.footer.html10
-rw-r--r--tpl/default/pluginscontent.html13
-rw-r--r--tpl/default/server.html129
-rw-r--r--tpl/default/server.requirements.html68
-rw-r--r--tpl/default/tag.cloud.html2
-rw-r--r--tpl/default/tools.html14
-rw-r--r--tpl/vintage/daily.html8
-rw-r--r--tpl/vintage/editlink.html32
-rw-r--r--tpl/vintage/includes.html4
-rw-r--r--tpl/vintage/linklist.html9
-rw-r--r--tpl/vintage/page.footer.html6
-rw-r--r--tpl/vintage/page.header.html24
-rw-r--r--webpack.config.js6
-rw-r--r--yarn.lock11
208 files changed, 8969 insertions, 1991 deletions
diff --git a/.docker/nginx.conf b/.docker/nginx.conf
index 07fba33f..30810a87 100644
--- a/.docker/nginx.conf
+++ b/.docker/nginx.conf
@@ -17,27 +17,13 @@ http {
17 index index.html index.php; 17 index index.html index.php;
18 18
19 server { 19 server {
20 listen 80; 20 listen 80;
21 root /var/www/shaarli; 21 root /var/www/shaarli;
22 22
23 access_log /var/log/nginx/shaarli.access.log; 23 access_log /var/log/nginx/shaarli.access.log;
24 error_log /var/log/nginx/shaarli.error.log; 24 error_log /var/log/nginx/shaarli.error.log;
25 25
26 location ~ /\. { 26 location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
27 # deny access to dotfiles
28 access_log off;
29 log_not_found off;
30 deny all;
31 }
32
33 location ~ ~$ {
34 # deny access to temp editor files, e.g. "script.php~"
35 access_log off;
36 log_not_found off;
37 deny all;
38 }
39
40 location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
41 # cache static assets 27 # cache static assets
42 expires max; 28 expires max;
43 add_header Pragma public; 29 add_header Pragma public;
@@ -49,25 +35,25 @@ http {
49 alias /var/www/shaarli/images/favicon.ico; 35 alias /var/www/shaarli/images/favicon.ico;
50 } 36 }
51 37
38 location /doc/html/ {
39 default_type "text/html";
40 try_files $uri $uri/ $uri.html =404;
41 }
42
52 location / { 43 location / {
53 # Slim - rewrite URLs 44 # Slim - rewrite URLs & do NOT serve static files through this location
54 try_files $uri /index.php$is_args$args; 45 try_files _ /index.php$is_args$args;
55 } 46 }
56 47
57 location ~ (index)\.php$ { 48 location ~ index\.php$ {
58 # Slim - split URL path into (script_filename, path_info) 49 # Slim - split URL path into (script_filename, path_info)
59 try_files $uri =404; 50 try_files $uri =404;
60 fastcgi_split_path_info ^(.+\.php)(/.+)$; 51 fastcgi_split_path_info ^(index.php)(/.+)$;
61 52
62 # filter and proxy PHP requests to PHP-FPM 53 # filter and proxy PHP requests to PHP-FPM
63 fastcgi_pass unix:/var/run/php-fpm.sock; 54 fastcgi_pass unix:/var/run/php-fpm.sock;
64 fastcgi_index index.php; 55 fastcgi_index index.php;
65 include fastcgi.conf; 56 include fastcgi.conf;
66 } 57 }
67
68 location ~ \.php$ {
69 # deny access to all other PHP scripts
70 deny all;
71 }
72 } 58 }
73} 59}
diff --git a/.dockerignore b/.dockerignore
index 96fd31c5..19fd87a5 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -2,8 +2,16 @@
2.dev 2.dev
3.git 3.git
4.github 4.github
5.gitattributes
6.gitignore
7.travis.yml
5tests 8tests
6 9
10# Docker related resources are not needed inside the container
11.dockerignore
12Dockerfile
13Dockerfile.armhf
14
7# Docker Compose resources 15# Docker Compose resources
8docker-compose.yml 16docker-compose.yml
9 17
@@ -13,6 +21,9 @@ data/*
13pagecache/* 21pagecache/*
14tmp/* 22tmp/*
15 23
24# Shaarli's docs are created during the build
25doc/html/
26
16# Eclipse project files 27# Eclipse project files
17.settings 28.settings
18.buildpath 29.buildpath
diff --git a/.htaccess b/.htaccess
index 25fcfb03..9d1522df 100644
--- a/.htaccess
+++ b/.htaccess
@@ -13,7 +13,7 @@ RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
13# Alternative (if the 2 lines above don't work) 13# Alternative (if the 2 lines above don't work)
14# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0 14# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
15 15
16# REST API 16# Slim URL Redirection
17# Ionos Hosting needs RewriteBase / 17# Ionos Hosting needs RewriteBase /
18# RewriteBase / 18# RewriteBase /
19RewriteCond %{REQUEST_FILENAME} !-f 19RewriteCond %{REQUEST_FILENAME} !-f
diff --git a/.travis.yml b/.travis.yml
index d7460947..422bf835 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -49,6 +49,10 @@ cache:
49 directories: 49 directories:
50 - $HOME/.composer/cache 50 - $HOME/.composer/cache
51 51
52before_install:
53 # Disable xdebug: it significantly speed up tests and linter, and we don't use coverage yet
54 - phpenv config-rm xdebug.ini || echo 'No xdebug config.'
55
52install: 56install:
53 # install/update composer and php dependencies 57 # install/update composer and php dependencies
54 - composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION 58 - composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION
@@ -60,4 +64,5 @@ before_script:
60script: 64script:
61 - make clean 65 - make clean
62 - make check_permissions 66 - make check_permissions
67 - make code_sniffer
63 - make all_tests 68 - make all_tests
diff --git a/AUTHORS b/AUTHORS
index 0ec52acc..be815364 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,4 +1,4 @@
1 991 ArthurHoaro <arthur@hoa.ro> 1 1097 ArthurHoaro <arthur@hoa.ro>
2 402 VirtualTam <virtualtam@flibidi.net> 2 402 VirtualTam <virtualtam@flibidi.net>
3 294 nodiscc <nodiscc@gmail.com> 3 294 nodiscc <nodiscc@gmail.com>
4 56 Sébastien Sauvage <sebsauvage@sebsauvage.net> 4 56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
@@ -25,6 +25,7 @@
25 2 Alexandre G.-Raymond <alex@ndre.gr> 25 2 Alexandre G.-Raymond <alex@ndre.gr>
26 2 Chris Kuethe <chris.kuethe@gmail.com> 26 2 Chris Kuethe <chris.kuethe@gmail.com>
27 2 Felix Bartels <felix@host-consultants.de> 27 2 Felix Bartels <felix@host-consultants.de>
28 2 Ganesh Kandu <kanduganesh@gmail.com>
28 2 Guillaume Virlet <github@virlet.org> 29 2 Guillaume Virlet <github@virlet.org>
29 2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org> 30 2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
30 2 Mathieu Chabanon <git@matchab.fr> 31 2 Mathieu Chabanon <git@matchab.fr>
@@ -39,6 +40,7 @@
39 2 pips <pips@e5150.fr> 40 2 pips <pips@e5150.fr>
40 2 trailjeep <trailjeep@gmail.com> 41 2 trailjeep <trailjeep@gmail.com>
41 2 yude <yudesleepy@gmail.com> 42 2 yude <yudesleepy@gmail.com>
43 2 yudete <yu@yude.moe>
42 1 Adrien Oliva <adrien.oliva@yapbreak.fr> 44 1 Adrien Oliva <adrien.oliva@yapbreak.fr>
43 1 Adrien le Maire <adrien@alemaire.be> 45 1 Adrien le Maire <adrien@alemaire.be>
44 1 Alexis J <alexis@effingo.be> 46 1 Alexis J <alexis@effingo.be>
@@ -65,6 +67,7 @@
65 1 Kevin Masson <kevin.masson@methodinthemadness.eu> 67 1 Kevin Masson <kevin.masson@methodinthemadness.eu>
66 1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org> 68 1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
67 1 Lionel Martin <renarddesmers@gmail.com> 69 1 Lionel Martin <renarddesmers@gmail.com>
70 1 Loïc Carr <zizou.xena@gmail.com>
68 1 Mark Gerarts <mark.gerarts@gmail.com> 71 1 Mark Gerarts <mark.gerarts@gmail.com>
69 1 Marsup <marsup@gmail.com> 72 1 Marsup <marsup@gmail.com>
70 1 Paul van den Burg <github@paulvandenburg.nl> 73 1 Paul van den Burg <github@paulvandenburg.nl>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1686d67..18404049 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,55 @@ All notable changes to this project will be documented in this file.
4The format is based on [Keep a Changelog](http://keepachangelog.com/) 4The format is based on [Keep a Changelog](http://keepachangelog.com/)
5and this project adheres to [Semantic Versioning](http://semver.org/). 5and this project adheres to [Semantic Versioning](http://semver.org/).
6 6
7## [v0.12.1]() - UNRELEASED 7## [v0.12.2]() - UNRELEASED
8
9## [v0.12.1](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-11-12
10
11> 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
12> update yours using [the documentation](https://shaarli.readthedocs.io/en/master/Server-configuration/).
13> Users using official Docker image will receive updated configuration automatically.
14
15### Added
16- Bulk creation of bookmarks
17- Server administration tool page (and install page requirements)
18- Support any tag separator, not just whitespaces
19- Share a private bookmark using a URL with a token
20- Add a setting to retrieve bookmark metadata asynchronously (enabled by default)
21- Highlight fulltext search results
22- Weekly and monthly view/RSS feed for daily page
23- MarkdownExtra formatter
24- Default formatter: add a setting to disable auto-linkification
25- Add mutex on datastore I/O operations to prevent data loss
26- PHP 8.0 support
27- REST API: allow override of creation and update dates
28- Add strict types for bookmarks management
29
30### Changed
31- Improve regex and performances to extract HTML metadata (title, description, etc.)
32- Support using Shaarli without URL rewriting (prefix URL with `/index.php/`)
33- Improve the "Manage tags" tools page
34- Use PSR-3 logger for login attempts
35- Move utils classes to Shaarli\Helper namespace and folder
36- Include php-simplexml in Docker image
37- Raise 404 error instead of 500 if permalink access is denied
38- Display error details even with dev.debug set to false
39- Reviewed nginx configuration
40- Reviewed Apache configuration
41- Replace vimeo link in demo bookmarks due to IP ban on the demo instance
42- Apply PSR-12 on code base, and add CI check using PHPCS
43
44### Fixed
45- Compatiliby issue on login with PHP 7.1
46- Japanese translations update
47- Redirect to referrer after bookmark deletion
48- Inject ROOT_PATH in plugin instead of regenerating it everywhere
49- Wallabag plugin: minor improvements
50- REST API postLink: change relative path to absolute path
51- Webpack: fix vintage theme images include
52- Docker-compose: fix SSL certificate + add parameter for Docker tag
53
54### Removed
55- `config.json.php` new lines in prefix/suffix to prevent issues with Windows PHP
8 56
9## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13 57## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13
10 58
diff --git a/Dockerfile b/Dockerfile
index e2ff71fd..79d33130 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,7 +26,7 @@ RUN cd shaarli \
26 26
27# Stage 4: 27# Stage 4:
28# - Shaarli image 28# - Shaarli image
29FROM alpine:3.8 29FROM alpine:3.12
30LABEL maintainer="Shaarli Community" 30LABEL maintainer="Shaarli Community"
31 31
32RUN apk --update --no-cache add \ 32RUN apk --update --no-cache add \
@@ -44,6 +44,7 @@ RUN apk --update --no-cache add \
44 php7-openssl \ 44 php7-openssl \
45 php7-session \ 45 php7-session \
46 php7-xml \ 46 php7-xml \
47 php7-simplexml \
47 php7-zlib \ 48 php7-zlib \
48 s6 49 s6
49 50
diff --git a/Dockerfile.armhf b/Dockerfile.armhf
index 5bbf6680..471f2397 100644
--- a/Dockerfile.armhf
+++ b/Dockerfile.armhf
@@ -1,7 +1,7 @@
1# Stage 1: 1# Stage 1:
2# - Copy Shaarli sources 2# - Copy Shaarli sources
3# - Build documentation 3# - Build documentation
4FROM arm32v6/alpine:3.8 as docs 4FROM arm32v6/alpine:3.10 as docs
5ADD . /usr/src/app/shaarli 5ADD . /usr/src/app/shaarli
6RUN apk --update --no-cache add py2-pip \ 6RUN apk --update --no-cache add py2-pip \
7 && cd /usr/src/app/shaarli \ 7 && cd /usr/src/app/shaarli \
@@ -10,7 +10,7 @@ RUN apk --update --no-cache add py2-pip \
10 10
11# Stage 2: 11# Stage 2:
12# - Resolve PHP dependencies with Composer 12# - Resolve PHP dependencies with Composer
13FROM arm32v6/alpine:3.8 as composer 13FROM arm32v6/alpine:3.10 as composer
14COPY --from=docs /usr/src/app/shaarli /app/shaarli 14COPY --from=docs /usr/src/app/shaarli /app/shaarli
15RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer \ 15RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer \
16 && cd /app/shaarli \ 16 && cd /app/shaarli \
@@ -18,7 +18,7 @@ RUN apk --update --no-cache add php7-curl php7-mbstring php7-simplexml composer
18 18
19# Stage 3: 19# Stage 3:
20# - Frontend dependencies 20# - Frontend dependencies
21FROM arm32v6/alpine:3.8 as node 21FROM arm32v6/alpine:3.10 as node
22COPY --from=composer /app/shaarli /shaarli 22COPY --from=composer /app/shaarli /shaarli
23RUN apk --update --no-cache add yarn nodejs-current python2 build-base \ 23RUN apk --update --no-cache add yarn nodejs-current python2 build-base \
24 && cd /shaarli \ 24 && cd /shaarli \
@@ -28,7 +28,7 @@ RUN apk --update --no-cache add yarn nodejs-current python2 build-base \
28 28
29# Stage 4: 29# Stage 4:
30# - Shaarli image 30# - Shaarli image
31FROM arm32v6/alpine:3.8 31FROM arm32v6/alpine:3.10
32LABEL maintainer="Shaarli Community" 32LABEL maintainer="Shaarli Community"
33 33
34RUN apk --update --no-cache add \ 34RUN apk --update --no-cache add \
diff --git a/Makefile b/Makefile
index 0ff6bd3f..181b61c4 100644
--- a/Makefile
+++ b/Makefile
@@ -27,10 +27,6 @@ PHPCS := $(BIN)/phpcs
27code_sniffer: 27code_sniffer:
28 @$(PHPCS) 28 @$(PHPCS)
29 29
30### - errors filtered by coding standard: PEAR, PSR1, PSR2, Zend...
31PHPCS_%:
32 @$(PHPCS) --report-full --report-width=200 --standard=$*
33
34### - errors by Git author 30### - errors by Git author
35code_sniffer_blame: 31code_sniffer_blame:
36 @$(PHPCS) --report-gitblame 32 @$(PHPCS) --report-gitblame
@@ -175,6 +171,7 @@ translate:
175eslint: 171eslint:
176 @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/ 172 @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/
177 @yarn run eslint -c .dev/.eslintrc.js assets/default/js/ 173 @yarn run eslint -c .dev/.eslintrc.js assets/default/js/
174 @yarn run eslint -c .dev/.eslintrc.js assets/common/js/
178 175
179### Run CSSLint check against Shaarli's SCSS files 176### Run CSSLint check against Shaarli's SCSS files
180sasslint: 177sasslint:
diff --git a/README.md b/README.md
index 46dda8d5..71198032 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ _It is designed to be personal (single-user), fast and handy._
9[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) 9[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
10[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli) 10[![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
11&bull; 11&bull;
12[![](https://img.shields.io/badge/latest-v0.12.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) 12[![](https://img.shields.io/badge/latest-v0.12.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.1)
13[![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli) 13[![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli)
14&bull; 14&bull;
15[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli) 15[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)
diff --git a/application/History.php b/application/History.php
index 4fd2f294..d230f39d 100644
--- a/application/History.php
+++ b/application/History.php
@@ -1,9 +1,11 @@
1<?php 1<?php
2
2namespace Shaarli; 3namespace Shaarli;
3 4
4use DateTime; 5use DateTime;
5use Exception; 6use Exception;
6use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Helper\FileUtils;
7 9
8/** 10/**
9 * Class History 11 * Class History
@@ -30,27 +32,27 @@ class History
30 /** 32 /**
31 * @var string Action key: a new link has been created. 33 * @var string Action key: a new link has been created.
32 */ 34 */
33 const CREATED = 'CREATED'; 35 public const CREATED = 'CREATED';
34 36
35 /** 37 /**
36 * @var string Action key: a link has been updated. 38 * @var string Action key: a link has been updated.
37 */ 39 */
38 const UPDATED = 'UPDATED'; 40 public const UPDATED = 'UPDATED';
39 41
40 /** 42 /**
41 * @var string Action key: a link has been deleted. 43 * @var string Action key: a link has been deleted.
42 */ 44 */
43 const DELETED = 'DELETED'; 45 public const DELETED = 'DELETED';
44 46
45 /** 47 /**
46 * @var string Action key: settings have been updated. 48 * @var string Action key: settings have been updated.
47 */ 49 */
48 const SETTINGS = 'SETTINGS'; 50 public const SETTINGS = 'SETTINGS';
49 51
50 /** 52 /**
51 * @var string Action key: a bulk import has been processed. 53 * @var string Action key: a bulk import has been processed.
52 */ 54 */
53 const IMPORT = 'IMPORT'; 55 public const IMPORT = 'IMPORT';
54 56
55 /** 57 /**
56 * @var string History file path. 58 * @var string History file path.
diff --git a/application/Languages.php b/application/Languages.php
index d83e0765..7177db2c 100644
--- a/application/Languages.php
+++ b/application/Languages.php
@@ -41,7 +41,7 @@ class Languages
41 /** 41 /**
42 * Core translations domain 42 * Core translations domain
43 */ 43 */
44 const DEFAULT_DOMAIN = 'shaarli'; 44 public const DEFAULT_DOMAIN = 'shaarli';
45 45
46 /** 46 /**
47 * @var TranslatorInterface 47 * @var TranslatorInterface
@@ -76,7 +76,8 @@ class Languages
76 $this->language = $confLanguage; 76 $this->language = $confLanguage;
77 } 77 }
78 78
79 if (! extension_loaded('gettext') 79 if (
80 ! extension_loaded('gettext')
80 || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php']) 81 || in_array($this->conf->get('translation.mode', 'auto'), ['auto', 'php'])
81 ) { 82 ) {
82 $this->initPhpTranslator(); 83 $this->initPhpTranslator();
@@ -98,7 +99,7 @@ class Languages
98 $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages'); 99 $this->translator->loadDomain(self::DEFAULT_DOMAIN, 'inc/languages');
99 100
100 // Default extension translation from the current theme 101 // Default extension translation from the current theme
101 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $this->conf->get('theme') .'/language'; 102 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $this->conf->get('theme') . '/language';
102 if (is_dir($themeTransFolder)) { 103 if (is_dir($themeTransFolder)) {
103 $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false); 104 $this->translator->loadDomain($this->conf->get('theme'), $themeTransFolder, false);
104 } 105 }
@@ -121,7 +122,9 @@ class Languages
121 $translations = new Translations(); 122 $translations = new Translations();
122 // Core translations 123 // Core translations
123 try { 124 try {
124 $translations = $translations->addFromPoFile('inc/languages/'. $this->language .'/LC_MESSAGES/shaarli.po'); 125 $translations = $translations->addFromPoFile(
126 'inc/languages/' . $this->language . '/LC_MESSAGES/shaarli.po'
127 );
125 $translations->setDomain('shaarli'); 128 $translations->setDomain('shaarli');
126 $this->translator->loadTranslations($translations); 129 $this->translator->loadTranslations($translations);
127 } catch (\InvalidArgumentException $e) { 130 } catch (\InvalidArgumentException $e) {
@@ -129,11 +132,11 @@ class Languages
129 132
130 // Default extension translation from the current theme 133 // Default extension translation from the current theme
131 $theme = $this->conf->get('theme'); 134 $theme = $this->conf->get('theme');
132 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') .'/'. $theme .'/language'; 135 $themeTransFolder = rtrim($this->conf->get('raintpl_tpl'), '/') . '/' . $theme . '/language';
133 if (is_dir($themeTransFolder)) { 136 if (is_dir($themeTransFolder)) {
134 try { 137 try {
135 $translations = Translations::fromPoFile( 138 $translations = Translations::fromPoFile(
136 $themeTransFolder .'/'. $this->language .'/LC_MESSAGES/'. $theme .'.po' 139 $themeTransFolder . '/' . $this->language . '/LC_MESSAGES/' . $theme . '.po'
137 ); 140 );
138 $translations->setDomain($theme); 141 $translations->setDomain($theme);
139 $this->translator->loadTranslations($translations); 142 $this->translator->loadTranslations($translations);
@@ -149,7 +152,7 @@ class Languages
149 152
150 try { 153 try {
151 $extension = Translations::fromPoFile( 154 $extension = Translations::fromPoFile(
152 $translationPath . $this->language .'/LC_MESSAGES/'. $domain .'.po' 155 $translationPath . $this->language . '/LC_MESSAGES/' . $domain . '.po'
153 ); 156 );
154 $extension->setDomain($domain); 157 $extension->setDomain($domain);
155 $this->translator->loadTranslations($extension); 158 $this->translator->loadTranslations($extension);
@@ -183,6 +186,7 @@ class Languages
183 'en' => t('English'), 186 'en' => t('English'),
184 'fr' => t('French'), 187 'fr' => t('French'),
185 'jp' => t('Japanese'), 188 'jp' => t('Japanese'),
189 'ru' => t('Russian'),
186 ]; 190 ];
187 } 191 }
188} 192}
diff --git a/application/Thumbnailer.php b/application/Thumbnailer.php
index 5aec23c8..c4ff8d7a 100644
--- a/application/Thumbnailer.php
+++ b/application/Thumbnailer.php
@@ -13,7 +13,7 @@ use WebThumbnailer\WebThumbnailer;
13 */ 13 */
14class Thumbnailer 14class Thumbnailer
15{ 15{
16 const COMMON_MEDIA_DOMAINS = [ 16 protected const COMMON_MEDIA_DOMAINS = [
17 'imgur.com', 17 'imgur.com',
18 'flickr.com', 18 'flickr.com',
19 'youtube.com', 19 'youtube.com',
@@ -31,9 +31,9 @@ class Thumbnailer
31 'deviantart.com', 31 'deviantart.com',
32 ]; 32 ];
33 33
34 const MODE_ALL = 'all'; 34 public const MODE_ALL = 'all';
35 const MODE_COMMON = 'common'; 35 public const MODE_COMMON = 'common';
36 const MODE_NONE = 'none'; 36 public const MODE_NONE = 'none';
37 37
38 /** 38 /**
39 * @var WebThumbnailer instance. 39 * @var WebThumbnailer instance.
@@ -60,7 +60,7 @@ class Thumbnailer
60 // TODO: create a proper error handling system able to catch exceptions... 60 // TODO: create a proper error handling system able to catch exceptions...
61 die(t( 61 die(t(
62 'php-gd extension must be loaded to use thumbnails. ' 62 'php-gd extension must be loaded to use thumbnails. '
63 .'Thumbnails are now disabled. Please reload the page.' 63 . 'Thumbnails are now disabled. Please reload the page.'
64 )); 64 ));
65 } 65 }
66 66
@@ -81,7 +81,8 @@ class Thumbnailer
81 */ 81 */
82 public function get($url) 82 public function get($url)
83 { 83 {
84 if ($this->conf->get('thumbnails.mode') === self::MODE_COMMON 84 if (
85 $this->conf->get('thumbnails.mode') === self::MODE_COMMON
85 && ! $this->isCommonMediaOrImage($url) 86 && ! $this->isCommonMediaOrImage($url)
86 ) { 87 ) {
87 return false; 88 return false;
diff --git a/application/TimeZone.php b/application/TimeZone.php
index c1869ef8..a420eb96 100644
--- a/application/TimeZone.php
+++ b/application/TimeZone.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Generates a list of available timezone continents and cities. 4 * Generates a list of available timezone continents and cities.
4 * 5 *
@@ -43,7 +44,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
43 // Try to split the provided timezone 44 // Try to split the provided timezone
44 $spos = strpos($preselectedTimezone, '/'); 45 $spos = strpos($preselectedTimezone, '/');
45 $pcontinent = substr($preselectedTimezone, 0, $spos); 46 $pcontinent = substr($preselectedTimezone, 0, $spos);
46 $pcity = substr($preselectedTimezone, $spos+1); 47 $pcity = substr($preselectedTimezone, $spos + 1);
47 } 48 }
48 49
49 $continents = []; 50 $continents = [];
@@ -60,7 +61,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
60 } 61 }
61 62
62 $continent = substr($tz, 0, $spos); 63 $continent = substr($tz, 0, $spos);
63 $city = substr($tz, $spos+1); 64 $city = substr($tz, $spos + 1);
64 $cities[] = ['continent' => $continent, 'city' => $city]; 65 $cities[] = ['continent' => $continent, 'city' => $city];
65 $continents[$continent] = true; 66 $continents[$continent] = true;
66 } 67 }
@@ -85,7 +86,7 @@ function generateTimeZoneData($installedTimeZones, $preselectedTimezone = '')
85function isTimeZoneValid($continent, $city) 86function isTimeZoneValid($continent, $city)
86{ 87{
87 return in_array( 88 return in_array(
88 $continent.'/'.$city, 89 $continent . '/' . $city,
89 timezone_identifiers_list() 90 timezone_identifiers_list()
90 ); 91 );
91} 92}
diff --git a/application/Utils.php b/application/Utils.php
index bcfda65c..952378ab 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -1,24 +1,27 @@
1<?php 1<?php
2
2/** 3/**
3 * Shaarli utilities 4 * Shaarli utilities
4 */ 5 */
5 6
6/** 7/**
7 * Logs a message to a text file 8 * Format log using provided data.
8 * 9 *
9 * The log format is compatible with fail2ban. 10 * @param string $message the message to log
11 * @param string|null $clientIp the client's remote IPv4/IPv6 address
10 * 12 *
11 * @param string $logFile where to write the logs 13 * @return string Formatted message to log
12 * @param string $clientIp the client's remote IPv4/IPv6 address
13 * @param string $message the message to log
14 */ 14 */
15function logm($logFile, $clientIp, $message) 15function format_log(string $message, string $clientIp = null): string
16{ 16{
17 file_put_contents( 17 $out = $message;
18 $logFile, 18
19 date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL, 19 if (!empty($clientIp)) {
20 FILE_APPEND 20 // Note: we keep the first dash to avoid breaking fail2ban configs
21 ); 21 $out = '- ' . $clientIp . ' - ' . $out;
22 }
23
24 return $out;
22} 25}
23 26
24/** 27/**
@@ -100,7 +103,7 @@ function escape($input)
100 } 103 }
101 104
102 if (is_array($input)) { 105 if (is_array($input)) {
103 $out = array(); 106 $out = [];
104 foreach ($input as $key => $value) { 107 foreach ($input as $key => $value) {
105 $out[escape($key)] = escape($value); 108 $out[escape($key)] = escape($value);
106 } 109 }
@@ -161,7 +164,7 @@ function checkDateFormat($format, $string)
161 * 164 *
162 * @return string $referer - final referer. 165 * @return string $referer - final referer.
163 */ 166 */
164function generateLocation($referer, $host, $loopTerms = array()) 167function generateLocation($referer, $host, $loopTerms = [])
165{ 168{
166 $finalReferer = './?'; 169 $finalReferer = './?';
167 170
@@ -194,7 +197,7 @@ function generateLocation($referer, $host, $loopTerms = array())
194function autoLocale($headerLocale) 197function autoLocale($headerLocale)
195{ 198{
196 // Default if browser does not send HTTP_ACCEPT_LANGUAGE 199 // Default if browser does not send HTTP_ACCEPT_LANGUAGE
197 $locales = array('en_US', 'en_US.utf8', 'en_US.UTF-8'); 200 $locales = ['en_US', 'en_US.utf8', 'en_US.UTF-8'];
198 if (! empty($headerLocale)) { 201 if (! empty($headerLocale)) {
199 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) { 202 if (preg_match_all('/([a-z]{2,3})[-_]?([a-z]{2})?,?/i', $headerLocale, $matches, PREG_SET_ORDER)) {
200 $attempts = []; 203 $attempts = [];
@@ -325,6 +328,23 @@ function format_date($date, $time = true, $intl = true)
325} 328}
326 329
327/** 330/**
331 * Format the date month according to the locale.
332 *
333 * @param DateTimeInterface $date to format.
334 *
335 * @return bool|string Formatted date, or false if the input is invalid.
336 */
337function format_month(DateTimeInterface $date)
338{
339 if (! $date instanceof DateTimeInterface) {
340 return false;
341 }
342
343 return strftime('%B', $date->getTimestamp());
344}
345
346
347/**
328 * Check if the input is an integer, no matter its real type. 348 * Check if the input is an integer, no matter its real type.
329 * 349 *
330 * PHP is a bit messy regarding this: 350 * PHP is a bit messy regarding this:
@@ -357,13 +377,15 @@ function return_bytes($val)
357 return $val; 377 return $val;
358 } 378 }
359 $val = trim($val); 379 $val = trim($val);
360 $last = strtolower($val[strlen($val)-1]); 380 $last = strtolower($val[strlen($val) - 1]);
361 $val = intval(substr($val, 0, -1)); 381 $val = intval(substr($val, 0, -1));
362 switch ($last) { 382 switch ($last) {
363 case 'g': 383 case 'g':
364 $val *= 1024; 384 $val *= 1024;
385 // do no break in order 1024^2 for each unit
365 case 'm': 386 case 'm':
366 $val *= 1024; 387 $val *= 1024;
388 // do no break in order 1024^2 for each unit
367 case 'k': 389 case 'k':
368 $val *= 1024; 390 $val *= 1024;
369 } 391 }
@@ -452,14 +474,28 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
452 * Wrapper function for translation which match the API 474 * Wrapper function for translation which match the API
453 * of gettext()/_() and ngettext(). 475 * of gettext()/_() and ngettext().
454 * 476 *
455 * @param string $text Text to translate. 477 * @param string $text Text to translate.
456 * @param string $nText The plural message ID. 478 * @param string $nText The plural message ID.
457 * @param int $nb The number of items for plural forms. 479 * @param int $nb The number of items for plural forms.
458 * @param string $domain The domain where the translation is stored (default: shaarli). 480 * @param string $domain The domain where the translation is stored (default: shaarli).
481 * @param array $variables Associative array of variables to replace in translated text.
482 * @param bool $fixCase Apply `ucfirst` on the translated string, might be useful for strings with variables.
459 * 483 *
460 * @return string Text translated. 484 * @return string Text translated.
461 */ 485 */
462function t($text, $nText = '', $nb = 1, $domain = 'shaarli') 486function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
487{
488 $postFunction = $fixCase ? 'ucfirst' : function ($input) {
489 return $input;
490 };
491
492 return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
493}
494
495/**
496 * Converts an exception into a printable stack trace string.
497 */
498function exception2text(Throwable $e): string
463{ 499{
464 return dn__($domain, $text, $nText, $nb); 500 return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
465} 501}
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index adc8b266..9fb88358 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Api; 3namespace Shaarli\Api;
3 4
4use malkusch\lock\mutex\FlockMutex; 5use malkusch\lock\mutex\FlockMutex;
@@ -108,7 +109,8 @@ class ApiMiddleware
108 */ 109 */
109 protected function checkToken($request) 110 protected function checkToken($request)
110 { 111 {
111 if (!$request->hasHeader('Authorization') 112 if (
113 !$request->hasHeader('Authorization')
112 && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION']) 114 && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
113 ) { 115 ) {
114 throw new ApiAuthorizationException('JWT token not provided'); 116 throw new ApiAuthorizationException('JWT token not provided');
diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php
index eb1ca9bc..9228bb2d 100644
--- a/application/api/ApiUtils.php
+++ b/application/api/ApiUtils.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Api; 3namespace Shaarli\Api;
3 4
4use Shaarli\Api\Exceptions\ApiAuthorizationException; 5use Shaarli\Api\Exceptions\ApiAuthorizationException;
@@ -27,7 +28,7 @@ class ApiUtils
27 throw new ApiAuthorizationException('Malformed JWT token'); 28 throw new ApiAuthorizationException('Malformed JWT token');
28 } 29 }
29 30
30 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] .'.'. $parts[1], $secret, true)); 31 $genSign = Base64Url::encode(hash_hmac('sha512', $parts[0] . '.' . $parts[1], $secret, true));
31 if ($parts[2] != $genSign) { 32 if ($parts[2] != $genSign) {
32 throw new ApiAuthorizationException('Invalid JWT signature'); 33 throw new ApiAuthorizationException('Invalid JWT signature');
33 } 34 }
@@ -42,7 +43,8 @@ class ApiUtils
42 throw new ApiAuthorizationException('Invalid JWT payload'); 43 throw new ApiAuthorizationException('Invalid JWT payload');
43 } 44 }
44 45
45 if (empty($payload->iat) 46 if (
47 empty($payload->iat)
46 || $payload->iat > time() 48 || $payload->iat > time()
47 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION 49 || time() - $payload->iat > ApiMiddleware::$TOKEN_DURATION
48 ) { 50 ) {
@@ -89,13 +91,17 @@ class ApiUtils
89 * If no URL is provided, it will generate a local note URL. 91 * If no URL is provided, it will generate a local note URL.
90 * If no title is provided, it will use the URL as title. 92 * If no title is provided, it will use the URL as title.
91 * 93 *
92 * @param array|null $input Request Link. 94 * @param array|null $input Request Link.
93 * @param bool $defaultPrivate Setting defined if a bookmark is private by default. 95 * @param bool $defaultPrivate Setting defined if a bookmark is private by default.
96 * @param string $tagsSeparator Tags separator loaded from the config file.
94 * 97 *
95 * @return Bookmark instance. 98 * @return Bookmark instance.
96 */ 99 */
97 public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark 100 public static function buildBookmarkFromRequest(
98 { 101 ?array $input,
102 bool $defaultPrivate,
103 string $tagsSeparator
104 ): Bookmark {
99 $bookmark = new Bookmark(); 105 $bookmark = new Bookmark();
100 $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; 106 $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
101 if (isset($input['private'])) { 107 if (isset($input['private'])) {
@@ -107,6 +113,15 @@ class ApiUtils
107 $bookmark->setTitle(! empty($input['title']) ? $input['title'] : ''); 113 $bookmark->setTitle(! empty($input['title']) ? $input['title'] : '');
108 $bookmark->setUrl($url); 114 $bookmark->setUrl($url);
109 $bookmark->setDescription(! empty($input['description']) ? $input['description'] : ''); 115 $bookmark->setDescription(! empty($input['description']) ? $input['description'] : '');
116
117 // Be permissive with provided tags format
118 if (is_string($input['tags'] ?? null)) {
119 $input['tags'] = tags_str2array($input['tags'], $tagsSeparator);
120 }
121 if (is_array($input['tags'] ?? null) && count($input['tags']) === 1 && is_string($input['tags'][0])) {
122 $input['tags'] = tags_str2array($input['tags'][0], $tagsSeparator);
123 }
124
110 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); 125 $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
111 $bookmark->setPrivate($private); 126 $bookmark->setPrivate($private);
112 127
diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php
index 505647a9..d83a3a25 100644
--- a/application/api/controllers/HistoryController.php
+++ b/application/api/controllers/HistoryController.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Api\Controllers; 3namespace Shaarli\Api\Controllers;
5 4
6use Shaarli\Api\Exceptions\ApiBadParametersException; 5use Shaarli\Api\Exceptions\ApiBadParametersException;
diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php
index 12f6b2f0..ae7db93e 100644
--- a/application/api/controllers/Info.php
+++ b/application/api/controllers/Info.php
@@ -29,13 +29,13 @@ class Info extends ApiController
29 $info = [ 29 $info = [
30 'global_counter' => $this->bookmarkService->count(), 30 'global_counter' => $this->bookmarkService->count(),
31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE), 31 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE),
32 'settings' => array( 32 'settings' => [
33 'title' => $this->conf->get('general.title', 'Shaarli'), 33 'title' => $this->conf->get('general.title', 'Shaarli'),
34 'header_link' => $this->conf->get('general.header_link', '?'), 34 'header_link' => $this->conf->get('general.header_link', '?'),
35 'timezone' => $this->conf->get('general.timezone', 'UTC'), 35 'timezone' => $this->conf->get('general.timezone', 'UTC'),
36 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []), 36 'enabled_plugins' => $this->conf->get('general.enabled_plugins', []),
37 'default_private_links' => $this->conf->get('privacy.default_private_links', false), 37 'default_private_links' => $this->conf->get('privacy.default_private_links', false),
38 ), 38 ],
39 ]; 39 ];
40 40
41 return $response->withJson($info, 200, $this->jsonStyle); 41 return $response->withJson($info, 200, $this->jsonStyle);
diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php
index 73a1b84e..b83b2260 100644
--- a/application/api/controllers/Links.php
+++ b/application/api/controllers/Links.php
@@ -117,9 +117,14 @@ class Links extends ApiController
117 public function postLink($request, $response) 117 public function postLink($request, $response)
118 { 118 {
119 $data = (array) ($request->getParsedBody() ?? []); 119 $data = (array) ($request->getParsedBody() ?? []);
120 $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); 120 $bookmark = ApiUtils::buildBookmarkFromRequest(
121 $data,
122 $this->conf->get('privacy.default_private_links'),
123 $this->conf->get('general.tags_separator', ' ')
124 );
121 // duplicate by URL, return 409 Conflict 125 // duplicate by URL, return 409 Conflict
122 if (! empty($bookmark->getUrl()) 126 if (
127 ! empty($bookmark->getUrl())
123 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) 128 && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
124 ) { 129 ) {
125 return $response->withJson( 130 return $response->withJson(
@@ -131,7 +136,7 @@ class Links extends ApiController
131 136
132 $this->bookmarkService->add($bookmark); 137 $this->bookmarkService->add($bookmark);
133 $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); 138 $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
134 $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]); 139 $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
135 return $response->withAddedHeader('Location', $redirect) 140 return $response->withAddedHeader('Location', $redirect)
136 ->withJson($out, 201, $this->jsonStyle); 141 ->withJson($out, 201, $this->jsonStyle);
137 } 142 }
@@ -157,9 +162,14 @@ class Links extends ApiController
157 $index = index_url($this->ci['environment']); 162 $index = index_url($this->ci['environment']);
158 $data = $request->getParsedBody(); 163 $data = $request->getParsedBody();
159 164
160 $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links')); 165 $requestBookmark = ApiUtils::buildBookmarkFromRequest(
166 $data,
167 $this->conf->get('privacy.default_private_links'),
168 $this->conf->get('general.tags_separator', ' ')
169 );
161 // duplicate URL on a different link, return 409 Conflict 170 // duplicate URL on a different link, return 409 Conflict
162 if (! empty($requestBookmark->getUrl()) 171 if (
172 ! empty($requestBookmark->getUrl())
163 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) 173 && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
164 && $dup->getId() != $id 174 && $dup->getId() != $id
165 ) { 175 ) {
diff --git a/application/api/exceptions/ApiAuthorizationException.php b/application/api/exceptions/ApiAuthorizationException.php
index 0e3f4776..c77e9eea 100644
--- a/application/api/exceptions/ApiAuthorizationException.php
+++ b/application/api/exceptions/ApiAuthorizationException.php
@@ -28,7 +28,7 @@ class ApiAuthorizationException extends ApiException
28 */ 28 */
29 public function setMessage($message) 29 public function setMessage($message)
30 { 30 {
31 $original = $this->debug === true ? ': '. $this->getMessage() : ''; 31 $original = $this->debug === true ? ': ' . $this->getMessage() : '';
32 $this->message = $message . $original; 32 $this->message = $message . $original;
33 } 33 }
34} 34}
diff --git a/application/api/exceptions/ApiException.php b/application/api/exceptions/ApiException.php
index d6b66323..7deafb96 100644
--- a/application/api/exceptions/ApiException.php
+++ b/application/api/exceptions/ApiException.php
@@ -44,7 +44,7 @@ abstract class ApiException extends \Exception
44 } 44 }
45 return [ 45 return [
46 'message' => $this->getMessage(), 46 'message' => $this->getMessage(),
47 'stacktrace' => get_class($this) .': '. $this->getTraceAsString() 47 'stacktrace' => get_class($this) . ': ' . $this->getTraceAsString()
48 ]; 48 ];
49 } 49 }
50 50
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
index ea565d1f..4238ef25 100644
--- a/application/bookmark/Bookmark.php
+++ b/application/bookmark/Bookmark.php
@@ -19,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException;
19class Bookmark 19class Bookmark
20{ 20{
21 /** @var string Date format used in string (former ID format) */ 21 /** @var string Date format used in string (former ID format) */
22 const LINK_DATE_FORMAT = 'Ymd_His'; 22 public const LINK_DATE_FORMAT = 'Ymd_His';
23 23
24 /** @var int Bookmark ID */ 24 /** @var int Bookmark ID */
25 protected $id; 25 protected $id;
@@ -60,11 +60,13 @@ class Bookmark
60 /** 60 /**
61 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format. 61 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
62 * 62 *
63 * @param array $data 63 * @param array $data
64 * @param string $tagsSeparator Tags separator loaded from the config file.
65 * This is a context data, and it should *never* be stored in the Bookmark object.
64 * 66 *
65 * @return $this 67 * @return $this
66 */ 68 */
67 public function fromArray(array $data): Bookmark 69 public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
68 { 70 {
69 $this->id = $data['id'] ?? null; 71 $this->id = $data['id'] ?? null;
70 $this->shortUrl = $data['shorturl'] ?? null; 72 $this->shortUrl = $data['shorturl'] ?? null;
@@ -77,7 +79,7 @@ class Bookmark
77 if (is_array($data['tags'])) { 79 if (is_array($data['tags'])) {
78 $this->tags = $data['tags']; 80 $this->tags = $data['tags'];
79 } else { 81 } else {
80 $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY); 82 $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
81 } 83 }
82 if (! empty($data['updated'])) { 84 if (! empty($data['updated'])) {
83 $this->updated = $data['updated']; 85 $this->updated = $data['updated'];
@@ -104,7 +106,8 @@ class Bookmark
104 */ 106 */
105 public function validate(): void 107 public function validate(): void
106 { 108 {
107 if ($this->id === null 109 if (
110 $this->id === null
108 || ! is_int($this->id) 111 || ! is_int($this->id)
109 || empty($this->shortUrl) 112 || empty($this->shortUrl)
110 || empty($this->created) 113 || empty($this->created)
@@ -112,7 +115,7 @@ class Bookmark
112 throw new InvalidBookmarkException($this); 115 throw new InvalidBookmarkException($this);
113 } 116 }
114 if (empty($this->url)) { 117 if (empty($this->url)) {
115 $this->url = '/shaare/'. $this->shortUrl; 118 $this->url = '/shaare/' . $this->shortUrl;
116 } 119 }
117 if (empty($this->title)) { 120 if (empty($this->title)) {
118 $this->title = $this->url; 121 $this->title = $this->url;
@@ -348,7 +351,12 @@ class Bookmark
348 */ 351 */
349 public function setTags(?array $tags): Bookmark 352 public function setTags(?array $tags): Bookmark
350 { 353 {
351 $this->setTagsString(implode(' ', $tags ?? [])); 354 $this->tags = array_map(
355 function (string $tag): string {
356 return $tag[0] === '-' ? substr($tag, 1) : $tag;
357 },
358 tags_filter($tags, ' ')
359 );
352 360
353 return $this; 361 return $this;
354 } 362 }
@@ -378,6 +386,24 @@ class Bookmark
378 } 386 }
379 387
380 /** 388 /**
389 * Return true if:
390 * - the bookmark's thumbnail is not already set to false (= not found)
391 * - it's not a note
392 * - it's an HTTP(S) link
393 * - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
394 *
395 * @return bool True if the bookmark's thumbnail needs to be retrieved.
396 */
397 public function shouldUpdateThumbnail(): bool
398 {
399 return $this->thumbnail !== false
400 && !$this->isNote()
401 && startsWith(strtolower($this->url), 'http')
402 && (null === $this->thumbnail || !is_file($this->thumbnail))
403 ;
404 }
405
406 /**
381 * Get the Sticky. 407 * Get the Sticky.
382 * 408 *
383 * @return bool 409 * @return bool
@@ -402,11 +428,13 @@ class Bookmark
402 } 428 }
403 429
404 /** 430 /**
405 * @return string Bookmark's tags as a string, separated by a space 431 * @param string $separator Tags separator loaded from the config file.
432 *
433 * @return string Bookmark's tags as a string, separated by a separator
406 */ 434 */
407 public function getTagsString(): string 435 public function getTagsString(string $separator = ' '): string
408 { 436 {
409 return implode(' ', $this->getTags()); 437 return tags_array2str($this->getTags(), $separator);
410 } 438 }
411 439
412 /** 440 /**
@@ -426,19 +454,13 @@ class Bookmark
426 * - trailing dash in tags will be removed 454 * - trailing dash in tags will be removed
427 * 455 *
428 * @param string|null $tags 456 * @param string|null $tags
457 * @param string $separator Tags separator loaded from the config file.
429 * 458 *
430 * @return $this 459 * @return $this
431 */ 460 */
432 public function setTagsString(?string $tags): Bookmark 461 public function setTagsString(?string $tags, string $separator = ' '): Bookmark
433 { 462 {
434 // Remove first '-' char in tags. 463 $this->setTags(tags_str2array($tags, $separator));
435 $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
436 // Explode all tags separted by spaces or commas
437 $tags = preg_split('/[\s,]+/', $tags);
438 // Remove eventual empty values
439 $tags = array_values(array_filter($tags));
440
441 $this->tags = $tags;
442 464
443 return $this; 465 return $this;
444 } 466 }
@@ -489,7 +511,7 @@ class Bookmark
489 */ 511 */
490 public function renameTag(string $fromTag, string $toTag): void 512 public function renameTag(string $fromTag, string $toTag): void
491 { 513 {
492 if (($pos = array_search($fromTag, $this->tags)) !== false) { 514 if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
493 $this->tags[$pos] = trim($toTag); 515 $this->tags[$pos] = trim($toTag);
494 } 516 }
495 } 517 }
@@ -501,7 +523,7 @@ class Bookmark
501 */ 523 */
502 public function deleteTag(string $tag): void 524 public function deleteTag(string $tag): void
503 { 525 {
504 if (($pos = array_search($tag, $this->tags)) !== false) { 526 if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
505 unset($this->tags[$pos]); 527 unset($this->tags[$pos]);
506 $this->tags = array_values($this->tags); 528 $this->tags = array_values($this->tags);
507 } 529 }
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
index 67bb3b73..b9328116 100644
--- a/application/bookmark/BookmarkArray.php
+++ b/application/bookmark/BookmarkArray.php
@@ -72,7 +72,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
72 */ 72 */
73 public function offsetSet($offset, $value) 73 public function offsetSet($offset, $value)
74 { 74 {
75 if (! $value instanceof Bookmark 75 if (
76 ! $value instanceof Bookmark
76 || $value->getId() === null || empty($value->getUrl()) 77 || $value->getId() === null || empty($value->getUrl())
77 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) 78 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
78 || $offset !== null && $offset !== $value->getId() 79 || $offset !== null && $offset !== $value->getId()
@@ -222,7 +223,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
222 */ 223 */
223 public function getByUrl(string $url): ?Bookmark 224 public function getByUrl(string $url): ?Bookmark
224 { 225 {
225 if (! empty($url) 226 if (
227 ! empty($url)
226 && isset($this->urls[$url]) 228 && isset($this->urls[$url])
227 && isset($this->bookmarks[$this->urls[$url]]) 229 && isset($this->bookmarks[$this->urls[$url]])
228 ) { 230 ) {
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index eb7899bf..6666a251 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -69,7 +69,7 @@ class BookmarkFileService implements BookmarkServiceInterface
69 } else { 69 } else {
70 try { 70 try {
71 $this->bookmarks = $this->bookmarksIO->read(); 71 $this->bookmarks = $this->bookmarksIO->read();
72 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { 72 } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
73 $this->bookmarks = new BookmarkArray(); 73 $this->bookmarks = new BookmarkArray();
74 74
75 if ($this->isLoggedIn) { 75 if ($this->isLoggedIn) {
@@ -85,25 +85,29 @@ class BookmarkFileService implements BookmarkServiceInterface
85 if (! $this->bookmarks instanceof BookmarkArray) { 85 if (! $this->bookmarks instanceof BookmarkArray) {
86 $this->migrate(); 86 $this->migrate();
87 exit( 87 exit(
88 'Your data store has been migrated, please reload the page.'. PHP_EOL . 88 'Your data store has been migrated, please reload the page.' . PHP_EOL .
89 'If this message keeps showing up, please delete data/updates.txt file.' 89 'If this message keeps showing up, please delete data/updates.txt file.'
90 ); 90 );
91 } 91 }
92 } 92 }
93 93
94 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); 94 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
95 } 95 }
96 96
97 /** 97 /**
98 * @inheritDoc 98 * @inheritDoc
99 */ 99 */
100 public function findByHash(string $hash): Bookmark 100 public function findByHash(string $hash, string $privateKey = null): Bookmark
101 { 101 {
102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); 102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
103 // PHP 7.3 introduced array_key_first() to avoid this hack 103 // PHP 7.3 introduced array_key_first() to avoid this hack
104 $first = reset($bookmark); 104 $first = reset($bookmark);
105 if (! $this->isLoggedIn && $first->isPrivate()) { 105 if (
106 throw new Exception('Not authorized'); 106 !$this->isLoggedIn
107 && $first->isPrivate()
108 && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
109 ) {
110 throw new BookmarkNotFoundException();
107 } 111 }
108 112
109 return $first; 113 return $first;
@@ -162,7 +166,8 @@ class BookmarkFileService implements BookmarkServiceInterface
162 } 166 }
163 167
164 $bookmark = $this->bookmarks[$id]; 168 $bookmark = $this->bookmarks[$id];
165 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 169 if (
170 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
166 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 171 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
167 ) { 172 ) {
168 throw new Exception('Unauthorized'); 173 throw new Exception('Unauthorized');
@@ -262,7 +267,8 @@ class BookmarkFileService implements BookmarkServiceInterface
262 } 267 }
263 268
264 $bookmark = $this->bookmarks[$id]; 269 $bookmark = $this->bookmarks[$id];
265 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 270 if (
271 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
266 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 272 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
267 ) { 273 ) {
268 return false; 274 return false;
@@ -304,7 +310,8 @@ class BookmarkFileService implements BookmarkServiceInterface
304 $caseMapping = []; 310 $caseMapping = [];
305 foreach ($bookmarks as $bookmark) { 311 foreach ($bookmarks as $bookmark) {
306 foreach ($bookmark->getTags() as $tag) { 312 foreach ($bookmark->getTags() as $tag) {
307 if (empty($tag) 313 if (
314 empty($tag)
308 || (! $this->isLoggedIn && startsWith($tag, '.')) 315 || (! $this->isLoggedIn && startsWith($tag, '.'))
309 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG 316 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
310 || in_array($tag, $filteringTags, true) 317 || in_array($tag, $filteringTags, true)
@@ -340,26 +347,42 @@ class BookmarkFileService implements BookmarkServiceInterface
340 /** 347 /**
341 * @inheritDoc 348 * @inheritDoc
342 */ 349 */
343 public function days(): array 350 public function findByDate(
344 { 351 \DateTimeInterface $from,
345 $bookmarkDays = []; 352 \DateTimeInterface $to,
346 foreach ($this->search() as $bookmark) { 353 ?\DateTimeInterface &$previous,
347 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; 354 ?\DateTimeInterface &$next
355 ): array {
356 $out = [];
357 $previous = null;
358 $next = null;
359
360 foreach ($this->search([], null, false, false, true) as $bookmark) {
361 if ($to < $bookmark->getCreated()) {
362 $next = $bookmark->getCreated();
363 } elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
364 $out[] = $bookmark;
365 } else {
366 if ($previous !== null) {
367 break;
368 }
369 $previous = $bookmark->getCreated();
370 }
348 } 371 }
349 $bookmarkDays = array_keys($bookmarkDays);
350 sort($bookmarkDays);
351 372
352 return array_map('strval', $bookmarkDays); 373 return $out;
353 } 374 }
354 375
355 /** 376 /**
356 * @inheritDoc 377 * @inheritDoc
357 */ 378 */
358 public function filterDay(string $request) 379 public function getLatest(): ?Bookmark
359 { 380 {
360 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; 381 foreach ($this->search([], null, false, false, true) as $bookmark) {
382 return $bookmark;
383 }
361 384
362 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); 385 return null;
363 } 386 }
364 387
365 /** 388 /**
@@ -386,14 +409,14 @@ class BookmarkFileService implements BookmarkServiceInterface
386 false 409 false
387 ); 410 );
388 $updater = new LegacyUpdater( 411 $updater = new LegacyUpdater(
389 UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), 412 UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
390 $bookmarkDb, 413 $bookmarkDb,
391 $this->conf, 414 $this->conf,
392 true 415 true
393 ); 416 );
394 $newUpdates = $updater->update(); 417 $newUpdates = $updater->update();
395 if (! empty($newUpdates)) { 418 if (! empty($newUpdates)) {
396 UpdaterUtils::write_updates_file( 419 UpdaterUtils::writeUpdatesFile(
397 $this->conf->get('resource.updates'), 420 $this->conf->get('resource.updates'),
398 $updater->getDoneUpdates() 421 $updater->getDoneUpdates()
399 ); 422 );
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index c79386ea..db83c51c 100644
--- a/application/bookmark/BookmarkFilter.php
+++ b/application/bookmark/BookmarkFilter.php
@@ -6,6 +6,7 @@ namespace Shaarli\Bookmark;
6 6
7use Exception; 7use Exception;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Config\ConfigManager;
9 10
10/** 11/**
11 * Class LinkFilter. 12 * Class LinkFilter.
@@ -58,12 +59,16 @@ class BookmarkFilter
58 */ 59 */
59 private $bookmarks; 60 private $bookmarks;
60 61
62 /** @var ConfigManager */
63 protected $conf;
64
61 /** 65 /**
62 * @param Bookmark[] $bookmarks initialization. 66 * @param Bookmark[] $bookmarks initialization.
63 */ 67 */
64 public function __construct($bookmarks) 68 public function __construct($bookmarks, ConfigManager $conf)
65 { 69 {
66 $this->bookmarks = $bookmarks; 70 $this->bookmarks = $bookmarks;
71 $this->conf = $conf;
67 } 72 }
68 73
69 /** 74 /**
@@ -107,10 +112,14 @@ class BookmarkFilter
107 $filtered = $this->bookmarks; 112 $filtered = $this->bookmarks;
108 } 113 }
109 if (!empty($request[0])) { 114 if (!empty($request[0])) {
110 $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); 115 $filtered = (new BookmarkFilter($filtered, $this->conf))
116 ->filterTags($request[0], $casesensitive, $visibility)
117 ;
111 } 118 }
112 if (!empty($request[1])) { 119 if (!empty($request[1])) {
113 $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); 120 $filtered = (new BookmarkFilter($filtered, $this->conf))
121 ->filterFulltext($request[1], $visibility)
122 ;
114 } 123 }
115 return $filtered; 124 return $filtered;
116 case self::$FILTER_TEXT: 125 case self::$FILTER_TEXT:
@@ -141,7 +150,7 @@ class BookmarkFilter
141 return $this->bookmarks; 150 return $this->bookmarks;
142 } 151 }
143 152
144 $out = array(); 153 $out = [];
145 foreach ($this->bookmarks as $key => $value) { 154 foreach ($this->bookmarks as $key => $value) {
146 if ($value->isPrivate() && $visibility === 'private') { 155 if ($value->isPrivate() && $visibility === 'private') {
147 $out[$key] = $value; 156 $out[$key] = $value;
@@ -280,8 +289,9 @@ class BookmarkFilter
280 * 289 *
281 * @return string generated regex fragment 290 * @return string generated regex fragment
282 */ 291 */
283 private static function tag2regex(string $tag): string 292 protected function tag2regex(string $tag): string
284 { 293 {
294 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
285 $len = strlen($tag); 295 $len = strlen($tag);
286 if (!$len || $tag === "-" || $tag === "*") { 296 if (!$len || $tag === "-" || $tag === "*") {
287 // nothing to search, return empty regex 297 // nothing to search, return empty regex
@@ -295,12 +305,13 @@ class BookmarkFilter
295 $i = 0; // start at first character 305 $i = 0; // start at first character
296 $regex = '(?='; // use positive lookahead 306 $regex = '(?='; // use positive lookahead
297 } 307 }
298 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning 308 // before tag may only be the separator or the beginning
309 $regex .= '.*(?:^|' . $tagsSeparator . ')';
299 // iterate over string, separating it into placeholder and content 310 // iterate over string, separating it into placeholder and content
300 for (; $i < $len; $i++) { 311 for (; $i < $len; $i++) {
301 if ($tag[$i] === '*') { 312 if ($tag[$i] === '*') {
302 // placeholder found 313 // placeholder found
303 $regex .= '[^ ]*?'; 314 $regex .= '[^' . $tagsSeparator . ']*?';
304 } else { 315 } else {
305 // regular characters 316 // regular characters
306 $offset = strpos($tag, '*', $i); 317 $offset = strpos($tag, '*', $i);
@@ -316,7 +327,8 @@ class BookmarkFilter
316 $i = $offset; 327 $i = $offset;
317 } 328 }
318 } 329 }
319 $regex .= '(?:$| ))'; // after the tag may only be a space or the end 330 // after the tag may only be the separator or the end
331 $regex .= '(?:$|' . $tagsSeparator . '))';
320 return $regex; 332 return $regex;
321 } 333 }
322 334
@@ -334,14 +346,15 @@ class BookmarkFilter
334 */ 346 */
335 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all') 347 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
336 { 348 {
349 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
337 // get single tags (we may get passed an array, even though the docs say different) 350 // get single tags (we may get passed an array, even though the docs say different)
338 $inputTags = $tags; 351 $inputTags = $tags;
339 if (!is_array($tags)) { 352 if (!is_array($tags)) {
340 // we got an input string, split tags 353 // we got an input string, split tags
341 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); 354 $inputTags = tags_str2array($inputTags, $tagsSeparator);
342 } 355 }
343 356
344 if (!count($inputTags)) { 357 if (count($inputTags) === 0) {
345 // no input tags 358 // no input tags
346 return $this->noFilter($visibility); 359 return $this->noFilter($visibility);
347 } 360 }
@@ -358,7 +371,7 @@ class BookmarkFilter
358 } 371 }
359 372
360 // build regex from all tags 373 // build regex from all tags
361 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; 374 $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
362 if (!$casesensitive) { 375 if (!$casesensitive) {
363 // make regex case insensitive 376 // make regex case insensitive
364 $re .= 'i'; 377 $re .= 'i';
@@ -378,10 +391,11 @@ class BookmarkFilter
378 continue; 391 continue;
379 } 392 }
380 } 393 }
381 $search = $link->getTagsString(); // build search string, start with tags of current link 394 // build search string, start with tags of current link
395 $search = $link->getTagsString($tagsSeparator);
382 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { 396 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
383 // description given and at least one possible tag found 397 // description given and at least one possible tag found
384 $descTags = array(); 398 $descTags = [];
385 // find all tags in the form of #tag in the description 399 // find all tags in the form of #tag in the description
386 preg_match_all( 400 preg_match_all(
387 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 401 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -390,9 +404,9 @@ class BookmarkFilter
390 ); 404 );
391 if (count($descTags[1])) { 405 if (count($descTags[1])) {
392 // there were some tags in the description, add them to the search string 406 // there were some tags in the description, add them to the search string
393 $search .= ' ' . implode(' ', $descTags[1]); 407 $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
394 } 408 }
395 }; 409 }
396 // match regular expression with search string 410 // match regular expression with search string
397 if (!preg_match($re, $search)) { 411 if (!preg_match($re, $search)) {
398 // this entry does _not_ match our regex 412 // this entry does _not_ match our regex
@@ -422,7 +436,7 @@ class BookmarkFilter
422 } 436 }
423 } 437 }
424 438
425 if (empty(trim($link->getTagsString()))) { 439 if (empty($link->getTags())) {
426 $filtered[$key] = $link; 440 $filtered[$key] = $link;
427 } 441 }
428 } 442 }
@@ -537,10 +551,11 @@ class BookmarkFilter
537 */ 551 */
538 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string 552 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
539 { 553 {
540 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; 554 $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
541 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; 555 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
542 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; 556 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
543 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; 557 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') . '\\';
558 $content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') . '\\';
544 559
545 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())]; 560 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
546 $nextField = $lengths['title']['end'] + 1; 561 $nextField = $lengths['title']['end'] + 1;
@@ -548,7 +563,7 @@ class BookmarkFilter
548 $nextField = $lengths['description']['end'] + 1; 563 $nextField = $lengths['description']['end'] + 1;
549 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())]; 564 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
550 $nextField = $lengths['url']['end'] + 1; 565 $nextField = $lengths['url']['end'] + 1;
551 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())]; 566 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
552 567
553 return $content; 568 return $content;
554 } 569 }
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
index f40fa476..8439d470 100644
--- a/application/bookmark/BookmarkIO.php
+++ b/application/bookmark/BookmarkIO.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
6 6
7use malkusch\lock\exception\LockAcquireException;
7use malkusch\lock\mutex\Mutex; 8use malkusch\lock\mutex\Mutex;
8use malkusch\lock\mutex\NoMutex; 9use malkusch\lock\mutex\NoMutex;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; 10use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
@@ -80,7 +81,7 @@ class BookmarkIO
80 } 81 }
81 82
82 $content = null; 83 $content = null;
83 $this->mutex->synchronized(function () use (&$content) { 84 $this->synchronized(function () use (&$content) {
84 $content = file_get_contents($this->datastore); 85 $content = file_get_contents($this->datastore);
85 }); 86 });
86 87
@@ -112,18 +113,35 @@ class BookmarkIO
112 if (is_file($this->datastore) && !is_writeable($this->datastore)) { 113 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
113 // The datastore exists but is not writeable 114 // The datastore exists but is not writeable
114 throw new NotWritableDataStoreException($this->datastore); 115 throw new NotWritableDataStoreException($this->datastore);
115 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { 116 } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
116 // The datastore does not exist and its parent directory is not writeable 117 // The datastore does not exist and its parent directory is not writeable
117 throw new NotWritableDataStoreException(dirname($this->datastore)); 118 throw new NotWritableDataStoreException(dirname($this->datastore));
118 } 119 }
119 120
120 $data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix; 121 $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix;
121 122
122 $this->mutex->synchronized(function () use ($data) { 123 $this->synchronized(function () use ($data) {
123 file_put_contents( 124 file_put_contents(
124 $this->datastore, 125 $this->datastore,
125 $data 126 $data
126 ); 127 );
127 }); 128 });
128 } 129 }
130
131 /**
132 * Wrapper applying mutex to provided function.
133 * If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
134 *
135 * @see https://github.com/shaarli/Shaarli/issues/1650
136 *
137 * @param callable $function
138 */
139 protected function synchronized(callable $function): void
140 {
141 try {
142 $this->mutex->synchronized($function);
143 } catch (LockAcquireException $exception) {
144 $function();
145 }
146 }
129} 147}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
index 04b996f3..8ab5c441 100644
--- a/application/bookmark/BookmarkInitializer.php
+++ b/application/bookmark/BookmarkInitializer.php
@@ -13,6 +13,9 @@ namespace Shaarli\Bookmark;
13 * To prevent data corruption, it does not overwrite existing bookmarks, 13 * To prevent data corruption, it does not overwrite existing bookmarks,
14 * even though there should not be any. 14 * even though there should not be any.
15 * 15 *
16 * We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
17 * @phpcs:disable Generic.Files.LineLength.TooLong
18 *
16 * @package Shaarli\Bookmark 19 * @package Shaarli\Bookmark
17 */ 20 */
18class BookmarkInitializer 21class BookmarkInitializer
@@ -36,10 +39,10 @@ class BookmarkInitializer
36 public function initialize(): void 39 public function initialize(): void
37 { 40 {
38 $bookmark = new Bookmark(); 41 $bookmark = new Bookmark();
39 $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); 42 $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
40 $bookmark->setUrl('https://vimeo.com/153493904'); 43 $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
41 $bookmark->setDescription(t( 44 $bookmark->setDescription(t(
42'Shaarli will automatically pick up the thumbnail for links to a variety of websites. 45 'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
43 46
44Explore your new Shaarli instance by trying out controls and menus. 47Explore your new Shaarli instance by trying out controls and menus.
45Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. 48Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
@@ -54,7 +57,7 @@ Now you can edit or delete the default shaares.
54 $bookmark = new Bookmark(); 57 $bookmark = new Bookmark();
55 $bookmark->setTitle(t('Note: Shaare descriptions')); 58 $bookmark->setTitle(t('Note: Shaare descriptions'));
56 $bookmark->setDescription(t( 59 $bookmark->setDescription(t(
57'Adding a shaare without entering a URL creates a text-only "note" post such as this one. 60 'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
58This note is private, so you are the only one able to see it while logged in. 61This note is private, so you are the only one able to see it while logged in.
59 62
60You can use this to keep notes, post articles, code snippets, and much more. 63You can use this to keep notes, post articles, code snippets, and much more.
@@ -91,7 +94,7 @@ Markdown also supports tables:
91 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') 94 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
92 ); 95 );
93 $bookmark->setDescription(t( 96 $bookmark->setDescription(t(
94'Welcome to Shaarli! 97 'Welcome to Shaarli!
95 98
96Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. 99Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
97You can add a description to your bookmarks, such as this one, and tag them. 100You can add a description to your bookmarks, such as this one, and tag them.
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
index 37a54d03..08cdbb4e 100644
--- a/application/bookmark/BookmarkServiceInterface.php
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -20,13 +20,14 @@ interface BookmarkServiceInterface
20 /** 20 /**
21 * Find a bookmark by hash 21 * Find a bookmark by hash
22 * 22 *
23 * @param string $hash 23 * @param string $hash Bookmark's hash
24 * @param string|null $privateKey Optional key used to access private links while logged out
24 * 25 *
25 * @return Bookmark 26 * @return Bookmark
26 * 27 *
27 * @throws \Exception 28 * @throws \Exception
28 */ 29 */
29 public function findByHash(string $hash): Bookmark; 30 public function findByHash(string $hash, string $privateKey = null);
30 31
31 /** 32 /**
32 * @param $url 33 * @param $url
@@ -155,22 +156,29 @@ interface BookmarkServiceInterface
155 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; 156 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
156 157
157 /** 158 /**
158 * Returns the list of days containing articles (oldest first) 159 * Return a list of bookmark matching provided period of time.
160 * It also update directly previous and next date outside of given period found in the datastore.
159 * 161 *
160 * @return array containing days (in format YYYYMMDD). 162 * @param \DateTimeInterface $from Starting date.
163 * @param \DateTimeInterface $to Ending date.
164 * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
165 * @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
166 *
167 * @return array List of bookmarks matching provided period of time.
161 */ 168 */
162 public function days(): array; 169 public function findByDate(
170 \DateTimeInterface $from,
171 \DateTimeInterface $to,
172 ?\DateTimeInterface &$previous,
173 ?\DateTimeInterface &$next
174 ): array;
163 175
164 /** 176 /**
165 * Returns the list of articles for a given day. 177 * Returns the latest bookmark by creation date.
166 *
167 * @param string $request day to filter. Format: YYYYMMDD.
168 * 178 *
169 * @return Bookmark[] list of shaare found. 179 * @return Bookmark|null Found Bookmark or null if the datastore is empty.
170 *
171 * @throws BookmarkNotFoundException
172 */ 180 */
173 public function filterDay(string $request); 181 public function getLatest(): ?Bookmark;
174 182
175 /** 183 /**
176 * Creates the default database after a fresh install. 184 * Creates the default database after a fresh install.
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index faf5dbfd..0ab2d213 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -67,17 +67,20 @@ function html_extract_tag($tag, $html)
67 $propertiesKey = ['property', 'name', 'itemprop']; 67 $propertiesKey = ['property', 'name', 'itemprop'];
68 $properties = implode('|', $propertiesKey); 68 $properties = implode('|', $propertiesKey);
69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' 69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
70 $orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; 70 $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
71 // Try to retrieve OpenGraph image. 71 // Support quotes in double quoted content, and the other way around
72 $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#'; 72 $content = 'content=(["\'])((?:(?!\1).)*)\1';
73 // Try to retrieve OpenGraph tag.
74 $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
73 // If the attributes are not in the order property => content (e.g. Github) 75 // If the attributes are not in the order property => content (e.g. Github)
74 // New regex to keep this readable... more or less. 76 // New regex to keep this readable... more or less.
75 $ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#'; 77 $ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
76 78
77 if (preg_match($ogRegex, $html, $matches) > 0 79 if (
80 preg_match($ogRegex, $html, $matches) > 0
78 || preg_match($ogRegexReverse, $html, $matches) > 0 81 || preg_match($ogRegexReverse, $html, $matches) > 0
79 ) { 82 ) {
80 return $matches[1]; 83 return $matches[2];
81 } 84 }
82 85
83 return false; 86 return false;
@@ -116,7 +119,7 @@ function hashtag_autolink($description, $indexUrl = '')
116 * \p{Mn} - any non marking space (accents, umlauts, etc) 119 * \p{Mn} - any non marking space (accents, umlauts, etc)
117 */ 120 */
118 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 121 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
119 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>'; 122 $replacement = '$1<a href="' . $indexUrl . './add-tag/$2" title="Hashtag $2">#$2</a>';
120 return preg_replace($regex, $replacement, $description); 123 return preg_replace($regex, $replacement, $description);
121} 124}
122 125
@@ -138,12 +141,17 @@ function space2nbsp($text)
138 * 141 *
139 * @param string $description shaare's description. 142 * @param string $description shaare's description.
140 * @param string $indexUrl URL to Shaarli's index. 143 * @param string $indexUrl URL to Shaarli's index.
141 144 * @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
145 *
142 * @return string formatted description. 146 * @return string formatted description.
143 */ 147 */
144function format_description($description, $indexUrl = '') 148function format_description($description, $indexUrl = '', $autolink = true)
145{ 149{
146 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl))); 150 if ($autolink) {
151 $description = hashtag_autolink(text2clickable($description), $indexUrl);
152 }
153
154 return nl2br(space2nbsp($description));
147} 155}
148 156
149/** 157/**
@@ -171,3 +179,49 @@ function is_note($linkUrl)
171{ 179{
172 return isset($linkUrl[0]) && $linkUrl[0] === '?'; 180 return isset($linkUrl[0]) && $linkUrl[0] === '?';
173} 181}
182
183/**
184 * Extract an array of tags from a given tag string, with provided separator.
185 *
186 * @param string|null $tags String containing a list of tags separated by $separator.
187 * @param string $separator Shaarli's default: ' ' (whitespace)
188 *
189 * @return array List of tags
190 */
191function tags_str2array(?string $tags, string $separator): array
192{
193 // For whitespaces, we use the special \s regex character
194 $separator = $separator === ' ' ? '\s' : $separator;
195
196 return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
197}
198
199/**
200 * Return a tag string with provided separator from a list of tags.
201 * Note that given array is clean up by tags_filter().
202 *
203 * @param array|null $tags List of tags
204 * @param string $separator
205 *
206 * @return string
207 */
208function tags_array2str(?array $tags, string $separator): string
209{
210 return implode($separator, tags_filter($tags, $separator));
211}
212
213/**
214 * Clean an array of tags: trim + remove empty entries
215 *
216 * @param array|null $tags List of tags
217 * @param string $separator
218 *
219 * @return array
220 */
221function tags_filter(?array $tags, string $separator): array
222{
223 $trimDefault = " \t\n\r\0\x0B";
224 return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
225 return trim($entry, $trimDefault . $separator);
226 }, $tags ?? [])));
227}
diff --git a/application/bookmark/exception/BookmarkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php
index 827a3d35..a91d1efa 100644
--- a/application/bookmark/exception/BookmarkNotFoundException.php
+++ b/application/bookmark/exception/BookmarkNotFoundException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
3 4
4use Exception; 5use Exception;
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
index cd48c1e6..16a98470 100644
--- a/application/bookmark/exception/EmptyDataStoreException.php
+++ b/application/bookmark/exception/EmptyDataStoreException.php
@@ -1,7 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6 5class EmptyDataStoreException extends \Exception
7class EmptyDataStoreException extends \Exception {} 6{
7}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
index 10c84a6d..fe184f8c 100644
--- a/application/bookmark/exception/InvalidBookmarkException.php
+++ b/application/bookmark/exception/InvalidBookmarkException.php
@@ -16,14 +16,14 @@ class InvalidBookmarkException extends \Exception
16 } else { 16 } else {
17 $created = 'Not a DateTime object'; 17 $created = 'Not a DateTime object';
18 } 18 }
19 $this->message = 'This bookmark is not valid'. PHP_EOL; 19 $this->message = 'This bookmark is not valid' . PHP_EOL;
20 $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; 20 $this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
21 $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; 21 $this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
22 $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; 22 $this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
23 $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; 23 $this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
24 $this->message .= ' - Created: '. $created . PHP_EOL; 24 $this->message .= ' - Created: ' . $created . PHP_EOL;
25 } else { 25 } else {
26 $this->message = 'The provided data is not a bookmark'. PHP_EOL; 26 $this->message = 'The provided data is not a bookmark' . PHP_EOL;
27 $this->message .= var_export($bookmark, true); 27 $this->message .= var_export($bookmark, true);
28 } 28 }
29 } 29 }
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
index 95f34b50..df91f3bc 100644
--- a/application/bookmark/exception/NotWritableDataStoreException.php
+++ b/application/bookmark/exception/NotWritableDataStoreException.php
@@ -1,9 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6
7class NotWritableDataStoreException extends \Exception 5class NotWritableDataStoreException extends \Exception
8{ 6{
9 /** 7 /**
@@ -13,7 +11,7 @@ class NotWritableDataStoreException extends \Exception
13 */ 11 */
14 public function __construct($dataStore) 12 public function __construct($dataStore)
15 { 13 {
16 $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. 14 $this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
17 'Your data might be corrupted, or your file isn\'t readable.'; 15 'Your data might be corrupted, or your file isn\'t readable.';
18 } 16 }
19} 17}
diff --git a/application/config/ConfigIO.php b/application/config/ConfigIO.php
index 3efe5b6f..a623bc8b 100644
--- a/application/config/ConfigIO.php
+++ b/application/config/ConfigIO.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4/** 5/**
diff --git a/application/config/ConfigJson.php b/application/config/ConfigJson.php
index c0c0dab9..23b22269 100644
--- a/application/config/ConfigJson.php
+++ b/application/config/ConfigJson.php
@@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
19 $data = file_get_contents($filepath); 19 $data = file_get_contents($filepath);
20 $data = str_replace(self::getPhpHeaders(), '', $data); 20 $data = str_replace(self::getPhpHeaders(), '', $data);
21 $data = str_replace(self::getPhpSuffix(), '', $data); 21 $data = str_replace(self::getPhpSuffix(), '', $data);
22 $data = json_decode($data, true); 22 $data = json_decode(trim($data), true);
23 if ($data === null) { 23 if ($data === null) {
24 $errorCode = json_last_error(); 24 $errorCode = json_last_error();
25 $error = sprintf( 25 $error = sprintf(
@@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
73 */ 73 */
74 public static function getPhpHeaders() 74 public static function getPhpHeaders()
75 { 75 {
76 return '<?php /*'. PHP_EOL; 76 return '<?php /*';
77 } 77 }
78 78
79 /** 79 /**
@@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
85 */ 85 */
86 public static function getPhpSuffix() 86 public static function getPhpSuffix()
87 { 87 {
88 return PHP_EOL . '*/ ?>'; 88 return '*/ ?>';
89 } 89 }
90} 90}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index 4c98be30..717a038f 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4use Shaarli\Config\Exception\MissingFieldConfigException; 5use Shaarli\Config\Exception\MissingFieldConfigException;
@@ -20,7 +21,7 @@ class ConfigManager
20 */ 21 */
21 protected static $NOT_FOUND = 'NOT_FOUND'; 22 protected static $NOT_FOUND = 'NOT_FOUND';
22 23
23 public static $DEFAULT_PLUGINS = array('qrcode'); 24 public static $DEFAULT_PLUGINS = ['qrcode'];
24 25
25 /** 26 /**
26 * @var string Config folder. 27 * @var string Config folder.
@@ -133,7 +134,7 @@ class ConfigManager
133 public function set($setting, $value, $write = false, $isLoggedIn = false) 134 public function set($setting, $value, $write = false, $isLoggedIn = false)
134 { 135 {
135 if (empty($setting) || ! is_string($setting)) { 136 if (empty($setting) || ! is_string($setting)) {
136 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); 137 throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
137 } 138 }
138 139
139 // During the ConfigIO transition, map legacy settings to the new ones. 140 // During the ConfigIO transition, map legacy settings to the new ones.
@@ -160,7 +161,7 @@ class ConfigManager
160 public function remove($setting, $write = false, $isLoggedIn = false) 161 public function remove($setting, $write = false, $isLoggedIn = false)
161 { 162 {
162 if (empty($setting) || ! is_string($setting)) { 163 if (empty($setting) || ! is_string($setting)) {
163 throw new \Exception(t('Invalid setting key parameter. String expected, got: '). gettype($setting)); 164 throw new \Exception(t('Invalid setting key parameter. String expected, got: ') . gettype($setting));
164 } 165 }
165 166
166 // During the ConfigIO transition, map legacy settings to the new ones. 167 // During the ConfigIO transition, map legacy settings to the new ones.
@@ -213,7 +214,7 @@ class ConfigManager
213 public function write($isLoggedIn) 214 public function write($isLoggedIn)
214 { 215 {
215 // These fields are required in configuration. 216 // These fields are required in configuration.
216 $mandatoryFields = array( 217 $mandatoryFields = [
217 'credentials.login', 218 'credentials.login',
218 'credentials.hash', 219 'credentials.hash',
219 'credentials.salt', 220 'credentials.salt',
@@ -222,7 +223,7 @@ class ConfigManager
222 'general.title', 223 'general.title',
223 'general.header_link', 224 'general.header_link',
224 'privacy.default_private_links', 225 'privacy.default_private_links',
225 ); 226 ];
226 227
227 // Only logged in user can alter config. 228 // Only logged in user can alter config.
228 if (is_file($this->getConfigFileExt()) && !$isLoggedIn) { 229 if (is_file($this->getConfigFileExt()) && !$isLoggedIn) {
@@ -366,10 +367,12 @@ class ConfigManager
366 $this->setEmpty('general.links_per_page', 20); 367 $this->setEmpty('general.links_per_page', 20);
367 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS); 368 $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
368 $this->setEmpty('general.default_note_title', 'Note: '); 369 $this->setEmpty('general.default_note_title', 'Note: ');
369 $this->setEmpty('general.retrieve_description', false); 370 $this->setEmpty('general.retrieve_description', true);
371 $this->setEmpty('general.enable_async_metadata', true);
372 $this->setEmpty('general.tags_separator', ' ');
370 373
371 $this->setEmpty('updates.check_updates', false); 374 $this->setEmpty('updates.check_updates', true);
372 $this->setEmpty('updates.check_updates_branch', 'stable'); 375 $this->setEmpty('updates.check_updates_branch', 'latest');
373 $this->setEmpty('updates.check_updates_interval', 86400); 376 $this->setEmpty('updates.check_updates_interval', 86400);
374 377
375 $this->setEmpty('feed.rss_permalinks', true); 378 $this->setEmpty('feed.rss_permalinks', true);
@@ -390,7 +393,7 @@ class ConfigManager
390 $this->setEmpty('translation.mode', 'php'); 393 $this->setEmpty('translation.mode', 'php');
391 $this->setEmpty('translation.extensions', []); 394 $this->setEmpty('translation.extensions', []);
392 395
393 $this->setEmpty('plugins', array()); 396 $this->setEmpty('plugins', []);
394 397
395 $this->setEmpty('formatter', 'markdown'); 398 $this->setEmpty('formatter', 'markdown');
396 } 399 }
diff --git a/application/config/ConfigPhp.php b/application/config/ConfigPhp.php
index cad34594..53d6a7a3 100644
--- a/application/config/ConfigPhp.php
+++ b/application/config/ConfigPhp.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Config; 3namespace Shaarli\Config;
3 4
4/** 5/**
@@ -12,7 +13,7 @@ class ConfigPhp implements ConfigIO
12 /** 13 /**
13 * @var array List of config key without group. 14 * @var array List of config key without group.
14 */ 15 */
15 public static $ROOT_KEYS = array( 16 public static $ROOT_KEYS = [
16 'login', 17 'login',
17 'hash', 18 'hash',
18 'salt', 19 'salt',
@@ -22,7 +23,7 @@ class ConfigPhp implements ConfigIO
22 'redirector', 23 'redirector',
23 'disablesessionprotection', 24 'disablesessionprotection',
24 'privateLinkByDefault', 25 'privateLinkByDefault',
25 ); 26 ];
26 27
27 /** 28 /**
28 * Map legacy config keys with the new ones. 29 * Map legacy config keys with the new ones.
@@ -31,7 +32,7 @@ class ConfigPhp implements ConfigIO
31 * 32 *
32 * @var array current key => legacy key. 33 * @var array current key => legacy key.
33 */ 34 */
34 public static $LEGACY_KEYS_MAPPING = array( 35 public static $LEGACY_KEYS_MAPPING = [
35 'credentials.login' => 'login', 36 'credentials.login' => 'login',
36 'credentials.hash' => 'hash', 37 'credentials.hash' => 'hash',
37 'credentials.salt' => 'salt', 38 'credentials.salt' => 'salt',
@@ -68,7 +69,7 @@ class ConfigPhp implements ConfigIO
68 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS', 69 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS',
69 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS', 70 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS',
70 'security.open_shaarli' => 'config.OPEN_SHAARLI', 71 'security.open_shaarli' => 'config.OPEN_SHAARLI',
71 ); 72 ];
72 73
73 /** 74 /**
74 * @inheritdoc 75 * @inheritdoc
@@ -76,12 +77,12 @@ class ConfigPhp implements ConfigIO
76 public function read($filepath) 77 public function read($filepath)
77 { 78 {
78 if (! file_exists($filepath) || ! is_readable($filepath)) { 79 if (! file_exists($filepath) || ! is_readable($filepath)) {
79 return array(); 80 return [];
80 } 81 }
81 82
82 include $filepath; 83 include $filepath;
83 84
84 $out = array(); 85 $out = [];
85 foreach (self::$ROOT_KEYS as $key) { 86 foreach (self::$ROOT_KEYS as $key) {
86 $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : ''; 87 $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : '';
87 } 88 }
@@ -95,7 +96,7 @@ class ConfigPhp implements ConfigIO
95 */ 96 */
96 public function write($filepath, $conf) 97 public function write($filepath, $conf)
97 { 98 {
98 $configStr = '<?php '. PHP_EOL; 99 $configStr = '<?php ' . PHP_EOL;
99 foreach (self::$ROOT_KEYS as $key) { 100 foreach (self::$ROOT_KEYS as $key) {
100 if (isset($conf[$key])) { 101 if (isset($conf[$key])) {
101 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL; 102 $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL;
@@ -106,8 +107,8 @@ class ConfigPhp implements ConfigIO
106 foreach ($conf['config'] as $key => $value) { 107 foreach ($conf['config'] as $key => $value) {
107 $configStr .= '$GLOBALS[\'config\'][\'' 108 $configStr .= '$GLOBALS[\'config\'][\''
108 . $key 109 . $key
109 .'\'] = ' 110 . '\'] = '
110 .var_export($conf['config'][$key], true).';' 111 . var_export($conf['config'][$key], true) . ';'
111 . PHP_EOL; 112 . PHP_EOL;
112 } 113 }
113 114
@@ -115,18 +116,19 @@ class ConfigPhp implements ConfigIO
115 foreach ($conf['plugins'] as $key => $value) { 116 foreach ($conf['plugins'] as $key => $value) {
116 $configStr .= '$GLOBALS[\'plugins\'][\'' 117 $configStr .= '$GLOBALS[\'plugins\'][\''
117 . $key 118 . $key
118 .'\'] = ' 119 . '\'] = '
119 .var_export($conf['plugins'][$key], true).';' 120 . var_export($conf['plugins'][$key], true) . ';'
120 . PHP_EOL; 121 . PHP_EOL;
121 } 122 }
122 } 123 }
123 124
124 if (!file_put_contents($filepath, $configStr) 125 if (
126 !file_put_contents($filepath, $configStr)
125 || strcmp(file_get_contents($filepath), $configStr) != 0 127 || strcmp(file_get_contents($filepath), $configStr) != 0
126 ) { 128 ) {
127 throw new \Shaarli\Exceptions\IOException( 129 throw new \Shaarli\Exceptions\IOException(
128 $filepath, 130 $filepath,
129 t('Shaarli could not create the config file. '. 131 t('Shaarli could not create the config file. ' .
130 'Please make sure Shaarli has the right to write in the folder is it installed in.') 132 'Please make sure Shaarli has the right to write in the folder is it installed in.')
131 ); 133 );
132 } 134 }
diff --git a/application/config/ConfigPlugin.php b/application/config/ConfigPlugin.php
index ea8dfbda..6cadef12 100644
--- a/application/config/ConfigPlugin.php
+++ b/application/config/ConfigPlugin.php
@@ -39,8 +39,8 @@ function save_plugin_config($formData)
39 throw new PluginConfigOrderException(); 39 throw new PluginConfigOrderException();
40 } 40 }
41 41
42 $plugins = array(); 42 $plugins = [];
43 $newEnabledPlugins = array(); 43 $newEnabledPlugins = [];
44 foreach ($formData as $key => $data) { 44 foreach ($formData as $key => $data) {
45 if (startsWith($key, 'order')) { 45 if (startsWith($key, 'order')) {
46 continue; 46 continue;
@@ -62,7 +62,7 @@ function save_plugin_config($formData)
62 throw new PluginConfigOrderException(); 62 throw new PluginConfigOrderException();
63 } 63 }
64 64
65 $finalPlugins = array(); 65 $finalPlugins = [];
66 // Make plugins order continuous. 66 // Make plugins order continuous.
67 foreach ($plugins as $plugin) { 67 foreach ($plugins as $plugin) {
68 $finalPlugins[] = $plugin; 68 $finalPlugins[] = $plugin;
@@ -81,7 +81,7 @@ function save_plugin_config($formData)
81 */ 81 */
82function validate_plugin_order($formData) 82function validate_plugin_order($formData)
83{ 83{
84 $orders = array(); 84 $orders = [];
85 foreach ($formData as $key => $value) { 85 foreach ($formData as $key => $value) {
86 // No duplicate order allowed. 86 // No duplicate order allowed.
87 if (in_array($value, $orders, true)) { 87 if (in_array($value, $orders, true)) {
diff --git a/application/config/exception/MissingFieldConfigException.php b/application/config/exception/MissingFieldConfigException.php
index 9e0a9359..a5f4356a 100644
--- a/application/config/exception/MissingFieldConfigException.php
+++ b/application/config/exception/MissingFieldConfigException.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Config\Exception; 3namespace Shaarli\Config\Exception;
5 4
6/** 5/**
diff --git a/application/config/exception/UnauthorizedConfigException.php b/application/config/exception/UnauthorizedConfigException.php
index 72311fae..b041c6e3 100644
--- a/application/config/exception/UnauthorizedConfigException.php
+++ b/application/config/exception/UnauthorizedConfigException.php
@@ -1,6 +1,5 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Config\Exception; 3namespace Shaarli\Config\Exception;
5 4
6/** 5/**
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index c21d58dd..6d69a880 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use malkusch\lock\mutex\FlockMutex; 7use malkusch\lock\mutex\FlockMutex;
8use Psr\Log\LoggerInterface;
8use Shaarli\Bookmark\BookmarkFileService; 9use Shaarli\Bookmark\BookmarkFileService;
9use Shaarli\Bookmark\BookmarkServiceInterface; 10use Shaarli\Bookmark\BookmarkServiceInterface;
10use Shaarli\Config\ConfigManager; 11use Shaarli\Config\ConfigManager;
@@ -14,6 +15,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
14use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; 15use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
15use Shaarli\History; 16use Shaarli\History;
16use Shaarli\Http\HttpAccess; 17use Shaarli\Http\HttpAccess;
18use Shaarli\Http\MetadataRetriever;
17use Shaarli\Netscape\NetscapeBookmarkUtils; 19use Shaarli\Netscape\NetscapeBookmarkUtils;
18use Shaarli\Plugin\PluginManager; 20use Shaarli\Plugin\PluginManager;
19use Shaarli\Render\PageBuilder; 21use Shaarli\Render\PageBuilder;
@@ -48,6 +50,12 @@ class ContainerBuilder
48 /** @var LoginManager */ 50 /** @var LoginManager */
49 protected $login; 51 protected $login;
50 52
53 /** @var PluginManager */
54 protected $pluginManager;
55
56 /** @var LoggerInterface */
57 protected $logger;
58
51 /** @var string|null */ 59 /** @var string|null */
52 protected $basePath = null; 60 protected $basePath = null;
53 61
@@ -55,12 +63,16 @@ class ContainerBuilder
55 ConfigManager $conf, 63 ConfigManager $conf,
56 SessionManager $session, 64 SessionManager $session,
57 CookieManager $cookieManager, 65 CookieManager $cookieManager,
58 LoginManager $login 66 LoginManager $login,
67 PluginManager $pluginManager,
68 LoggerInterface $logger
59 ) { 69 ) {
60 $this->conf = $conf; 70 $this->conf = $conf;
61 $this->session = $session; 71 $this->session = $session;
62 $this->login = $login; 72 $this->login = $login;
63 $this->cookieManager = $cookieManager; 73 $this->cookieManager = $cookieManager;
74 $this->pluginManager = $pluginManager;
75 $this->logger = $logger;
64 } 76 }
65 77
66 public function build(): ShaarliContainer 78 public function build(): ShaarliContainer
@@ -71,11 +83,10 @@ class ContainerBuilder
71 $container['sessionManager'] = $this->session; 83 $container['sessionManager'] = $this->session;
72 $container['cookieManager'] = $this->cookieManager; 84 $container['cookieManager'] = $this->cookieManager;
73 $container['loginManager'] = $this->login; 85 $container['loginManager'] = $this->login;
86 $container['pluginManager'] = $this->pluginManager;
87 $container['logger'] = $this->logger;
74 $container['basePath'] = $this->basePath; 88 $container['basePath'] = $this->basePath;
75 89
76 $container['plugins'] = function (ShaarliContainer $container): PluginManager {
77 return new PluginManager($container->conf);
78 };
79 90
80 $container['history'] = function (ShaarliContainer $container): History { 91 $container['history'] = function (ShaarliContainer $container): History {
81 return new History($container->conf->get('resource.history')); 92 return new History($container->conf->get('resource.history'));
@@ -90,24 +101,21 @@ class ContainerBuilder
90 ); 101 );
91 }; 102 };
92 103
104 $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
105 return new MetadataRetriever($container->conf, $container->httpAccess);
106 };
107
93 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { 108 $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
94 return new PageBuilder( 109 return new PageBuilder(
95 $container->conf, 110 $container->conf,
96 $container->sessionManager->getSession(), 111 $container->sessionManager->getSession(),
112 $container->logger,
97 $container->bookmarkService, 113 $container->bookmarkService,
98 $container->sessionManager->generateToken(), 114 $container->sessionManager->generateToken(),
99 $container->loginManager->isLoggedIn() 115 $container->loginManager->isLoggedIn()
100 ); 116 );
101 }; 117 };
102 118
103 $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
104 $pluginManager = new PluginManager($container->conf);
105
106 $pluginManager->load($container->conf->get('general.enabled_plugins'));
107
108 return $pluginManager;
109 };
110
111 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { 119 $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
112 return new FormatterFactory( 120 return new FormatterFactory(
113 $container->conf, 121 $container->conf,
@@ -145,7 +153,7 @@ class ContainerBuilder
145 153
146 $container['updater'] = function (ShaarliContainer $container): Updater { 154 $container['updater'] = function (ShaarliContainer $container): Updater {
147 return new Updater( 155 return new Updater(
148 UpdaterUtils::read_updates_file($container->conf->get('resource.updates')), 156 UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')),
149 $container->bookmarkService, 157 $container->bookmarkService,
150 $container->conf, 158 $container->conf,
151 $container->loginManager->isLoggedIn() 159 $container->loginManager->isLoggedIn()
diff --git a/application/container/ShaarliContainer.php b/application/container/ShaarliContainer.php
index 66e669aa..3e5bd252 100644
--- a/application/container/ShaarliContainer.php
+++ b/application/container/ShaarliContainer.php
@@ -4,12 +4,14 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use Psr\Log\LoggerInterface;
7use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
8use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
9use Shaarli\Feed\FeedBuilder; 10use Shaarli\Feed\FeedBuilder;
10use Shaarli\Formatter\FormatterFactory; 11use Shaarli\Formatter\FormatterFactory;
11use Shaarli\History; 12use Shaarli\History;
12use Shaarli\Http\HttpAccess; 13use Shaarli\Http\HttpAccess;
14use Shaarli\Http\MetadataRetriever;
13use Shaarli\Netscape\NetscapeBookmarkUtils; 15use Shaarli\Netscape\NetscapeBookmarkUtils;
14use Shaarli\Plugin\PluginManager; 16use Shaarli\Plugin\PluginManager;
15use Shaarli\Render\PageBuilder; 17use Shaarli\Render\PageBuilder;
@@ -35,6 +37,8 @@ use Slim\Container;
35 * @property History $history 37 * @property History $history
36 * @property HttpAccess $httpAccess 38 * @property HttpAccess $httpAccess
37 * @property LoginManager $loginManager 39 * @property LoginManager $loginManager
40 * @property LoggerInterface $logger
41 * @property MetadataRetriever $metadataRetriever
38 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils 42 * @property NetscapeBookmarkUtils $netscapeBookmarkUtils
39 * @property callable $notFoundHandler Overrides default Slim exception display 43 * @property callable $notFoundHandler Overrides default Slim exception display
40 * @property PageBuilder $pageBuilder 44 * @property PageBuilder $pageBuilder
diff --git a/application/exceptions/IOException.php b/application/exceptions/IOException.php
index 2aa25e5c..c1a9ffbe 100644
--- a/application/exceptions/IOException.php
+++ b/application/exceptions/IOException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Exceptions; 3namespace Shaarli\Exceptions;
3 4
4use Exception; 5use Exception;
diff --git a/application/feed/CachedPage.php b/application/feed/CachedPage.php
index d809bdd9..c23c200f 100644
--- a/application/feed/CachedPage.php
+++ b/application/feed/CachedPage.php
@@ -1,34 +1,43 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Feed; 5namespace Shaarli\Feed;
4 6
7use DatePeriod;
8
5/** 9/**
6 * Simple cache system, mainly for the RSS/ATOM feeds 10 * Simple cache system, mainly for the RSS/ATOM feeds
7 */ 11 */
8class CachedPage 12class CachedPage
9{ 13{
10 // Directory containing page caches 14 /** Directory containing page caches */
11 private $cacheDir; 15 protected $cacheDir;
16
17 /** Should this URL be cached (boolean)? */
18 protected $shouldBeCached;
12 19
13 // Should this URL be cached (boolean)? 20 /** Name of the cache file for this URL */
14 private $shouldBeCached; 21 protected $filename;
15 22
16 // Name of the cache file for this URL 23 /** @var DatePeriod|null Optionally specify a period of time for cache validity */
17 private $filename; 24 protected $validityPeriod;
18 25
19 /** 26 /**
20 * Creates a new CachedPage 27 * Creates a new CachedPage
21 * 28 *
22 * @param string $cacheDir page cache directory 29 * @param string $cacheDir page cache directory
23 * @param string $url page URL 30 * @param string $url page URL
24 * @param bool $shouldBeCached whether this page needs to be cached 31 * @param bool $shouldBeCached whether this page needs to be cached
32 * @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
25 */ 33 */
26 public function __construct($cacheDir, $url, $shouldBeCached) 34 public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod)
27 { 35 {
28 // TODO: check write access to the cache directory 36 // TODO: check write access to the cache directory
29 $this->cacheDir = $cacheDir; 37 $this->cacheDir = $cacheDir;
30 $this->filename = $this->cacheDir . '/' . sha1($url) . '.cache'; 38 $this->filename = $this->cacheDir . '/' . sha1($url) . '.cache';
31 $this->shouldBeCached = $shouldBeCached; 39 $this->shouldBeCached = $shouldBeCached;
40 $this->validityPeriod = $validityPeriod;
32 } 41 }
33 42
34 /** 43 /**
@@ -41,10 +50,20 @@ class CachedPage
41 if (!$this->shouldBeCached) { 50 if (!$this->shouldBeCached) {
42 return null; 51 return null;
43 } 52 }
44 if (is_file($this->filename)) { 53 if (!is_file($this->filename)) {
45 return file_get_contents($this->filename); 54 return null;
55 }
56 if ($this->validityPeriod !== null) {
57 $cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename));
58 if (
59 $cacheDate < $this->validityPeriod->getStartDate()
60 || $cacheDate > $this->validityPeriod->getEndDate()
61 ) {
62 return null;
63 }
46 } 64 }
47 return null; 65
66 return file_get_contents($this->filename);
48 } 67 }
49 68
50 /** 69 /**
diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php
index f70fce4f..ed62af26 100644
--- a/application/feed/FeedBuilder.php
+++ b/application/feed/FeedBuilder.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Feed; 3namespace Shaarli\Feed;
3 4
4use DateTime; 5use DateTime;
@@ -107,14 +108,14 @@ class FeedBuilder
107 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput); 108 $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
108 109
109 // Can't use array_keys() because $link is a LinkDB instance and not a real array. 110 // Can't use array_keys() because $link is a LinkDB instance and not a real array.
110 $keys = array(); 111 $keys = [];
111 foreach ($linksToDisplay as $key => $value) { 112 foreach ($linksToDisplay as $key => $value) {
112 $keys[] = $key; 113 $keys[] = $key;
113 } 114 }
114 115
115 $pageaddr = escape(index_url($this->serverInfo)); 116 $pageaddr = escape(index_url($this->serverInfo));
116 $this->formatter->addContextData('index_url', $pageaddr); 117 $this->formatter->addContextData('index_url', $pageaddr);
117 $linkDisplayed = array(); 118 $linkDisplayed = [];
118 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { 119 for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
119 $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr); 120 $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
120 } 121 }
@@ -176,9 +177,9 @@ class FeedBuilder
176 $data = $this->formatter->format($link); 177 $data = $this->formatter->format($link);
177 $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl']; 178 $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
178 if ($this->usePermalinks === true) { 179 if ($this->usePermalinks === true) {
179 $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>'; 180 $permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>';
180 } else { 181 } else {
181 $permalink = '<a href="'. $data['guid'] .'" title="'. t('Permalink') .'">'. t('Permalink') .'</a>'; 182 $permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>';
182 } 183 }
183 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink; 184 $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
184 185
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
index d58a5e39..7e0afafc 100644
--- a/application/formatter/BookmarkDefaultFormatter.php
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -12,8 +12,8 @@ namespace Shaarli\Formatter;
12 */ 12 */
13class BookmarkDefaultFormatter extends BookmarkFormatter 13class BookmarkDefaultFormatter extends BookmarkFormatter
14{ 14{
15 const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT'; 15 protected const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
16 const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|'; 16 protected const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
17 17
18 /** 18 /**
19 * @inheritdoc 19 * @inheritdoc
@@ -46,8 +46,13 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
46 $bookmark->getDescription() ?? '', 46 $bookmark->getDescription() ?? '',
47 $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] 47 $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
48 ); 48 );
49 $description = format_description(
50 escape($description),
51 $indexUrl,
52 $this->conf->get('formatter_settings.autolink', true)
53 );
49 54
50 return $this->replaceTokens(format_description(escape($description), $indexUrl)); 55 return $this->replaceTokens($description);
51 } 56 }
52 57
53 /** 58 /**
@@ -63,15 +68,16 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
63 */ 68 */
64 protected function formatTagListHtml($bookmark) 69 protected function formatTagListHtml($bookmark)
65 { 70 {
71 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
66 if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { 72 if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
67 return $this->formatTagList($bookmark); 73 return $this->formatTagList($bookmark);
68 } 74 }
69 75
70 $tags = $this->tokenizeSearchHighlightField( 76 $tags = $this->tokenizeSearchHighlightField(
71 $bookmark->getTagsString(), 77 $bookmark->getTagsString($tagsSeparator),
72 $bookmark->getAdditionalContentEntry('search_highlight')['tags'] 78 $bookmark->getAdditionalContentEntry('search_highlight')['tags']
73 ); 79 );
74 $tags = $this->filterTagList(explode(' ', $tags)); 80 $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
75 $tags = escape($tags); 81 $tags = escape($tags);
76 $tags = $this->replaceTokensArray($tags); 82 $tags = $this->replaceTokensArray($tags);
77 83
@@ -83,7 +89,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
83 */ 89 */
84 protected function formatTagString($bookmark) 90 protected function formatTagString($bookmark)
85 { 91 {
86 return implode(' ', $this->formatTagList($bookmark)); 92 return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
87 } 93 }
88 94
89 /** 95 /**
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
index e1b7f705..124ce78b 100644
--- a/application/formatter/BookmarkFormatter.php
+++ b/application/formatter/BookmarkFormatter.php
@@ -267,7 +267,7 @@ abstract class BookmarkFormatter
267 */ 267 */
268 protected function formatTagString($bookmark) 268 protected function formatTagString($bookmark)
269 { 269 {
270 return implode(' ', $this->formatTagList($bookmark)); 270 return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
271 } 271 }
272 272
273 /** 273 /**
@@ -351,6 +351,7 @@ abstract class BookmarkFormatter
351 351
352 /** 352 /**
353 * Format tag list, e.g. remove private tags if the user is not logged in. 353 * Format tag list, e.g. remove private tags if the user is not logged in.
354 * TODO: this method is called multiple time to format tags, the result should be cached.
354 * 355 *
355 * @param array $tags 356 * @param array $tags
356 * 357 *
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php
index f7714be9..ee4e8dca 100644
--- a/application/formatter/BookmarkMarkdownFormatter.php
+++ b/application/formatter/BookmarkMarkdownFormatter.php
@@ -16,7 +16,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
16 /** 16 /**
17 * When this tag is present in a bookmark, its description should not be processed with Markdown 17 * When this tag is present in a bookmark, its description should not be processed with Markdown
18 */ 18 */
19 const NO_MD_TAG = 'nomarkdown'; 19 public const NO_MD_TAG = 'nomarkdown';
20 20
21 /** @var \Parsedown instance */ 21 /** @var \Parsedown instance */
22 protected $parsedown; 22 protected $parsedown;
@@ -71,7 +71,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
71 $processedDescription = $this->replaceTokens($processedDescription); 71 $processedDescription = $this->replaceTokens($processedDescription);
72 72
73 if (!empty($processedDescription)) { 73 if (!empty($processedDescription)) {
74 $processedDescription = '<div class="markdown">'. $processedDescription . '</div>'; 74 $processedDescription = '<div class="markdown">' . $processedDescription . '</div>';
75 } 75 }
76 76
77 return $processedDescription; 77 return $processedDescription;
@@ -110,7 +110,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
110 function ($match) use ($allowedProtocols, $indexUrl) { 110 function ($match) use ($allowedProtocols, $indexUrl) {
111 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; 111 $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : '';
112 $link .= whitelist_protocols($match[1], $allowedProtocols); 112 $link .= whitelist_protocols($match[1], $allowedProtocols);
113 return ']('. $link.')'; 113 return '](' . $link . ')';
114 }, 114 },
115 $description 115 $description
116 ); 116 );
@@ -137,7 +137,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
137 * \p{Mn} - any non marking space (accents, umlauts, etc) 137 * \p{Mn} - any non marking space (accents, umlauts, etc)
138 */ 138 */
139 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 139 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
140 $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)'; 140 $replacement = '$1[#$2](' . $indexUrl . './add-tag/$2)';
141 141
142 $descriptionLines = explode(PHP_EOL, $description); 142 $descriptionLines = explode(PHP_EOL, $description);
143 $descriptionOut = ''; 143 $descriptionOut = '';
@@ -178,17 +178,17 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
178 */ 178 */
179 protected function sanitizeHtml($description) 179 protected function sanitizeHtml($description)
180 { 180 {
181 $escapeTags = array( 181 $escapeTags = [
182 'script', 182 'script',
183 'style', 183 'style',
184 'link', 184 'link',
185 'iframe', 185 'iframe',
186 'frameset', 186 'frameset',
187 'frame', 187 'frame',
188 ); 188 ];
189 foreach ($escapeTags as $tag) { 189 foreach ($escapeTags as $tag) {
190 $description = preg_replace_callback( 190 $description = preg_replace_callback(
191 '#<\s*'. $tag .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is', 191 '#<\s*' . $tag . '[^>]*>(.*</\s*' . $tag . '[^>]*>)?#is',
192 function ($match) { 192 function ($match) {
193 return escape($match[0]); 193 return escape($match[0]);
194 }, 194 },
diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php
index bc372273..4ff07cdf 100644
--- a/application/formatter/BookmarkRawFormatter.php
+++ b/application/formatter/BookmarkRawFormatter.php
@@ -10,4 +10,6 @@ namespace Shaarli\Formatter;
10 * 10 *
11 * @package Shaarli\Formatter 11 * @package Shaarli\Formatter
12 */ 12 */
13class BookmarkRawFormatter extends BookmarkFormatter {} 13class BookmarkRawFormatter extends BookmarkFormatter
14{
15}
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
index a029579f..bb865aed 100644
--- a/application/formatter/FormatterFactory.php
+++ b/application/formatter/FormatterFactory.php
@@ -41,7 +41,7 @@ class FormatterFactory
41 public function getFormatter(string $type = null): BookmarkFormatter 41 public function getFormatter(string $type = null): BookmarkFormatter
42 { 42 {
43 $type = $type ? $type : $this->conf->get('formatter', 'default'); 43 $type = $type ? $type : $this->conf->get('formatter', 'default');
44 $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; 44 $className = '\\Shaarli\\Formatter\\Bookmark' . ucfirst($type) . 'Formatter';
45 if (!class_exists($className)) { 45 if (!class_exists($className)) {
46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; 46 $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter';
47 } 47 }
diff --git a/application/front/ShaarliMiddleware.php b/application/front/ShaarliMiddleware.php
index d1aa1399..164217f4 100644
--- a/application/front/ShaarliMiddleware.php
+++ b/application/front/ShaarliMiddleware.php
@@ -42,7 +42,8 @@ class ShaarliMiddleware
42 $this->initBasePath($request); 42 $this->initBasePath($request);
43 43
44 try { 44 try {
45 if (!is_file($this->container->conf->getConfigFileExt()) 45 if (
46 !is_file($this->container->conf->getConfigFileExt())
46 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true) 47 && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
47 ) { 48 ) {
48 return $response->withRedirect($this->container->basePath . '/install'); 49 return $response->withRedirect($this->container->basePath . '/install');
@@ -86,7 +87,8 @@ class ShaarliMiddleware
86 */ 87 */
87 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool 88 protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
88 { 89 {
89 if (// if the user isn't logged in 90 if (
91// if the user isn't logged in
90 !$this->container->loginManager->isLoggedIn() 92 !$this->container->loginManager->isLoggedIn()
91 // and Shaarli doesn't have public content... 93 // and Shaarli doesn't have public content...
92 && $this->container->conf->get('privacy.hide_public_links') 94 && $this->container->conf->get('privacy.hide_public_links')
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
index 0ed7ad81..dc421661 100644
--- a/application/front/controller/admin/ConfigureController.php
+++ b/application/front/controller/admin/ConfigureController.php
@@ -51,7 +51,10 @@ class ConfigureController extends ShaarliAdminController
51 $this->assignView('languages', Languages::getAvailableLanguages()); 51 $this->assignView('languages', Languages::getAvailableLanguages());
52 $this->assignView('gd_enabled', extension_loaded('gd')); 52 $this->assignView('gd_enabled', extension_loaded('gd'));
53 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)); 53 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
54 $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 54 $this->assignView(
55 'pagetitle',
56 t('Configure') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
57 );
55 58
56 return $response->write($this->render(TemplatePage::CONFIGURE)); 59 return $response->write($this->render(TemplatePage::CONFIGURE));
57 } 60 }
@@ -95,12 +98,15 @@ class ConfigureController extends ShaarliAdminController
95 } 98 }
96 99
97 $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE; 100 $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
98 if ($thumbnailsMode !== Thumbnailer::MODE_NONE 101 if (
102 $thumbnailsMode !== Thumbnailer::MODE_NONE
99 && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) 103 && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
100 ) { 104 ) {
101 $this->saveWarningMessage( 105 $this->saveWarningMessage(
102 t('You have enabled or changed thumbnails mode.') . 106 t('You have enabled or changed thumbnails mode.') .
103 '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>' 107 '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
108 t('Please synchronize them.') .
109 '</a>'
104 ); 110 );
105 } 111 }
106 $this->container->conf->set('thumbnails.mode', $thumbnailsMode); 112 $this->container->conf->set('thumbnails.mode', $thumbnailsMode);
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php
index 2be957fa..f01d7e9b 100644
--- a/application/front/controller/admin/ExportController.php
+++ b/application/front/controller/admin/ExportController.php
@@ -23,7 +23,7 @@ class ExportController extends ShaarliAdminController
23 */ 23 */
24 public function index(Request $request, Response $response): Response 24 public function index(Request $request, Response $response): Response
25 { 25 {
26 $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 26 $this->assignView('pagetitle', t('Export') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
27 27
28 return $response->write($this->render(TemplatePage::EXPORT)); 28 return $response->write($this->render(TemplatePage::EXPORT));
29 } 29 }
@@ -68,7 +68,7 @@ class ExportController extends ShaarliAdminController
68 $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8'); 68 $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
69 $response = $response->withHeader( 69 $response = $response->withHeader(
70 'Content-disposition', 70 'Content-disposition',
71 'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' 71 'attachment; filename=bookmarks_' . $selection . '_' . $now->format(Bookmark::LINK_DATE_FORMAT) . '.html'
72 ); 72 );
73 73
74 $this->assignView('date', $now->format(DateTime::RFC822)); 74 $this->assignView('date', $now->format(DateTime::RFC822));
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php
index 758d5ef9..c2ad6a09 100644
--- a/application/front/controller/admin/ImportController.php
+++ b/application/front/controller/admin/ImportController.php
@@ -38,7 +38,7 @@ class ImportController extends ShaarliAdminController
38 true 38 true
39 ) 39 )
40 ); 40 );
41 $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 41 $this->assignView('pagetitle', t('Import') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
42 42
43 return $response->write($this->render(TemplatePage::IMPORT)); 43 return $response->write($this->render(TemplatePage::IMPORT));
44 } 44 }
@@ -64,7 +64,7 @@ class ImportController extends ShaarliAdminController
64 $msg = sprintf( 64 $msg = sprintf(
65 t( 65 t(
66 'The file you are trying to upload is probably bigger than what this webserver can accept' 66 'The file you are trying to upload is probably bigger than what this webserver can accept'
67 .' (%s). Please upload in smaller chunks.' 67 . ' (%s). Please upload in smaller chunks.'
68 ), 68 ),
69 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize')) 69 get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
70 ); 70 );
diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
deleted file mode 100644
index bb083486..00000000
--- a/application/front/controller/admin/ManageShaareController.php
+++ /dev/null
@@ -1,371 +0,0 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkMarkdownFormatter;
10use Shaarli\Render\TemplatePage;
11use Shaarli\Thumbnailer;
12use Slim\Http\Request;
13use Slim\Http\Response;
14
15/**
16 * Class PostBookmarkController
17 *
18 * Slim controller used to handle Shaarli create or edit bookmarks.
19 */
20class ManageShaareController extends ShaarliAdminController
21{
22 /**
23 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
24 */
25 public function addShaare(Request $request, Response $response): Response
26 {
27 $this->assignView(
28 'pagetitle',
29 t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
30 );
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34
35 /**
36 * GET /admin/shaare - Displays the bookmark form for creation.
37 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
38 */
39 public function displayCreateForm(Request $request, Response $response): Response
40 {
41 $url = cleanup_url($request->getParam('post'));
42
43 $linkIsNew = false;
44 // Check if URL is not already in database (in this case, we will edit the existing link)
45 $bookmark = $this->container->bookmarkService->findByUrl($url);
46 if (null === $bookmark) {
47 $linkIsNew = true;
48 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
49 $title = $request->getParam('title');
50 $description = $request->getParam('description');
51 $tags = $request->getParam('tags');
52 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
53
54 // If this is an HTTP(S) link, we try go get the page to extract
55 // the title (otherwise we will to straight to the edit form.)
56 if (empty($title) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
57 $retrieveDescription = $this->container->conf->get('general.retrieve_description');
58 // Short timeout to keep the application responsive
59 // The callback will fill $charset and $title with data from the downloaded page.
60 $this->container->httpAccess->getHttpResponse(
61 $url,
62 $this->container->conf->get('general.download_timeout', 30),
63 $this->container->conf->get('general.download_max_size', 4194304),
64 $this->container->httpAccess->getCurlDownloadCallback(
65 $charset,
66 $title,
67 $description,
68 $tags,
69 $retrieveDescription
70 )
71 );
72 if (! empty($title) && strtolower($charset) !== 'utf-8' && mb_check_encoding($charset)) {
73 $title = mb_convert_encoding($title, 'utf-8', $charset);
74 }
75 }
76
77 if (empty($url) && empty($title)) {
78 $title = $this->container->conf->get('general.default_note_title', t('Note: '));
79 }
80
81 $link = [
82 'title' => $title,
83 'url' => $url ?? '',
84 'description' => $description ?? '',
85 'tags' => $tags ?? '',
86 'private' => $private,
87 ];
88 } else {
89 $formatter = $this->container->formatterFactory->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91 }
92
93 return $this->displayForm($link, $linkIsNew, $request, $response);
94 }
95
96 /**
97 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
98 */
99 public function displayEditForm(Request $request, Response $response, array $args): Response
100 {
101 $id = $args['id'] ?? '';
102 try {
103 if (false === ctype_digit($id)) {
104 throw new BookmarkNotFoundException();
105 }
106 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
107 } catch (BookmarkNotFoundException $e) {
108 $this->saveErrorMessage(sprintf(
109 t('Bookmark with identifier %s could not be found.'),
110 $id
111 ));
112
113 return $this->redirect($response, '/');
114 }
115
116 $formatter = $this->container->formatterFactory->getFormatter('raw');
117 $link = $formatter->format($bookmark);
118
119 return $this->displayForm($link, false, $request, $response);
120 }
121
122 /**
123 * POST /admin/shaare
124 */
125 public function save(Request $request, Response $response): Response
126 {
127 $this->checkToken($request);
128
129 // lf_id should only be present if the link exists.
130 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
131 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
132 // Edit
133 $bookmark = $this->container->bookmarkService->get($id);
134 } else {
135 // New link
136 $bookmark = new Bookmark();
137 }
138
139 $bookmark->setTitle($request->getParam('lf_title'));
140 $bookmark->setDescription($request->getParam('lf_description'));
141 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
142 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
143 $bookmark->setTagsString($request->getParam('lf_tags'));
144
145 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
146 && false === $bookmark->isNote()
147 ) {
148 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
149 }
150 $this->container->bookmarkService->addOrSet($bookmark, false);
151
152 // To preserve backward compatibility with 3rd parties, plugins still use arrays
153 $formatter = $this->container->formatterFactory->getFormatter('raw');
154 $data = $formatter->format($bookmark);
155 $this->executePageHooks('save_link', $data);
156
157 $bookmark->fromArray($data);
158 $this->container->bookmarkService->set($bookmark);
159
160 // If we are called from the bookmarklet, we must close the popup:
161 if ($request->getParam('source') === 'bookmarklet') {
162 return $response->write('<script>self.close();</script>');
163 }
164
165 if (!empty($request->getParam('returnurl'))) {
166 $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
167 }
168
169 return $this->redirectFromReferer(
170 $request,
171 $response,
172 ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
173 $bookmark->getShortUrl()
174 );
175 }
176
177 /**
178 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
179 */
180 public function deleteBookmark(Request $request, Response $response): Response
181 {
182 $this->checkToken($request);
183
184 $ids = escape(trim($request->getParam('id') ?? ''));
185 if (empty($ids) || strpos($ids, ' ') !== false) {
186 // multiple, space-separated ids provided
187 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
188 } else {
189 $ids = [$ids];
190 }
191
192 // assert at least one id is given
193 if (0 === count($ids)) {
194 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
195
196 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
197 }
198
199 $formatter = $this->container->formatterFactory->getFormatter('raw');
200 $count = 0;
201 foreach ($ids as $id) {
202 try {
203 $bookmark = $this->container->bookmarkService->get((int) $id);
204 } catch (BookmarkNotFoundException $e) {
205 $this->saveErrorMessage(sprintf(
206 t('Bookmark with identifier %s could not be found.'),
207 $id
208 ));
209
210 continue;
211 }
212
213 $data = $formatter->format($bookmark);
214 $this->executePageHooks('delete_link', $data);
215 $this->container->bookmarkService->remove($bookmark, false);
216 ++ $count;
217 }
218
219 if ($count > 0) {
220 $this->container->bookmarkService->save();
221 }
222
223 // If we are called from the bookmarklet, we must close the popup:
224 if ($request->getParam('source') === 'bookmarklet') {
225 return $response->write('<script>self.close();</script>');
226 }
227
228 // Don't redirect to where we were previously because the datastore has changed.
229 return $this->redirect($response, '/');
230 }
231
232 /**
233 * GET /admin/shaare/visibility
234 *
235 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
236 */
237 public function changeVisibility(Request $request, Response $response): Response
238 {
239 $this->checkToken($request);
240
241 $ids = trim(escape($request->getParam('id') ?? ''));
242 if (empty($ids) || strpos($ids, ' ') !== false) {
243 // multiple, space-separated ids provided
244 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
245 } else {
246 // only a single id provided
247 $ids = [$ids];
248 }
249
250 // assert at least one id is given
251 if (0 === count($ids)) {
252 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
253
254 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
255 }
256
257 // assert that the visibility is valid
258 $visibility = $request->getParam('newVisibility');
259 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
260 $this->saveErrorMessage(t('Invalid visibility provided.'));
261
262 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
263 } else {
264 $isPrivate = $visibility === 'private';
265 }
266
267 $formatter = $this->container->formatterFactory->getFormatter('raw');
268 $count = 0;
269
270 foreach ($ids as $id) {
271 try {
272 $bookmark = $this->container->bookmarkService->get((int) $id);
273 } catch (BookmarkNotFoundException $e) {
274 $this->saveErrorMessage(sprintf(
275 t('Bookmark with identifier %s could not be found.'),
276 $id
277 ));
278
279 continue;
280 }
281
282 $bookmark->setPrivate($isPrivate);
283
284 // To preserve backward compatibility with 3rd parties, plugins still use arrays
285 $data = $formatter->format($bookmark);
286 $this->executePageHooks('save_link', $data);
287 $bookmark->fromArray($data);
288
289 $this->container->bookmarkService->set($bookmark, false);
290 ++$count;
291 }
292
293 if ($count > 0) {
294 $this->container->bookmarkService->save();
295 }
296
297 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
298 }
299
300 /**
301 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
302 */
303 public function pinBookmark(Request $request, Response $response, array $args): Response
304 {
305 $this->checkToken($request);
306
307 $id = $args['id'] ?? '';
308 try {
309 if (false === ctype_digit($id)) {
310 throw new BookmarkNotFoundException();
311 }
312 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
313 } catch (BookmarkNotFoundException $e) {
314 $this->saveErrorMessage(sprintf(
315 t('Bookmark with identifier %s could not be found.'),
316 $id
317 ));
318
319 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
320 }
321
322 $formatter = $this->container->formatterFactory->getFormatter('raw');
323
324 $bookmark->setSticky(!$bookmark->isSticky());
325
326 // To preserve backward compatibility with 3rd parties, plugins still use arrays
327 $data = $formatter->format($bookmark);
328 $this->executePageHooks('save_link', $data);
329 $bookmark->fromArray($data);
330
331 $this->container->bookmarkService->set($bookmark);
332
333 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
334 }
335
336 /**
337 * Helper function used to display the shaare form whether it's a new or existing bookmark.
338 *
339 * @param array $link data used in template, either from parameters or from the data store
340 */
341 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
342 {
343 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
344 if ($this->container->conf->get('formatter') === 'markdown') {
345 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
346 }
347
348 $data = escape([
349 'link' => $link,
350 'link_is_new' => $isNew,
351 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
352 'source' => $request->getParam('source') ?? '',
353 'tags' => $tags,
354 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
355 ]);
356
357 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
358
359 foreach ($data as $key => $value) {
360 $this->assignView($key, $value);
361 }
362
363 $editLabel = false === $isNew ? t('Edit') .' ' : '';
364 $this->assignView(
365 'pagetitle',
366 $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
367 );
368
369 return $response->write($this->render(TemplatePage::EDIT_LINK));
370 }
371}
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
index 2065c3e2..8675a0c5 100644
--- a/application/front/controller/admin/ManageTagController.php
+++ b/application/front/controller/admin/ManageTagController.php
@@ -24,9 +24,15 @@ class ManageTagController extends ShaarliAdminController
24 $fromTag = $request->getParam('fromtag') ?? ''; 24 $fromTag = $request->getParam('fromtag') ?? '';
25 25
26 $this->assignView('fromtag', escape($fromTag)); 26 $this->assignView('fromtag', escape($fromTag));
27 $separator = escape($this->container->conf->get('general.tags_separator', ' '));
28 if ($separator === ' ') {
29 $separator = '&nbsp;';
30 $this->assignView('tags_separator_desc', t('whitespace'));
31 }
32 $this->assignView('tags_separator', $separator);
27 $this->assignView( 33 $this->assignView(
28 'pagetitle', 34 'pagetitle',
29 t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') 35 t('Manage tags') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
30 ); 36 );
31 37
32 return $response->write($this->render(TemplatePage::CHANGE_TAG)); 38 return $response->write($this->render(TemplatePage::CHANGE_TAG));
@@ -81,8 +87,35 @@ class ManageTagController extends ShaarliAdminController
81 87
82 $this->saveSuccessMessage($alert); 88 $this->saveSuccessMessage($alert);
83 89
84 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag); 90 $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags=' . urlencode($toTag);
85 91
86 return $this->redirect($response, $redirect); 92 return $this->redirect($response, $redirect);
87 } 93 }
94
95 /**
96 * POST /admin/tags/change-separator - Change tag separator
97 */
98 public function changeSeparator(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 $reservedCharacters = ['-', '.', '*'];
103 $newSeparator = $request->getParam('separator');
104 if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
105 $this->saveErrorMessage(t('Tags separator must be a single character.'));
106 } elseif (in_array($newSeparator, $reservedCharacters, true)) {
107 $reservedCharacters = implode(' ', array_map(function (string $character) {
108 return '<code>' . $character . '</code>';
109 }, $reservedCharacters));
110 $this->saveErrorMessage(
111 t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
112 );
113 } else {
114 $this->container->conf->set('general.tags_separator', $newSeparator, true, true);
115
116 $this->saveSuccessMessage('Your tags separator setting has been updated!');
117 }
118
119 return $this->redirect($response, '/admin/tags');
120 }
88} 121}
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644
index 00000000..ff845944
--- /dev/null
+++ b/application/front/controller/admin/MetadataController.php
@@ -0,0 +1,29 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Slim\Http\Request;
8use Slim\Http\Response;
9
10/**
11 * Controller used to retrieve/update bookmark's metadata.
12 */
13class MetadataController extends ShaarliAdminController
14{
15 /**
16 * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
17 */
18 public function ajaxRetrieveTitle(Request $request, Response $response): Response
19 {
20 $url = $request->getParam('url');
21
22 // Only try to extract metadata from URL with HTTP(s) scheme
23 if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
24 return $response->withJson($this->container->metadataRetriever->retrieve($url));
25 }
26
27 return $response->withJson([]);
28 }
29}
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php
index 5ec0d24b..4aaf1f82 100644
--- a/application/front/controller/admin/PasswordController.php
+++ b/application/front/controller/admin/PasswordController.php
@@ -25,7 +25,7 @@ class PasswordController extends ShaarliAdminController
25 25
26 $this->assignView( 26 $this->assignView(
27 'pagetitle', 27 'pagetitle',
28 t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli') 28 t('Change password') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
29 ); 29 );
30 } 30 }
31 31
@@ -78,7 +78,7 @@ class PasswordController extends ShaarliAdminController
78 78
79 // Save new password 79 // Save new password
80 // Salt renders rainbow-tables attacks useless. 80 // Salt renders rainbow-tables attacks useless.
81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand())); 81 $this->container->conf->set('credentials.salt', sha1(uniqid('', true) . '_' . mt_rand()));
82 $this->container->conf->set( 82 $this->container->conf->set(
83 'credentials.hash', 83 'credentials.hash',
84 sha1( 84 sha1(
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php
index 8e059681..ae47c1af 100644
--- a/application/front/controller/admin/PluginsController.php
+++ b/application/front/controller/admin/PluginsController.php
@@ -42,7 +42,7 @@ class PluginsController extends ShaarliAdminController
42 $this->assignView('disabledPlugins', $disabledPlugins); 42 $this->assignView('disabledPlugins', $disabledPlugins);
43 $this->assignView( 43 $this->assignView(
44 'pagetitle', 44 'pagetitle',
45 t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli') 45 t('Plugin Administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
46 ); 46 );
47 47
48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN)); 48 return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
@@ -64,7 +64,7 @@ class PluginsController extends ShaarliAdminController
64 unset($parameters['parameters_form']); 64 unset($parameters['parameters_form']);
65 unset($parameters['token']); 65 unset($parameters['token']);
66 foreach ($parameters as $param => $value) { 66 foreach ($parameters as $param => $value) {
67 $this->container->conf->set('plugins.'. $param, escape($value)); 67 $this->container->conf->set('plugins.' . $param, escape($value));
68 } 68 }
69 } else { 69 } else {
70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters)); 70 $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php
new file mode 100644
index 00000000..4b74f4a9
--- /dev/null
+++ b/application/front/controller/admin/ServerController.php
@@ -0,0 +1,101 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Helper\ApplicationUtils;
8use Shaarli\Helper\FileUtils;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12/**
13 * Slim controller used to handle Server administration page, and actions.
14 */
15class ServerController extends ShaarliAdminController
16{
17 /** @var string Cache type - main - by default pagecache/ and tmp/ */
18 protected const CACHE_MAIN = 'main';
19
20 /** @var string Cache type - thumbnails - by default cache/ */
21 protected const CACHE_THUMB = 'thumbnails';
22
23 /**
24 * GET /admin/server - Display page Server administration
25 */
26 public function index(Request $request, Response $response): Response
27 {
28 $releaseUrl = ApplicationUtils::$GITHUB_URL . '/releases/';
29 if ($this->container->conf->get('updates.check_updates', true)) {
30 $latestVersion = 'v' . ApplicationUtils::getVersion(
31 ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
32 );
33 $releaseUrl .= 'tag/' . $latestVersion;
34 } else {
35 $latestVersion = t('Check disabled');
36 }
37
38 $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
39 $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
40 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
41
42 $permissions = array_merge(
43 ApplicationUtils::checkResourcePermissions($this->container->conf),
44 ApplicationUtils::checkDatastoreMutex()
45 );
46
47 $this->assignView('php_version', PHP_VERSION);
48 $this->assignView('php_eol', format_date($phpEol, false));
49 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
50 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
51 $this->assignView('permissions', $permissions);
52 $this->assignView('release_url', $releaseUrl);
53 $this->assignView('latest_version', $latestVersion);
54 $this->assignView('current_version', $currentVersion);
55 $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
56 $this->assignView('index_url', index_url($this->container->environment));
57 $this->assignView('client_ip', client_ip_id($this->container->environment));
58 $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
59
60 $this->assignView(
61 'pagetitle',
62 t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
63 );
64
65 return $response->write($this->render('server'));
66 }
67
68 /**
69 * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
70 */
71 public function clearCache(Request $request, Response $response): Response
72 {
73 $exclude = ['.htaccess'];
74
75 if ($request->getQueryParam('type') === static::CACHE_THUMB) {
76 $folders = [$this->container->conf->get('resource.thumbnails_cache')];
77
78 $this->saveWarningMessage(
79 t('Thumbnails cache has been cleared.') . ' ' .
80 '<a href="' . $this->container->basePath . '/admin/thumbnails">' .
81 t('Please synchronize them.') .
82 '</a>'
83 );
84 } else {
85 $folders = [
86 $this->container->conf->get('resource.page_cache'),
87 $this->container->conf->get('resource.raintpl_tmp'),
88 ];
89
90 $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
91 }
92
93 // Make sure that we don't delete root cache folder
94 $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
95 foreach ($folders as $folder) {
96 FileUtils::clearFolder($folder, false, $exclude);
97 }
98
99 return $this->redirect($response, '/admin/server');
100 }
101}
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php
index d9a7a2e0..0917b6d2 100644
--- a/application/front/controller/admin/SessionFilterController.php
+++ b/application/front/controller/admin/SessionFilterController.php
@@ -45,6 +45,4 @@ class SessionFilterController extends ShaarliAdminController
45 45
46 return $this->redirectFromReferer($request, $response, ['visibility']); 46 return $this->redirectFromReferer($request, $response, ['visibility']);
47 } 47 }
48
49
50} 48}
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php
new file mode 100644
index 00000000..ab8e7f40
--- /dev/null
+++ b/application/front/controller/admin/ShaareAddController.php
@@ -0,0 +1,34 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Formatter\BookmarkMarkdownFormatter;
8use Shaarli\Render\TemplatePage;
9use Slim\Http\Request;
10use Slim\Http\Response;
11
12class ShaareAddController extends ShaarliAdminController
13{
14 /**
15 * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
16 */
17 public function addShaare(Request $request, Response $response): Response
18 {
19 $tags = $this->container->bookmarkService->bookmarksCountPerTag();
20 if ($this->container->conf->get('formatter') === 'markdown') {
21 $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
22 }
23
24 $this->assignView(
25 'pagetitle',
26 t('Shaare a new link') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
27 );
28 $this->assignView('tags', $tags);
29 $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
30 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
31
32 return $response->write($this->render(TemplatePage::ADDLINK));
33 }
34}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
new file mode 100644
index 00000000..35837baa
--- /dev/null
+++ b/application/front/controller/admin/ShaareManageController.php
@@ -0,0 +1,202 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11/**
12 * Class PostBookmarkController
13 *
14 * Slim controller used to handle Shaarli create or edit bookmarks.
15 */
16class ShaareManageController extends ShaarliAdminController
17{
18 /**
19 * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
20 */
21 public function deleteBookmark(Request $request, Response $response): Response
22 {
23 $this->checkToken($request);
24
25 $ids = escape(trim($request->getParam('id') ?? ''));
26 if (empty($ids) || strpos($ids, ' ') !== false) {
27 // multiple, space-separated ids provided
28 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
29 } else {
30 $ids = [$ids];
31 }
32
33 // assert at least one id is given
34 if (0 === count($ids)) {
35 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
36
37 return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
38 }
39
40 $formatter = $this->container->formatterFactory->getFormatter('raw');
41 $count = 0;
42 foreach ($ids as $id) {
43 try {
44 $bookmark = $this->container->bookmarkService->get((int) $id);
45 } catch (BookmarkNotFoundException $e) {
46 $this->saveErrorMessage(sprintf(
47 t('Bookmark with identifier %s could not be found.'),
48 $id
49 ));
50
51 continue;
52 }
53
54 $data = $formatter->format($bookmark);
55 $this->executePageHooks('delete_link', $data);
56 $this->container->bookmarkService->remove($bookmark, false);
57 ++$count;
58 }
59
60 if ($count > 0) {
61 $this->container->bookmarkService->save();
62 }
63
64 // If we are called from the bookmarklet, we must close the popup:
65 if ($request->getParam('source') === 'bookmarklet') {
66 return $response->write('<script>self.close();</script>');
67 }
68
69 // Don't redirect to permalink after deletion.
70 return $this->redirectFromReferer($request, $response, ['shaare/']);
71 }
72
73 /**
74 * GET /admin/shaare/visibility
75 *
76 * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
77 */
78 public function changeVisibility(Request $request, Response $response): Response
79 {
80 $this->checkToken($request);
81
82 $ids = trim(escape($request->getParam('id') ?? ''));
83 if (empty($ids) || strpos($ids, ' ') !== false) {
84 // multiple, space-separated ids provided
85 $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
86 } else {
87 // only a single id provided
88 $ids = [$ids];
89 }
90
91 // assert at least one id is given
92 if (0 === count($ids)) {
93 $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
94
95 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
96 }
97
98 // assert that the visibility is valid
99 $visibility = $request->getParam('newVisibility');
100 if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
101 $this->saveErrorMessage(t('Invalid visibility provided.'));
102
103 return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
104 } else {
105 $isPrivate = $visibility === 'private';
106 }
107
108 $formatter = $this->container->formatterFactory->getFormatter('raw');
109 $count = 0;
110
111 foreach ($ids as $id) {
112 try {
113 $bookmark = $this->container->bookmarkService->get((int) $id);
114 } catch (BookmarkNotFoundException $e) {
115 $this->saveErrorMessage(sprintf(
116 t('Bookmark with identifier %s could not be found.'),
117 $id
118 ));
119
120 continue;
121 }
122
123 $bookmark->setPrivate($isPrivate);
124
125 // To preserve backward compatibility with 3rd parties, plugins still use arrays
126 $data = $formatter->format($bookmark);
127 $this->executePageHooks('save_link', $data);
128 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
129
130 $this->container->bookmarkService->set($bookmark, false);
131 ++$count;
132 }
133
134 if ($count > 0) {
135 $this->container->bookmarkService->save();
136 }
137
138 return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
139 }
140
141 /**
142 * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
143 */
144 public function pinBookmark(Request $request, Response $response, array $args): Response
145 {
146 $this->checkToken($request);
147
148 $id = $args['id'] ?? '';
149 try {
150 if (false === ctype_digit($id)) {
151 throw new BookmarkNotFoundException();
152 }
153 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
154 } catch (BookmarkNotFoundException $e) {
155 $this->saveErrorMessage(sprintf(
156 t('Bookmark with identifier %s could not be found.'),
157 $id
158 ));
159
160 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
161 }
162
163 $formatter = $this->container->formatterFactory->getFormatter('raw');
164
165 $bookmark->setSticky(!$bookmark->isSticky());
166
167 // To preserve backward compatibility with 3rd parties, plugins still use arrays
168 $data = $formatter->format($bookmark);
169 $this->executePageHooks('save_link', $data);
170 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
171
172 $this->container->bookmarkService->set($bookmark);
173
174 return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
175 }
176
177 /**
178 * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
179 */
180 public function sharePrivate(Request $request, Response $response, array $args): Response
181 {
182 $this->checkToken($request);
183
184 $hash = $args['hash'] ?? '';
185 $bookmark = $this->container->bookmarkService->findByHash($hash);
186
187 if ($bookmark->isPrivate() !== true) {
188 return $this->redirect($response, '/shaare/' . $hash);
189 }
190
191 if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
192 $privateKey = bin2hex(random_bytes(16));
193 $bookmark->addAdditionalContentEntry('private_key', $privateKey);
194 $this->container->bookmarkService->set($bookmark);
195 }
196
197 return $this->redirect(
198 $response,
199 '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
200 );
201 }
202}
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
new file mode 100644
index 00000000..fb9cacc2
--- /dev/null
+++ b/application/front/controller/admin/ShaarePublishController.php
@@ -0,0 +1,274 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\BookmarkMarkdownFormatter;
11use Shaarli\Render\TemplatePage;
12use Shaarli\Thumbnailer;
13use Slim\Http\Request;
14use Slim\Http\Response;
15
16class ShaarePublishController extends ShaarliAdminController
17{
18 /**
19 * @var BookmarkFormatter[] Statically cached instances of formatters
20 */
21 protected $formatters = [];
22
23 /**
24 * @var array Statically cached bookmark's tags counts
25 */
26 protected $tags;
27
28 /**
29 * GET /admin/shaare - Displays the bookmark form for creation.
30 * Note that if the URL is found in existing bookmarks, then it will be in edit mode.
31 */
32 public function displayCreateForm(Request $request, Response $response): Response
33 {
34 $url = cleanup_url($request->getParam('post'));
35 $link = $this->buildLinkDataFromUrl($request, $url);
36
37 return $this->displayForm($link, $link['linkIsNew'], $request, $response);
38 }
39
40 /**
41 * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
42 */
43 public function displayCreateBatchForms(Request $request, Response $response): Response
44 {
45 $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
46
47 $links = [];
48 foreach ($urls as $url) {
49 if (empty($url)) {
50 continue;
51 }
52 $link = $this->buildLinkDataFromUrl($request, $url);
53 $data = $this->buildFormData($link, $link['linkIsNew'], $request);
54 $data['token'] = $this->container->sessionManager->generateToken();
55 $data['source'] = 'batch';
56
57 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
58
59 $links[] = $data;
60 }
61
62 $this->assignView('links', $links);
63 $this->assignView('batch_mode', true);
64 $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
65
66 return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
67 }
68
69 /**
70 * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
71 */
72 public function displayEditForm(Request $request, Response $response, array $args): Response
73 {
74 $id = $args['id'] ?? '';
75 try {
76 if (false === ctype_digit($id)) {
77 throw new BookmarkNotFoundException();
78 }
79 $bookmark = $this->container->bookmarkService->get((int) $id); // Read database
80 } catch (BookmarkNotFoundException $e) {
81 $this->saveErrorMessage(sprintf(
82 t('Bookmark with identifier %s could not be found.'),
83 $id
84 ));
85
86 return $this->redirect($response, '/');
87 }
88
89 $formatter = $this->getFormatter('raw');
90 $link = $formatter->format($bookmark);
91
92 return $this->displayForm($link, false, $request, $response);
93 }
94
95 /**
96 * POST /admin/shaare
97 */
98 public function save(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 // lf_id should only be present if the link exists.
103 $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
104 if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
105 // Edit
106 $bookmark = $this->container->bookmarkService->get($id);
107 } else {
108 // New link
109 $bookmark = new Bookmark();
110 }
111
112 $bookmark->setTitle($request->getParam('lf_title'));
113 $bookmark->setDescription($request->getParam('lf_description'));
114 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
115 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
116 $bookmark->setTagsString(
117 $request->getParam('lf_tags'),
118 $this->container->conf->get('general.tags_separator', ' ')
119 );
120
121 if (
122 $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
123 && true !== $this->container->conf->get('general.enable_async_metadata', true)
124 && $bookmark->shouldUpdateThumbnail()
125 ) {
126 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
127 }
128 $this->container->bookmarkService->addOrSet($bookmark, false);
129
130 // To preserve backward compatibility with 3rd parties, plugins still use arrays
131 $formatter = $this->getFormatter('raw');
132 $data = $formatter->format($bookmark);
133 $this->executePageHooks('save_link', $data);
134
135 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
136 $this->container->bookmarkService->set($bookmark);
137
138 // If we are called from the bookmarklet, we must close the popup:
139 if ($request->getParam('source') === 'bookmarklet') {
140 return $response->write('<script>self.close();</script>');
141 } elseif ($request->getParam('source') === 'batch') {
142 return $response;
143 }
144
145 if (!empty($request->getParam('returnurl'))) {
146 $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
147 }
148
149 return $this->redirectFromReferer(
150 $request,
151 $response,
152 ['/admin/add-shaare', '/admin/shaare'],
153 ['addlink', 'post', 'edit_link'],
154 $bookmark->getShortUrl()
155 );
156 }
157
158 /**
159 * Helper function used to display the shaare form whether it's a new or existing bookmark.
160 *
161 * @param array $link data used in template, either from parameters or from the data store
162 */
163 protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
164 {
165 $data = $this->buildFormData($link, $isNew, $request);
166
167 $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
168
169 foreach ($data as $key => $value) {
170 $this->assignView($key, $value);
171 }
172
173 $editLabel = false === $isNew ? t('Edit') . ' ' : '';
174 $this->assignView(
175 'pagetitle',
176 $editLabel . t('Shaare') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
177 );
178
179 return $response->write($this->render(TemplatePage::EDIT_LINK));
180 }
181
182 protected function buildLinkDataFromUrl(Request $request, string $url): array
183 {
184 // Check if URL is not already in database (in this case, we will edit the existing link)
185 $bookmark = $this->container->bookmarkService->findByUrl($url);
186 if (null === $bookmark) {
187 // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
188 $title = $request->getParam('title');
189 $description = $request->getParam('description');
190 $tags = $request->getParam('tags');
191 if ($request->getParam('private') !== null) {
192 $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
193 } else {
194 $private = $this->container->conf->get('privacy.default_private_links', false);
195 }
196
197 // If this is an HTTP(S) link, we try go get the page to extract
198 // the title (otherwise we will to straight to the edit form.)
199 if (
200 true !== $this->container->conf->get('general.enable_async_metadata', true)
201 && empty($title)
202 && strpos(get_url_scheme($url) ?: '', 'http') !== false
203 ) {
204 $metadata = $this->container->metadataRetriever->retrieve($url);
205 }
206
207 if (empty($url)) {
208 $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
209 }
210
211 return [
212 'title' => $title ?? $metadata['title'] ?? '',
213 'url' => $url ?? '',
214 'description' => $description ?? $metadata['description'] ?? '',
215 'tags' => $tags ?? $metadata['tags'] ?? '',
216 'private' => $private,
217 'linkIsNew' => true,
218 ];
219 }
220
221 $formatter = $this->getFormatter('raw');
222 $link = $formatter->format($bookmark);
223 $link['linkIsNew'] = false;
224
225 return $link;
226 }
227
228 protected function buildFormData(array $link, bool $isNew, Request $request): array
229 {
230 $link['tags'] = $link['tags'] !== null && strlen($link['tags']) > 0
231 ? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
232 : $link['tags']
233 ;
234
235 return escape([
236 'link' => $link,
237 'link_is_new' => $isNew,
238 'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
239 'source' => $request->getParam('source') ?? '',
240 'tags' => $this->getTags(),
241 'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
242 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
243 'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
244 ]);
245 }
246
247 /**
248 * Memoize formatterFactory->getFormatter() calls.
249 */
250 protected function getFormatter(string $type): BookmarkFormatter
251 {
252 if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
253 $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
254 }
255
256 return $this->formatters[$type];
257 }
258
259 /**
260 * Memoize bookmarkService->bookmarksCountPerTag() calls.
261 */
262 protected function getTags(): array
263 {
264 if ($this->tags === null) {
265 $this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
266
267 if ($this->container->conf->get('formatter') === 'markdown') {
268 $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
269 }
270 }
271
272 return $this->tags;
273 }
274}
diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php
index 4dc09d38..94d97d4b 100644
--- a/application/front/controller/admin/ThumbnailsController.php
+++ b/application/front/controller/admin/ThumbnailsController.php
@@ -34,7 +34,7 @@ class ThumbnailsController extends ShaarliAdminController
34 $this->assignView('ids', $ids); 34 $this->assignView('ids', $ids);
35 $this->assignView( 35 $this->assignView(
36 'pagetitle', 36 'pagetitle',
37 t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli') 37 t('Thumbnails update') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
38 ); 38 );
39 39
40 return $response->write($this->render(TemplatePage::THUMBNAILS)); 40 return $response->write($this->render(TemplatePage::THUMBNAILS));
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php
index a87f20d2..560e5e3e 100644
--- a/application/front/controller/admin/ToolsController.php
+++ b/application/front/controller/admin/ToolsController.php
@@ -28,7 +28,7 @@ class ToolsController extends ShaarliAdminController
28 $this->assignView($key, $value); 28 $this->assignView($key, $value);
29 } 29 }
30 30
31 $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli')); 31 $this->assignView('pagetitle', t('Tools') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'));
32 32
33 return $response->write($this->render(TemplatePage::TOOLS)); 33 return $response->write($this->render(TemplatePage::TOOLS));
34 } 34 }
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php
index 18368751..fe8231be 100644
--- a/application/front/controller/visitor/BookmarkListController.php
+++ b/application/front/controller/visitor/BookmarkListController.php
@@ -35,7 +35,8 @@ class BookmarkListController extends ShaarliVisitorController
35 $formatter->addContextData('base_path', $this->container->basePath); 35 $formatter->addContextData('base_path', $this->container->basePath);
36 36
37 $searchTags = normalize_spaces($request->getParam('searchtags') ?? ''); 37 $searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
38 $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));; 38 $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));
39 ;
39 40
40 // Filter bookmarks according search parameters. 41 // Filter bookmarks according search parameters.
41 $visibility = $this->container->sessionManager->getSessionParameter('visibility'); 42 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
@@ -95,6 +96,10 @@ class BookmarkListController extends ShaarliVisitorController
95 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; 96 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
96 } 97 }
97 98
99 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
100 $searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
101 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
102
98 // Fill all template fields. 103 // Fill all template fields.
99 $data = array_merge( 104 $data = array_merge(
100 $this->initializeTemplateVars(), 105 $this->initializeTemplateVars(),
@@ -106,7 +111,7 @@ class BookmarkListController extends ShaarliVisitorController
106 'result_count' => count($linksToDisplay), 111 'result_count' => count($linksToDisplay),
107 'search_term' => escape($searchTerm), 112 'search_term' => escape($searchTerm),
108 'search_tags' => escape($searchTags), 113 'search_tags' => escape($searchTags),
109 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), 114 'search_tags_url' => $searchTagsUrlEncoded,
110 'visibility' => $visibility, 115 'visibility' => $visibility,
111 'links' => $linkDisp, 116 'links' => $linkDisp,
112 ] 117 ]
@@ -119,8 +124,9 @@ class BookmarkListController extends ShaarliVisitorController
119 return '[' . $tag . ']'; 124 return '[' . $tag . ']';
120 }; 125 };
121 $data['pagetitle'] .= ! empty($searchTags) 126 $data['pagetitle'] .= ! empty($searchTags)
122 ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' 127 ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
123 : ''; 128 : ''
129 ;
124 $data['pagetitle'] .= '- '; 130 $data['pagetitle'] .= '- ';
125 } 131 }
126 132
@@ -137,8 +143,10 @@ class BookmarkListController extends ShaarliVisitorController
137 */ 143 */
138 public function permalink(Request $request, Response $response, array $args): Response 144 public function permalink(Request $request, Response $response, array $args): Response
139 { 145 {
146 $privateKey = $request->getParam('key');
147
140 try { 148 try {
141 $bookmark = $this->container->bookmarkService->findByHash($args['hash']); 149 $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
142 } catch (BookmarkNotFoundException $e) { 150 } catch (BookmarkNotFoundException $e) {
143 $this->assignView('error_message', $e->getMessage()); 151 $this->assignView('error_message', $e->getMessage());
144 152
@@ -153,7 +161,7 @@ class BookmarkListController extends ShaarliVisitorController
153 $data = array_merge( 161 $data = array_merge(
154 $this->initializeTemplateVars(), 162 $this->initializeTemplateVars(),
155 [ 163 [
156 'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'), 164 'pagetitle' => $bookmark->getTitle() . ' - ' . $this->container->conf->get('general.title', 'Shaarli'),
157 'links' => [$formatter->format($bookmark)], 165 'links' => [$formatter->format($bookmark)],
158 ] 166 ]
159 ); 167 );
@@ -169,19 +177,25 @@ class BookmarkListController extends ShaarliVisitorController
169 */ 177 */
170 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool 178 protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
171 { 179 {
172 // Logged in, thumbnails enabled, not a note, is HTTP 180 if (false === $this->container->loginManager->isLoggedIn()) {
173 // and (never retrieved yet or no valid cache file) 181 return false;
174 if ($this->container->loginManager->isLoggedIn() 182 }
175 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE 183
176 && false !== $bookmark->getThumbnail() 184 // If thumbnail should be updated, we reset it to null
177 && !$bookmark->isNote() 185 if ($bookmark->shouldUpdateThumbnail()) {
178 && (null === $bookmark->getThumbnail() || !is_file($bookmark->getThumbnail())) 186 $bookmark->setThumbnail(null);
179 && startsWith(strtolower($bookmark->getUrl()), 'http') 187
180 ) { 188 // Requires an update, not async retrieval, thumbnails enabled
181 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl())); 189 if (
182 $this->container->bookmarkService->set($bookmark, $writeDatastore); 190 $bookmark->shouldUpdateThumbnail()
183 191 && true !== $this->container->conf->get('general.enable_async_metadata', true)
184 return true; 192 && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
193 ) {
194 $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
195 $this->container->bookmarkService->set($bookmark, $writeDatastore);
196
197 return true;
198 }
185 } 199 }
186 200
187 return false; 201 return false;
@@ -198,6 +212,7 @@ class BookmarkListController extends ShaarliVisitorController
198 'page_max' => '', 212 'page_max' => '',
199 'search_tags' => '', 213 'search_tags' => '',
200 'result_count' => '', 214 'result_count' => '',
215 'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
201 ]; 216 ];
202 } 217 }
203 218
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php
index 07617cf1..29492a5f 100644
--- a/application/front/controller/visitor/DailyController.php
+++ b/application/front/controller/visitor/DailyController.php
@@ -5,8 +5,8 @@ declare(strict_types=1);
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use DateTime; 7use DateTime;
8use DateTimeImmutable;
9use Shaarli\Bookmark\Bookmark; 8use Shaarli\Bookmark\Bookmark;
9use Shaarli\Helper\DailyPageHelper;
10use Shaarli\Render\TemplatePage; 10use Shaarli\Render\TemplatePage;
11use Slim\Http\Request; 11use Slim\Http\Request;
12use Slim\Http\Response; 12use Slim\Http\Response;
@@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
26 */ 26 */
27 public function index(Request $request, Response $response): Response 27 public function index(Request $request, Response $response): Response
28 { 28 {
29 $day = $request->getQueryParam('day') ?? date('Ymd'); 29 $type = DailyPageHelper::extractRequestedType($request);
30 30 $format = DailyPageHelper::getFormatByType($type);
31 $availableDates = $this->container->bookmarkService->days(); 31 $latestBookmark = $this->container->bookmarkService->getLatest();
32 $nbAvailableDates = count($availableDates); 32 $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
33 $index = array_search($day, $availableDates); 33 $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
34 34 $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
35 if ($index === false) { 35 $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
36 // no bookmarks for day, but at least one day with bookmarks 36
37 $day = $availableDates[$nbAvailableDates - 1] ?? $day; 37 $linksToDisplay = $this->container->bookmarkService->findByDate(
38 $previousDay = $availableDates[$nbAvailableDates - 2] ?? ''; 38 $start,
39 } else { 39 $end,
40 $previousDay = $availableDates[$index - 1] ?? ''; 40 $previousDay,
41 $nextDay = $availableDates[$index + 1] ?? ''; 41 $nextDay
42 } 42 );
43
44 if ($day === date('Ymd')) {
45 $this->assignView('dayDesc', t('Today'));
46 } elseif ($day === date('Ymd', strtotime('-1 days'))) {
47 $this->assignView('dayDesc', t('Yesterday'));
48 }
49
50 try {
51 $linksToDisplay = $this->container->bookmarkService->filterDay($day);
52 } catch (\Exception $exc) {
53 $linksToDisplay = [];
54 }
55 43
56 $formatter = $this->container->formatterFactory->getFormatter(); 44 $formatter = $this->container->formatterFactory->getFormatter();
57 $formatter->addContextData('base_path', $this->container->basePath); 45 $formatter->addContextData('base_path', $this->container->basePath);
@@ -63,13 +51,15 @@ class DailyController extends ShaarliVisitorController
63 $linksToDisplay[$key]['description'] = $bookmark->getDescription(); 51 $linksToDisplay[$key]['description'] = $bookmark->getDescription();
64 } 52 }
65 53
66 $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
67 $data = [ 54 $data = [
68 'linksToDisplay' => $linksToDisplay, 55 'linksToDisplay' => $linksToDisplay,
69 'day' => $dayDate->getTimestamp(), 56 'dayDate' => $start,
70 'dayDate' => $dayDate, 57 'day' => $start->getTimestamp(),
71 'previousday' => $previousDay ?? '', 58 'previousday' => $previousDay ? $previousDay->format($format) : '',
72 'nextday' => $nextDay ?? '', 59 'nextday' => $nextDay ? $nextDay->format($format) : '',
60 'dayDesc' => $dailyDesc,
61 'type' => $type,
62 'localizedType' => $this->translateType($type),
73 ]; 63 ];
74 64
75 // Hooks are called before column construction so that plugins don't have to deal with columns. 65 // Hooks are called before column construction so that plugins don't have to deal with columns.
@@ -82,7 +72,7 @@ class DailyController extends ShaarliVisitorController
82 $mainTitle = $this->container->conf->get('general.title', 'Shaarli'); 72 $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
83 $this->assignView( 73 $this->assignView(
84 'pagetitle', 74 'pagetitle',
85 t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle 75 $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
86 ); 76 );
87 77
88 return $response->write($this->render(TemplatePage::DAILY)); 78 return $response->write($this->render(TemplatePage::DAILY));
@@ -96,9 +86,11 @@ class DailyController extends ShaarliVisitorController
96 public function rss(Request $request, Response $response): Response 86 public function rss(Request $request, Response $response): Response
97 { 87 {
98 $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8'); 88 $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
89 $type = DailyPageHelper::extractRequestedType($request);
90 $cacheDuration = DailyPageHelper::getCacheDatePeriodByType($type);
99 91
100 $pageUrl = page_url($this->container->environment); 92 $pageUrl = page_url($this->container->environment);
101 $cache = $this->container->pageCacheManager->getCachePage($pageUrl); 93 $cache = $this->container->pageCacheManager->getCachePage($pageUrl, $cacheDuration);
102 94
103 $cached = $cache->cachedVersion(); 95 $cached = $cache->cachedVersion();
104 if (!empty($cached)) { 96 if (!empty($cached)) {
@@ -106,11 +98,13 @@ class DailyController extends ShaarliVisitorController
106 } 98 }
107 99
108 $days = []; 100 $days = [];
101 $format = DailyPageHelper::getFormatByType($type);
102 $length = DailyPageHelper::getRssLengthByType($type);
109 foreach ($this->container->bookmarkService->search() as $bookmark) { 103 foreach ($this->container->bookmarkService->search() as $bookmark) {
110 $day = $bookmark->getCreated()->format('Ymd'); 104 $day = $bookmark->getCreated()->format($format);
111 105
112 // Stop iterating after DAILY_RSS_NB_DAYS entries 106 // Stop iterating after DAILY_RSS_NB_DAYS entries
113 if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) { 107 if (count($days) === $length && !isset($days[$day])) {
114 break; 108 break;
115 } 109 }
116 110
@@ -127,12 +121,19 @@ class DailyController extends ShaarliVisitorController
127 121
128 /** @var Bookmark[] $bookmarks */ 122 /** @var Bookmark[] $bookmarks */
129 foreach ($days as $day => $bookmarks) { 123 foreach ($days as $day => $bookmarks) {
130 $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); 124 $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
125 $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
126
127 // We only want the RSS entry to be published when the period is over.
128 if (new DateTime() < $endDateTime) {
129 continue;
130 }
131
131 $dataPerDay[$day] = [ 132 $dataPerDay[$day] = [
132 'date' => $dayDatetime, 133 'date' => $endDateTime,
133 'date_rss' => $dayDatetime->format(DateTime::RSS), 134 'date_rss' => $endDateTime->format(DateTime::RSS),
134 'date_human' => format_date($dayDatetime, false, true), 135 'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime, false),
135 'absolute_url' => $indexUrl . 'daily?day=' . $day, 136 'absolute_url' => $indexUrl . 'daily?' . $type . '=' . $day,
136 'links' => [], 137 'links' => [],
137 ]; 138 ];
138 139
@@ -141,16 +142,20 @@ class DailyController extends ShaarliVisitorController
141 142
142 // Make permalink URL absolute 143 // Make permalink URL absolute
143 if ($bookmark->isNote()) { 144 if ($bookmark->isNote()) {
144 $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl(); 145 $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
145 } 146 }
146 } 147 }
147 } 148 }
148 149
149 $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli')); 150 $this->assignAllView([
150 $this->assignView('index_url', $indexUrl); 151 'title' => $this->container->conf->get('general.title', 'Shaarli'),
151 $this->assignView('page_url', $pageUrl); 152 'index_url' => $indexUrl,
152 $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false)); 153 'page_url' => $pageUrl,
153 $this->assignView('days', $dataPerDay); 154 'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
155 'days' => $dataPerDay,
156 'type' => $type,
157 'localizedType' => $this->translateType($type),
158 ]);
154 159
155 $rssContent = $this->render(TemplatePage::DAILY_RSS); 160 $rssContent = $this->render(TemplatePage::DAILY_RSS);
156 161
@@ -189,4 +194,13 @@ class DailyController extends ShaarliVisitorController
189 194
190 return $columns; 195 return $columns;
191 } 196 }
197
198 protected function translateType($type): string
199 {
200 return [
201 t('day') => t('Daily'),
202 t('week') => t('Weekly'),
203 t('month') => t('Monthly'),
204 ][t($type)] ?? t('Daily');
205 }
192} 206}
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php
index 10aa84c8..428e8254 100644
--- a/application/front/controller/visitor/ErrorController.php
+++ b/application/front/controller/visitor/ErrorController.php
@@ -26,12 +26,15 @@ class ErrorController extends ShaarliVisitorController
26 $response = $response->withStatus($throwable->getCode()); 26 $response = $response->withStatus($throwable->getCode());
27 } else { 27 } else {
28 // Internal error (any other Throwable) 28 // Internal error (any other Throwable)
29 if ($this->container->conf->get('dev.debug', false)) { 29 if ($this->container->conf->get('dev.debug', false) || $this->container->loginManager->isLoggedIn()) {
30 $this->assignView('message', $throwable->getMessage()); 30 $this->assignView('message', t('Error: ') . $throwable->getMessage());
31 $this->assignView( 31 $this->assignView(
32 'stacktrace', 32 'text',
33 nl2br(get_class($throwable) .': '. PHP_EOL . $throwable->getTraceAsString()) 33 '<a href="https://github.com/shaarli/Shaarli/issues/new">'
34 . t('Please report it on Github.')
35 . '</a>'
34 ); 36 );
37 $this->assignView('stacktrace', exception2text($throwable));
35 } else { 38 } else {
36 $this->assignView('message', t('An unexpected error occurred.')); 39 $this->assignView('message', t('An unexpected error occurred.'));
37 } 40 }
@@ -39,7 +42,6 @@ class ErrorController extends ShaarliVisitorController
39 $response = $response->withStatus(500); 42 $response = $response->withStatus(500);
40 } 43 }
41 44
42
43 return $response->write($this->render('error')); 45 return $response->write($this->render('error'));
44 } 46 }
45} 47}
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php
index 8d8b546a..edc7ef43 100644
--- a/application/front/controller/visitor/FeedController.php
+++ b/application/front/controller/visitor/FeedController.php
@@ -27,7 +27,7 @@ class FeedController extends ShaarliVisitorController
27 27
28 protected function processRequest(string $feedType, Request $request, Response $response): Response 28 protected function processRequest(string $feedType, Request $request, Response $response): Response
29 { 29 {
30 $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8'); 30 $response = $response->withHeader('Content-Type', 'application/' . $feedType . '+xml; charset=utf-8');
31 31
32 $pageUrl = page_url($this->container->environment); 32 $pageUrl = page_url($this->container->environment);
33 $cache = $this->container->pageCacheManager->getCachePage($pageUrl); 33 $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php
index 7cb32777..418d4a49 100644
--- a/application/front/controller/visitor/InstallController.php
+++ b/application/front/controller/visitor/InstallController.php
@@ -4,10 +4,10 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Visitor; 5namespace Shaarli\Front\Controller\Visitor;
6 6
7use Shaarli\ApplicationUtils;
8use Shaarli\Container\ShaarliContainer; 7use Shaarli\Container\ShaarliContainer;
9use Shaarli\Front\Exception\AlreadyInstalledException; 8use Shaarli\Front\Exception\AlreadyInstalledException;
10use Shaarli\Front\Exception\ResourcePermissionException; 9use Shaarli\Front\Exception\ResourcePermissionException;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Languages; 11use Shaarli\Languages;
12use Shaarli\Security\SessionManager; 12use Shaarli\Security\SessionManager;
13use Slim\Http\Request; 13use Slim\Http\Request;
@@ -39,7 +39,8 @@ class InstallController extends ShaarliVisitorController
39 // Before installation, we'll make sure that permissions are set properly, and sessions are working. 39 // Before installation, we'll make sure that permissions are set properly, and sessions are working.
40 $this->checkPermissions(); 40 $this->checkPermissions();
41 41
42 if (static::SESSION_TEST_VALUE 42 if (
43 static::SESSION_TEST_VALUE
43 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) 44 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
44 ) { 45 ) {
45 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE); 46 $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
@@ -53,6 +54,21 @@ class InstallController extends ShaarliVisitorController
53 $this->assignView('cities', $cities); 54 $this->assignView('cities', $cities);
54 $this->assignView('languages', Languages::getAvailableLanguages()); 55 $this->assignView('languages', Languages::getAvailableLanguages());
55 56
57 $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
58
59 $permissions = array_merge(
60 ApplicationUtils::checkResourcePermissions($this->container->conf),
61 ApplicationUtils::checkDatastoreMutex()
62 );
63
64 $this->assignView('php_version', PHP_VERSION);
65 $this->assignView('php_eol', format_date($phpEol, false));
66 $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
67 $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
68 $this->assignView('permissions', $permissions);
69
70 $this->assignView('pagetitle', t('Install Shaarli'));
71
56 return $response->write($this->render('install')); 72 return $response->write($this->render('install'));
57 } 73 }
58 74
@@ -65,17 +81,18 @@ class InstallController extends ShaarliVisitorController
65 // This part makes sure sessions works correctly. 81 // This part makes sure sessions works correctly.
66 // (Because on some hosts, session.save_path may not be set correctly, 82 // (Because on some hosts, session.save_path may not be set correctly,
67 // or we may not have write access to it.) 83 // or we may not have write access to it.)
68 if (static::SESSION_TEST_VALUE 84 if (
85 static::SESSION_TEST_VALUE
69 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY) 86 !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
70 ) { 87 ) {
71 // Step 2: Check if data in session is correct. 88 // Step 2: Check if data in session is correct.
72 $msg = t( 89 $msg = t(
73 '<pre>Sessions do not seem to work correctly on your server.<br>'. 90 '<pre>Sessions do not seem to work correctly on your server.<br>' .
74 'Make sure the variable "session.save_path" is set correctly in your PHP config, '. 91 'Make sure the variable "session.save_path" is set correctly in your PHP config, ' .
75 'and that you have write access to it.<br>'. 92 'and that you have write access to it.<br>' .
76 'It currently points to %s.<br>'. 93 'It currently points to %s.<br>' .
77 'On some browsers, accessing your server via a hostname like \'localhost\' '. 94 'On some browsers, accessing your server via a hostname like \'localhost\' ' .
78 'or any custom hostname without a dot causes cookie storage to fail. '. 95 'or any custom hostname without a dot causes cookie storage to fail. ' .
79 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>' 96 'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
80 ); 97 );
81 $msg = sprintf($msg, $this->container->sessionManager->getSavePath()); 98 $msg = sprintf($msg, $this->container->sessionManager->getSavePath());
@@ -94,7 +111,8 @@ class InstallController extends ShaarliVisitorController
94 public function save(Request $request, Response $response): Response 111 public function save(Request $request, Response $response): Response
95 { 112 {
96 $timezone = 'UTC'; 113 $timezone = 'UTC';
97 if (!empty($request->getParam('continent')) 114 if (
115 !empty($request->getParam('continent'))
98 && !empty($request->getParam('city')) 116 && !empty($request->getParam('city'))
99 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city')) 117 && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
100 ) { 118 ) {
@@ -104,7 +122,7 @@ class InstallController extends ShaarliVisitorController
104 122
105 $login = $request->getParam('setlogin'); 123 $login = $request->getParam('setlogin');
106 $this->container->conf->set('credentials.login', $login); 124 $this->container->conf->set('credentials.login', $login);
107 $salt = sha1(uniqid('', true) .'_'. mt_rand()); 125 $salt = sha1(uniqid('', true) . '_' . mt_rand());
108 $this->container->conf->set('credentials.salt', $salt); 126 $this->container->conf->set('credentials.salt', $salt);
109 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt)); 127 $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
110 128
@@ -113,7 +131,7 @@ class InstallController extends ShaarliVisitorController
113 } else { 131 } else {
114 $this->container->conf->set( 132 $this->container->conf->set(
115 'general.title', 133 'general.title',
116 'Shared bookmarks on '.escape(index_url($this->container->environment)) 134 'Shared bookmarks on ' . escape(index_url($this->container->environment))
117 ); 135 );
118 } 136 }
119 137
@@ -150,7 +168,7 @@ class InstallController extends ShaarliVisitorController
150 protected function checkPermissions(): bool 168 protected function checkPermissions(): bool
151 { 169 {
152 // Ensure Shaarli has proper access to its resources 170 // Ensure Shaarli has proper access to its resources
153 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf); 171 $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
154 if (empty($errors)) { 172 if (empty($errors)) {
155 return true; 173 return true;
156 } 174 }
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php
index 121ba40b..4b881535 100644
--- a/application/front/controller/visitor/LoginController.php
+++ b/application/front/controller/visitor/LoginController.php
@@ -43,7 +43,7 @@ class LoginController extends ShaarliVisitorController
43 $this 43 $this
44 ->assignView('returnurl', escape($returnUrl)) 44 ->assignView('returnurl', escape($returnUrl))
45 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true)) 45 ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
46 ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli')) 46 ->assignView('pagetitle', t('Login') . ' - ' . $this->container->conf->get('general.title', 'Shaarli'))
47 ; 47 ;
48 48
49 return $response->write($this->render(TemplatePage::LOGIN)); 49 return $response->write($this->render(TemplatePage::LOGIN));
@@ -64,8 +64,8 @@ class LoginController extends ShaarliVisitorController
64 return $this->redirect($response, '/'); 64 return $this->redirect($response, '/');
65 } 65 }
66 66
67 if (!$this->container->loginManager->checkCredentials( 67 if (
68 $this->container->environment['REMOTE_ADDR'], 68 !$this->container->loginManager->checkCredentials(
69 client_ip_id($this->container->environment), 69 client_ip_id($this->container->environment),
70 $request->getParam('login'), 70 $request->getParam('login'),
71 $request->getParam('password') 71 $request->getParam('password')
@@ -102,7 +102,8 @@ class LoginController extends ShaarliVisitorController
102 */ 102 */
103 protected function checkLoginState(): bool 103 protected function checkLoginState(): bool
104 { 104 {
105 if ($this->container->loginManager->isLoggedIn() 105 if (
106 $this->container->loginManager->isLoggedIn()
106 || $this->container->conf->get('security.open_shaarli', false) 107 || $this->container->conf->get('security.open_shaarli', false)
107 ) { 108 ) {
108 throw new CantLoginException(); 109 throw new CantLoginException();
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php
index 3c57f8dd..23553ee6 100644
--- a/application/front/controller/visitor/PictureWallController.php
+++ b/application/front/controller/visitor/PictureWallController.php
@@ -26,7 +26,7 @@ class PictureWallController extends ShaarliVisitorController
26 26
27 $this->assignView( 27 $this->assignView(
28 'pagetitle', 28 'pagetitle',
29 t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli') 29 t('Picture wall') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
30 ); 30 );
31 31
32 // Optionally filter the results: 32 // Optionally filter the results:
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php
index 54f9fe03..ae946c59 100644
--- a/application/front/controller/visitor/ShaarliVisitorController.php
+++ b/application/front/controller/visitor/ShaarliVisitorController.php
@@ -144,7 +144,8 @@ abstract class ShaarliVisitorController
144 if (null !== $referer) { 144 if (null !== $referer) {
145 $currentUrl = parse_url($referer); 145 $currentUrl = parse_url($referer);
146 // If the referer is not related to Shaarli instance, redirect to default 146 // If the referer is not related to Shaarli instance, redirect to default
147 if (isset($currentUrl['host']) 147 if (
148 isset($currentUrl['host'])
148 && strpos(index_url($this->container->environment), $currentUrl['host']) === false 149 && strpos(index_url($this->container->environment), $currentUrl['host']) === false
149 ) { 150 ) {
150 return $response->withRedirect($defaultPath); 151 return $response->withRedirect($defaultPath);
@@ -173,7 +174,7 @@ abstract class ShaarliVisitorController
173 } 174 }
174 } 175 }
175 176
176 $queryString = count($params) > 0 ? '?'. http_build_query($params) : ''; 177 $queryString = count($params) > 0 ? '?' . http_build_query($params) : '';
177 $anchor = $anchor ? '#' . $anchor : ''; 178 $anchor = $anchor ? '#' . $anchor : '';
178 179
179 return $response->withRedirect($path . $queryString . $anchor); 180 return $response->withRedirect($path . $queryString . $anchor);
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php
index 76ed7690..46d62779 100644
--- a/application/front/controller/visitor/TagCloudController.php
+++ b/application/front/controller/visitor/TagCloudController.php
@@ -47,13 +47,14 @@ class TagCloudController extends ShaarliVisitorController
47 */ 47 */
48 protected function processRequest(string $type, Request $request, Response $response): Response 48 protected function processRequest(string $type, Request $request, Response $response): Response
49 { 49 {
50 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
50 if ($this->container->loginManager->isLoggedIn() === true) { 51 if ($this->container->loginManager->isLoggedIn() === true) {
51 $visibility = $this->container->sessionManager->getSessionParameter('visibility'); 52 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
52 } 53 }
53 54
54 $sort = $request->getQueryParam('sort'); 55 $sort = $request->getQueryParam('sort');
55 $searchTags = $request->getQueryParam('searchtags'); 56 $searchTags = $request->getQueryParam('searchtags');
56 $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; 57 $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
57 58
58 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); 59 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
59 60
@@ -71,8 +72,9 @@ class TagCloudController extends ShaarliVisitorController
71 $tagsUrl[escape($tag)] = urlencode((string) $tag); 72 $tagsUrl[escape($tag)] = urlencode((string) $tag);
72 } 73 }
73 74
74 $searchTags = implode(' ', escape($filteringTags)); 75 $searchTags = tags_array2str($filteringTags, $tagsSeparator);
75 $searchTagsUrl = urlencode(implode(' ', $filteringTags)); 76 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
77 $searchTagsUrl = urlencode($searchTags);
76 $data = [ 78 $data = [
77 'search_tags' => escape($searchTags), 79 'search_tags' => escape($searchTags),
78 'search_tags_url' => $searchTagsUrl, 80 'search_tags_url' => $searchTagsUrl,
@@ -82,10 +84,10 @@ class TagCloudController extends ShaarliVisitorController
82 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); 84 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
83 $this->assignAllView($data); 85 $this->assignAllView($data);
84 86
85 $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; 87 $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) . ' - ' : '';
86 $this->assignView( 88 $this->assignView(
87 'pagetitle', 89 'pagetitle',
88 $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') 90 $searchTags . t('Tag ' . $type) . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
89 ); 91 );
90 92
91 return $response->write($this->render('tag.' . $type)); 93 return $response->write($this->render('tag.' . $type));
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php
index de4e7ea2..3aa58542 100644
--- a/application/front/controller/visitor/TagController.php
+++ b/application/front/controller/visitor/TagController.php
@@ -27,7 +27,7 @@ class TagController extends ShaarliVisitorController
27 // In case browser does not send HTTP_REFERER, we search a single tag 27 // In case browser does not send HTTP_REFERER, we search a single tag
28 if (null === $referer) { 28 if (null === $referer) {
29 if (null !== $newTag) { 29 if (null !== $newTag) {
30 return $this->redirect($response, '/?searchtags='. urlencode($newTag)); 30 return $this->redirect($response, '/?searchtags=' . urlencode($newTag));
31 } 31 }
32 32
33 return $this->redirect($response, '/'); 33 return $this->redirect($response, '/');
@@ -37,7 +37,7 @@ class TagController extends ShaarliVisitorController
37 parse_str($currentUrl['query'] ?? '', $params); 37 parse_str($currentUrl['query'] ?? '', $params);
38 38
39 if (null === $newTag) { 39 if (null === $newTag) {
40 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 40 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
41 } 41 }
42 42
43 // Prevent redirection loop 43 // Prevent redirection loop
@@ -45,9 +45,10 @@ class TagController extends ShaarliVisitorController
45 unset($params['addtag']); 45 unset($params['addtag']);
46 } 46 }
47 47
48 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
48 // Check if this tag is already in the search query and ignore it if it is. 49 // Check if this tag is already in the search query and ignore it if it is.
49 // Each tag is always separated by a space 50 // Each tag is always separated by a space
50 $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; 51 $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
51 52
52 $addtag = true; 53 $addtag = true;
53 foreach ($currentTags as $value) { 54 foreach ($currentTags as $value) {
@@ -62,12 +63,12 @@ class TagController extends ShaarliVisitorController
62 $currentTags[] = trim($newTag); 63 $currentTags[] = trim($newTag);
63 } 64 }
64 65
65 $params['searchtags'] = trim(implode(' ', $currentTags)); 66 $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
66 67
67 // We also remove page (keeping the same page has no sense, since the results are different) 68 // We also remove page (keeping the same page has no sense, since the results are different)
68 unset($params['page']); 69 unset($params['page']);
69 70
70 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 71 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
71 } 72 }
72 73
73 /** 74 /**
@@ -89,7 +90,7 @@ class TagController extends ShaarliVisitorController
89 parse_str($currentUrl['query'] ?? '', $params); 90 parse_str($currentUrl['query'] ?? '', $params);
90 91
91 if (null === $tagToRemove) { 92 if (null === $tagToRemove) {
92 return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params)); 93 return $response->withRedirect(($currentUrl['path'] ?? './') . '?' . http_build_query($params));
93 } 94 }
94 95
95 // Prevent redirection loop 96 // Prevent redirection loop
@@ -98,10 +99,11 @@ class TagController extends ShaarliVisitorController
98 } 99 }
99 100
100 if (isset($params['searchtags'])) { 101 if (isset($params['searchtags'])) {
101 $tags = explode(' ', $params['searchtags']); 102 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
103 $tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
102 // Remove value from array $tags. 104 // Remove value from array $tags.
103 $tags = array_diff($tags, [$tagToRemove]); 105 $tags = array_diff($tags, [$tagToRemove]);
104 $params['searchtags'] = implode(' ', $tags); 106 $params['searchtags'] = tags_array2str($tags, $tagsSeparator);
105 107
106 if (empty($params['searchtags'])) { 108 if (empty($params['searchtags'])) {
107 unset($params['searchtags']); 109 unset($params['searchtags']);
diff --git a/application/ApplicationUtils.php b/application/helper/ApplicationUtils.php
index 3aa21829..a6c03aae 100644
--- a/application/ApplicationUtils.php
+++ b/application/helper/ApplicationUtils.php
@@ -1,7 +1,10 @@
1<?php 1<?php
2namespace Shaarli; 2
3namespace Shaarli\Helper;
3 4
4use Exception; 5use Exception;
6use malkusch\lock\exception\LockAcquireException;
7use malkusch\lock\mutex\FlockMutex;
5use Shaarli\Config\ConfigManager; 8use Shaarli\Config\ConfigManager;
6 9
7/** 10/**
@@ -14,8 +17,9 @@ class ApplicationUtils
14 */ 17 */
15 public static $VERSION_FILE = 'shaarli_version.php'; 18 public static $VERSION_FILE = 'shaarli_version.php';
16 19
17 private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli'; 20 public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
18 private static $GIT_BRANCHES = array('latest', 'stable'); 21 public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
22 public static $GIT_BRANCHES = ['latest', 'stable'];
19 private static $VERSION_START_TAG = '<?php /* '; 23 private static $VERSION_START_TAG = '<?php /* ';
20 private static $VERSION_END_TAG = ' */ ?>'; 24 private static $VERSION_END_TAG = ' */ ?>';
21 25
@@ -63,8 +67,8 @@ class ApplicationUtils
63 } 67 }
64 68
65 return str_replace( 69 return str_replace(
66 array(self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL), 70 [self::$VERSION_START_TAG, self::$VERSION_END_TAG, PHP_EOL],
67 array('', '', ''), 71 ['', '', ''],
68 $data 72 $data
69 ); 73 );
70 } 74 }
@@ -125,7 +129,7 @@ class ApplicationUtils
125 // Late Static Binding allows overriding within tests 129 // Late Static Binding allows overriding within tests
126 // See http://php.net/manual/en/language.oop5.late-static-bindings.php 130 // See http://php.net/manual/en/language.oop5.late-static-bindings.php
127 $latestVersion = static::getVersion( 131 $latestVersion = static::getVersion(
128 self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE 132 self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
129 ); 133 );
130 134
131 if (!$latestVersion) { 135 if (!$latestVersion) {
@@ -171,35 +175,47 @@ class ApplicationUtils
171 /** 175 /**
172 * Checks Shaarli has the proper access permissions to its resources 176 * Checks Shaarli has the proper access permissions to its resources
173 * 177 *
174 * @param ConfigManager $conf Configuration Manager instance. 178 * @param ConfigManager $conf Configuration Manager instance.
179 * @param bool $minimalMode In minimal mode we only check permissions to be able to display a template.
180 * Currently we only need to be able to read the theme and write in raintpl cache.
175 * 181 *
176 * @return array A list of the detected configuration issues 182 * @return array A list of the detected configuration issues
177 */ 183 */
178 public static function checkResourcePermissions($conf) 184 public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
179 { 185 {
180 $errors = array(); 186 $errors = [];
181 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/'); 187 $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
182 188
183 // Check script and template directories are readable 189 // Check script and template directories are readable
184 foreach (array( 190 foreach (
185 'application', 191 [
186 'inc', 192 'application',
187 'plugins', 193 'inc',
188 $rainTplDir, 194 'plugins',
189 $rainTplDir . '/' . $conf->get('resource.theme'), 195 $rainTplDir,
190 ) as $path) { 196 $rainTplDir . '/' . $conf->get('resource.theme'),
197 ] as $path
198 ) {
191 if (!is_readable(realpath($path))) { 199 if (!is_readable(realpath($path))) {
192 $errors[] = '"' . $path . '" ' . t('directory is not readable'); 200 $errors[] = '"' . $path . '" ' . t('directory is not readable');
193 } 201 }
194 } 202 }
195 203
196 // Check cache and data directories are readable and writable 204 // Check cache and data directories are readable and writable
197 foreach (array( 205 if ($minimalMode) {
198 $conf->get('resource.thumbnails_cache'), 206 $folders = [
199 $conf->get('resource.data_dir'), 207 $conf->get('resource.raintpl_tmp'),
200 $conf->get('resource.page_cache'), 208 ];
201 $conf->get('resource.raintpl_tmp'), 209 } else {
202 ) as $path) { 210 $folders = [
211 $conf->get('resource.thumbnails_cache'),
212 $conf->get('resource.data_dir'),
213 $conf->get('resource.page_cache'),
214 $conf->get('resource.raintpl_tmp'),
215 ];
216 }
217
218 foreach ($folders as $path) {
203 if (!is_readable(realpath($path))) { 219 if (!is_readable(realpath($path))) {
204 $errors[] = '"' . $path . '" ' . t('directory is not readable'); 220 $errors[] = '"' . $path . '" ' . t('directory is not readable');
205 } 221 }
@@ -208,14 +224,20 @@ class ApplicationUtils
208 } 224 }
209 } 225 }
210 226
227 if ($minimalMode) {
228 return $errors;
229 }
230
211 // Check configuration files are readable and writable 231 // Check configuration files are readable and writable
212 foreach (array( 232 foreach (
213 $conf->getConfigFileExt(), 233 [
214 $conf->get('resource.datastore'), 234 $conf->getConfigFileExt(),
215 $conf->get('resource.ban_file'), 235 $conf->get('resource.datastore'),
216 $conf->get('resource.log'), 236 $conf->get('resource.ban_file'),
217 $conf->get('resource.update_check'), 237 $conf->get('resource.log'),
218 ) as $path) { 238 $conf->get('resource.update_check'),
239 ] as $path
240 ) {
219 if (!is_file(realpath($path))) { 241 if (!is_file(realpath($path))) {
220 # the file may not exist yet 242 # the file may not exist yet
221 continue; 243 continue;
@@ -232,6 +254,20 @@ class ApplicationUtils
232 return $errors; 254 return $errors;
233 } 255 }
234 256
257 public static function checkDatastoreMutex(): array
258 {
259 $mutex = new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2);
260 try {
261 $mutex->synchronized(function () {
262 return true;
263 });
264 } catch (LockAcquireException $e) {
265 $errors[] = t('Lock can not be acquired on the datastore. You might encounter concurrent access issues.');
266 }
267
268 return $errors ?? [];
269 }
270
235 /** 271 /**
236 * Returns a salted hash representing the current Shaarli version. 272 * Returns a salted hash representing the current Shaarli version.
237 * 273 *
@@ -246,4 +282,54 @@ class ApplicationUtils
246 { 282 {
247 return hash_hmac('sha256', $currentVersion, $salt); 283 return hash_hmac('sha256', $currentVersion, $salt);
248 } 284 }
285
286 /**
287 * Get a list of PHP extensions used by Shaarli.
288 *
289 * @return array[] List of extension with following keys:
290 * - name: extension name
291 * - required: whether the extension is required to use Shaarli
292 * - desc: short description of extension usage in Shaarli
293 * - loaded: whether the extension is properly loaded or not
294 */
295 public static function getPhpExtensionsRequirement(): array
296 {
297 $extensions = [
298 ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
299 ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
300 ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
301 ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
302 ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
303 ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
304 ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
305 ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
306 ];
307
308 foreach ($extensions as &$extension) {
309 $extension['loaded'] = extension_loaded($extension['name']);
310 }
311
312 return $extensions;
313 }
314
315 /**
316 * Return the EOL date of given PHP version. If the version is unknown,
317 * we return today + 2 years.
318 *
319 * @param string $fullVersion PHP version, e.g. 7.4.7
320 *
321 * @return string Date format: YYYY-MM-DD
322 */
323 public static function getPhpEol(string $fullVersion): string
324 {
325 preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
326
327 return [
328 '7.1' => '2019-12-01',
329 '7.2' => '2020-11-30',
330 '7.3' => '2021-12-06',
331 '7.4' => '2022-11-28',
332 '8.0' => '2023-12-01',
333 ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
334 }
249} 335}
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php
new file mode 100644
index 00000000..05f95812
--- /dev/null
+++ b/application/helper/DailyPageHelper.php
@@ -0,0 +1,236 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Helper;
6
7use DatePeriod;
8use DateTimeImmutable;
9use Exception;
10use Shaarli\Bookmark\Bookmark;
11use Slim\Http\Request;
12
13class DailyPageHelper
14{
15 public const MONTH = 'month';
16 public const WEEK = 'week';
17 public const DAY = 'day';
18
19 /**
20 * Extracts the type of the daily to display from the HTTP request parameters
21 *
22 * @param Request $request HTTP request
23 *
24 * @return string month/week/day
25 */
26 public static function extractRequestedType(Request $request): string
27 {
28 if ($request->getQueryParam(static::MONTH) !== null) {
29 return static::MONTH;
30 } elseif ($request->getQueryParam(static::WEEK) !== null) {
31 return static::WEEK;
32 }
33
34 return static::DAY;
35 }
36
37 /**
38 * Extracts a DateTimeImmutable from provided HTTP request.
39 * If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
40 * If the datastore is empty or no bookmark is provided, we use the current date.
41 *
42 * @param string $type month/week/day
43 * @param string|null $requestedDate Input string extracted from the request
44 * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
45 *
46 * @return DateTimeImmutable from input or latest bookmark.
47 *
48 * @throws Exception Type not supported.
49 */
50 public static function extractRequestedDateTime(
51 string $type,
52 ?string $requestedDate,
53 Bookmark $latestBookmark = null
54 ): DateTimeImmutable {
55 $format = static::getFormatByType($type);
56 if (empty($requestedDate)) {
57 return $latestBookmark instanceof Bookmark
58 ? new DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
59 : new DateTimeImmutable()
60 ;
61 }
62
63 // W is not supported by createFromFormat...
64 if ($type === static::WEEK) {
65 return (new DateTimeImmutable())
66 ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
67 ;
68 }
69
70 return DateTimeImmutable::createFromFormat($format, $requestedDate);
71 }
72
73 /**
74 * Get the DateTime format used by provided type
75 * Examples:
76 * - day: 20201016 (<year><month><day>)
77 * - week: 202041 (<year><week number>)
78 * - month: 202010 (<year><month>)
79 *
80 * @param string $type month/week/day
81 *
82 * @return string DateTime compatible format
83 *
84 * @see https://www.php.net/manual/en/datetime.format.php
85 *
86 * @throws Exception Type not supported.
87 */
88 public static function getFormatByType(string $type): string
89 {
90 switch ($type) {
91 case static::MONTH:
92 return 'Ym';
93 case static::WEEK:
94 return 'YW';
95 case static::DAY:
96 return 'Ymd';
97 default:
98 throw new Exception('Unsupported daily format type');
99 }
100 }
101
102 /**
103 * Get the first DateTime of the time period depending on given datetime and type.
104 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
105 * and we don't want to alter original datetime.
106 *
107 * @param string $type month/week/day
108 * @param DateTimeImmutable $requested DateTime extracted from request input
109 * (should come from extractRequestedDateTime)
110 *
111 * @return \DateTimeInterface First DateTime of the time period
112 *
113 * @throws Exception Type not supported.
114 */
115 public static function getStartDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
116 {
117 switch ($type) {
118 case static::MONTH:
119 return $requested->modify('first day of this month midnight');
120 case static::WEEK:
121 return $requested->modify('Monday this week midnight');
122 case static::DAY:
123 return $requested->modify('Today midnight');
124 default:
125 throw new Exception('Unsupported daily format type');
126 }
127 }
128
129 /**
130 * Get the last DateTime of the time period depending on given datetime and type.
131 * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
132 * and we don't want to alter original datetime.
133 *
134 * @param string $type month/week/day
135 * @param DateTimeImmutable $requested DateTime extracted from request input
136 * (should come from extractRequestedDateTime)
137 *
138 * @return \DateTimeInterface Last DateTime of the time period
139 *
140 * @throws Exception Type not supported.
141 */
142 public static function getEndDateTimeByType(string $type, DateTimeImmutable $requested): \DateTimeInterface
143 {
144 switch ($type) {
145 case static::MONTH:
146 return $requested->modify('last day of this month 23:59:59');
147 case static::WEEK:
148 return $requested->modify('Sunday this week 23:59:59');
149 case static::DAY:
150 return $requested->modify('Today 23:59:59');
151 default:
152 throw new Exception('Unsupported daily format type');
153 }
154 }
155
156 /**
157 * Get localized description of the time period depending on given datetime and type.
158 * Example: for a month period, it returns `October, 2020`.
159 *
160 * @param string $type month/week/day
161 * @param \DateTimeImmutable $requested DateTime extracted from request input
162 * (should come from extractRequestedDateTime)
163 * @param bool $includeRelative Include relative date description (today, yesterday, etc.)
164 *
165 * @return string Localized time period description
166 *
167 * @throws Exception Type not supported.
168 */
169 public static function getDescriptionByType(
170 string $type,
171 \DateTimeImmutable $requested,
172 bool $includeRelative = true
173 ): string {
174 switch ($type) {
175 case static::MONTH:
176 return $requested->format('F') . ', ' . $requested->format('Y');
177 case static::WEEK:
178 $requested = $requested->modify('Monday this week');
179 return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
180 case static::DAY:
181 $out = '';
182 if ($includeRelative && $requested->format('Ymd') === date('Ymd')) {
183 $out = t('Today') . ' - ';
184 } elseif ($includeRelative && $requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
185 $out = t('Yesterday') . ' - ';
186 }
187 return $out . format_date($requested, false);
188 default:
189 throw new Exception('Unsupported daily format type');
190 }
191 }
192
193 /**
194 * Get the number of items to display in the RSS feed depending on the given type.
195 *
196 * @param string $type month/week/day
197 *
198 * @return int number of elements
199 *
200 * @throws Exception Type not supported.
201 */
202 public static function getRssLengthByType(string $type): int
203 {
204 switch ($type) {
205 case static::MONTH:
206 return 12; // 1 year
207 case static::WEEK:
208 return 26; // ~6 months
209 case static::DAY:
210 return 30; // ~1 month
211 default:
212 throw new Exception('Unsupported daily format type');
213 }
214 }
215
216 /**
217 * Get the number of items to display in the RSS feed depending on the given type.
218 *
219 * @param string $type month/week/day
220 * @param ?DateTimeImmutable $requested Currently only used for UT
221 *
222 * @return DatePeriod number of elements
223 *
224 * @throws Exception Type not supported.
225 */
226 public static function getCacheDatePeriodByType(string $type, DateTimeImmutable $requested = null): DatePeriod
227 {
228 $requested = $requested ?? new DateTimeImmutable();
229
230 return new DatePeriod(
231 static::getStartDateTimeByType($type, $requested),
232 new \DateInterval('P1D'),
233 static::getEndDateTimeByType($type, $requested)
234 );
235 }
236}
diff --git a/application/FileUtils.php b/application/helper/FileUtils.php
index 30560bfc..e8a2168c 100644
--- a/application/FileUtils.php
+++ b/application/helper/FileUtils.php
@@ -1,6 +1,6 @@
1<?php 1<?php
2 2
3namespace Shaarli; 3namespace Shaarli\Helper;
4 4
5use Shaarli\Exceptions\IOException; 5use Shaarli\Exceptions\IOException;
6 6
@@ -81,4 +81,60 @@ class FileUtils
81 ) 81 )
82 ); 82 );
83 } 83 }
84
85 /**
86 * Recursively deletes a folder content, and deletes itself optionally.
87 * If an excluded file is found, folders won't be deleted.
88 *
89 * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
90 *
91 * @param string $path
92 * @param bool $selfDelete Delete the provided folder if true, only its content if false.
93 * @param array $exclude
94 */
95 public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
96 {
97 $skipped = false;
98
99 if (!is_dir($path)) {
100 throw new IOException(t('Provided path is not a directory.'));
101 }
102
103 if (!static::isPathInShaarliFolder($path)) {
104 throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
105 }
106
107 foreach (new \DirectoryIterator($path) as $file) {
108 if ($file->isDot()) {
109 continue;
110 }
111
112 if (in_array($file->getBasename(), $exclude, true)) {
113 $skipped = true;
114 continue;
115 }
116
117 if ($file->isFile()) {
118 unlink($file->getPathname());
119 } elseif ($file->isDir()) {
120 $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
121 }
122 }
123
124 if ($selfDelete && !$skipped) {
125 rmdir($path);
126 }
127
128 return $skipped;
129 }
130
131 /**
132 * Checks that the given path is inside Shaarli directory.
133 */
134 public static function isPathInShaarliFolder(string $path): bool
135 {
136 $rootDirectory = dirname(dirname(dirname(__FILE__)));
137
138 return strpos(realpath($path), $rootDirectory) !== false;
139 }
84} 140}
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
index 81d9e076..e80e0c01 100644
--- a/application/http/HttpAccess.php
+++ b/application/http/HttpAccess.php
@@ -14,9 +14,14 @@ namespace Shaarli\Http;
14 */ 14 */
15class HttpAccess 15class HttpAccess
16{ 16{
17 public function getHttpResponse($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) 17 public function getHttpResponse(
18 { 18 $url,
19 return get_http_response($url, $timeout, $maxBytes, $curlWriteFunction); 19 $timeout = 30,
20 $maxBytes = 4194304,
21 $curlHeaderFunction = null,
22 $curlWriteFunction = null
23 ) {
24 return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
20 } 25 }
21 26
22 public function getCurlDownloadCallback( 27 public function getCurlDownloadCallback(
@@ -25,7 +30,7 @@ class HttpAccess
25 &$description, 30 &$description,
26 &$keywords, 31 &$keywords,
27 $retrieveDescription, 32 $retrieveDescription,
28 $curlGetInfo = 'curl_getinfo' 33 $tagsSeparator
29 ) { 34 ) {
30 return get_curl_download_callback( 35 return get_curl_download_callback(
31 $charset, 36 $charset,
@@ -33,7 +38,12 @@ class HttpAccess
33 $description, 38 $description,
34 $keywords, 39 $keywords,
35 $retrieveDescription, 40 $retrieveDescription,
36 $curlGetInfo 41 $tagsSeparator
37 ); 42 );
38 } 43 }
44
45 public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
46 {
47 return get_curl_header_callback($charset, $curlGetInfo);
48 }
39} 49}
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php
index 9f414073..4bde1d5b 100644
--- a/application/http/HttpUtils.php
+++ b/application/http/HttpUtils.php
@@ -6,12 +6,14 @@ use Shaarli\Http\Url;
6 * GET an HTTP URL to retrieve its content 6 * GET an HTTP URL to retrieve its content
7 * Uses the cURL library or a fallback method 7 * Uses the cURL library or a fallback method
8 * 8 *
9 * @param string $url URL to get (http://...) 9 * @param string $url URL to get (http://...)
10 * @param int $timeout network timeout (in seconds) 10 * @param int $timeout network timeout (in seconds)
11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB) 11 * @param int $maxBytes maximum downloaded bytes (default: 4 MiB)
12 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION). 12 * @param callable|string $curlHeaderFunction Optional callback called during the download of headers
13 * Can be used to add download conditions on the 13 * (CURLOPT_HEADERFUNCTION)
14 * headers (response code, content type, etc.). 14 * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
15 * Can be used to add download conditions on the
16 * headers (response code, content type, etc.).
15 * 17 *
16 * @return array HTTP response headers, downloaded content 18 * @return array HTTP response headers, downloaded content
17 * 19 *
@@ -35,13 +37,18 @@ use Shaarli\Http\Url;
35 * @see http://stackoverflow.com/q/9183178 37 * @see http://stackoverflow.com/q/9183178
36 * @see http://stackoverflow.com/q/1462720 38 * @see http://stackoverflow.com/q/1462720
37 */ 39 */
38function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null) 40function get_http_response(
39{ 41 $url,
42 $timeout = 30,
43 $maxBytes = 4194304,
44 $curlHeaderFunction = null,
45 $curlWriteFunction = null
46) {
40 $urlObj = new Url($url); 47 $urlObj = new Url($url);
41 $cleanUrl = $urlObj->idnToAscii(); 48 $cleanUrl = $urlObj->idnToAscii();
42 49
43 if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) { 50 if (!filter_var($cleanUrl, FILTER_VALIDATE_URL) || !$urlObj->isHttp()) {
44 return array(array(0 => 'Invalid HTTP UrlUtils'), false); 51 return [[0 => 'Invalid HTTP UrlUtils'], false];
45 } 52 }
46 53
47 $userAgent = 54 $userAgent =
@@ -64,42 +71,39 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
64 71
65 $ch = curl_init($cleanUrl); 72 $ch = curl_init($cleanUrl);
66 if ($ch === false) { 73 if ($ch === false) {
67 return array(array(0 => 'curl_init() error'), false); 74 return [[0 => 'curl_init() error'], false];
68 } 75 }
69 76
70 // General cURL settings 77 // General cURL settings
71 curl_setopt($ch, CURLOPT_AUTOREFERER, true); 78 curl_setopt($ch, CURLOPT_AUTOREFERER, true);
72 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); 79 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
73 curl_setopt($ch, CURLOPT_HEADER, true); 80 // Default header download if the $curlHeaderFunction is not defined
81 curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction));
74 curl_setopt( 82 curl_setopt(
75 $ch, 83 $ch,
76 CURLOPT_HTTPHEADER, 84 CURLOPT_HTTPHEADER,
77 array('Accept-Language: ' . $acceptLanguage) 85 ['Accept-Language: ' . $acceptLanguage]
78 ); 86 );
79 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs); 87 curl_setopt($ch, CURLOPT_MAXREDIRS, $maxRedirs);
80 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 88 curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
81 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); 89 curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
82 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent); 90 curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
83 91
92 // Max download size management
93 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024 * 16);
94 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
95 if (is_callable($curlHeaderFunction)) {
96 curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
97 }
84 if (is_callable($curlWriteFunction)) { 98 if (is_callable($curlWriteFunction)) {
85 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction); 99 curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
86 } 100 }
87
88 // Max download size management
89 curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
90 curl_setopt($ch, CURLOPT_NOPROGRESS, false);
91 curl_setopt( 101 curl_setopt(
92 $ch, 102 $ch,
93 CURLOPT_PROGRESSFUNCTION, 103 CURLOPT_PROGRESSFUNCTION,
94 function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) { 104 function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
95 if (version_compare(phpversion(), '5.5', '<')) { 105 $downloaded = $arg2;
96 // PHP version lower than 5.5 106
97 // Callback has 4 arguments
98 $downloaded = $arg1;
99 } else {
100 // Callback has 5 arguments
101 $downloaded = $arg2;
102 }
103 // Non-zero return stops downloading 107 // Non-zero return stops downloading
104 return ($downloaded > $maxBytes) ? 1 : 0; 108 return ($downloaded > $maxBytes) ? 1 : 0;
105 } 109 }
@@ -118,9 +122,9 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
118 * Removing this would require updating 122 * Removing this would require updating
119 * GetHttpUrlTest::testGetInvalidRemoteUrl() 123 * GetHttpUrlTest::testGetInvalidRemoteUrl()
120 */ 124 */
121 return array(false, false); 125 return [false, false];
122 } 126 }
123 return array(array(0 => 'curl_exec() error: ' . $errorStr), false); 127 return [[0 => 'curl_exec() error: ' . $errorStr], false];
124 } 128 }
125 129
126 // Formatting output like the fallback method 130 // Formatting output like the fallback method
@@ -131,7 +135,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
131 $rawHeadersLastRedir = end($rawHeadersArrayRedirs); 135 $rawHeadersLastRedir = end($rawHeadersArrayRedirs);
132 136
133 $content = substr($response, $headSize); 137 $content = substr($response, $headSize);
134 $headers = array(); 138 $headers = [];
135 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) { 139 foreach (preg_split('~[\r\n]+~', $rawHeadersLastRedir) as $line) {
136 if (empty($line) || ctype_space($line)) { 140 if (empty($line) || ctype_space($line)) {
137 continue; 141 continue;
@@ -142,7 +146,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
142 $value = $splitLine[1]; 146 $value = $splitLine[1];
143 if (array_key_exists($key, $headers)) { 147 if (array_key_exists($key, $headers)) {
144 if (!is_array($headers[$key])) { 148 if (!is_array($headers[$key])) {
145 $headers[$key] = array(0 => $headers[$key]); 149 $headers[$key] = [0 => $headers[$key]];
146 } 150 }
147 $headers[$key][] = $value; 151 $headers[$key][] = $value;
148 } else { 152 } else {
@@ -153,7 +157,7 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
153 } 157 }
154 } 158 }
155 159
156 return array($headers, $content); 160 return [$headers, $content];
157} 161}
158 162
159/** 163/**
@@ -184,15 +188,15 @@ function get_http_response_fallback(
184 $acceptLanguage, 188 $acceptLanguage,
185 $maxRedr 189 $maxRedr
186) { 190) {
187 $options = array( 191 $options = [
188 'http' => array( 192 'http' => [
189 'method' => 'GET', 193 'method' => 'GET',
190 'timeout' => $timeout, 194 'timeout' => $timeout,
191 'user_agent' => $userAgent, 195 'user_agent' => $userAgent,
192 'header' => "Accept: */*\r\n" 196 'header' => "Accept: */*\r\n"
193 . 'Accept-Language: ' . $acceptLanguage 197 . 'Accept-Language: ' . $acceptLanguage
194 ) 198 ]
195 ); 199 ];
196 200
197 stream_context_set_default($options); 201 stream_context_set_default($options);
198 list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr); 202 list($headers, $finalUrl) = get_redirected_headers($cleanUrl, $maxRedr);
@@ -203,7 +207,7 @@ function get_http_response_fallback(
203 } 207 }
204 208
205 if (! $headers) { 209 if (! $headers) {
206 return array($headers, false); 210 return [$headers, false];
207 } 211 }
208 212
209 try { 213 try {
@@ -211,10 +215,10 @@ function get_http_response_fallback(
211 $context = stream_context_create($options); 215 $context = stream_context_create($options);
212 $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes); 216 $content = file_get_contents($finalUrl, false, $context, -1, $maxBytes);
213 } catch (Exception $exc) { 217 } catch (Exception $exc) {
214 return array(array(0 => 'HTTP Error'), $exc->getMessage()); 218 return [[0 => 'HTTP Error'], $exc->getMessage()];
215 } 219 }
216 220
217 return array($headers, $content); 221 return [$headers, $content];
218} 222}
219 223
220/** 224/**
@@ -233,10 +237,12 @@ function get_redirected_headers($url, $redirectionLimit = 3)
233 } 237 }
234 238
235 // Headers found, redirection found, and limit not reached. 239 // Headers found, redirection found, and limit not reached.
236 if ($redirectionLimit-- > 0 240 if (
241 $redirectionLimit-- > 0
237 && !empty($headers) 242 && !empty($headers)
238 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false) 243 && (strpos($headers[0], '301') !== false || strpos($headers[0], '302') !== false)
239 && !empty($headers['Location'])) { 244 && !empty($headers['Location'])
245 ) {
240 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location']; 246 $redirection = is_array($headers['Location']) ? end($headers['Location']) : $headers['Location'];
241 if ($redirection != $url) { 247 if ($redirection != $url) {
242 $redirection = getAbsoluteUrl($url, $redirection); 248 $redirection = getAbsoluteUrl($url, $redirection);
@@ -244,7 +250,7 @@ function get_redirected_headers($url, $redirectionLimit = 3)
244 } 250 }
245 } 251 }
246 252
247 return array($headers, $url); 253 return [$headers, $url];
248} 254}
249 255
250/** 256/**
@@ -266,7 +272,7 @@ function getAbsoluteUrl($originalUrl, $newUrl)
266 } 272 }
267 273
268 $parts = parse_url($originalUrl); 274 $parts = parse_url($originalUrl);
269 $final = $parts['scheme'] .'://'. $parts['host']; 275 $final = $parts['scheme'] . '://' . $parts['host'];
270 $final .= (!empty($parts['port'])) ? $parts['port'] : ''; 276 $final .= (!empty($parts['port'])) ? $parts['port'] : '';
271 $final .= '/'; 277 $final .= '/';
272 if ($newUrl[0] != '/') { 278 if ($newUrl[0] != '/') {
@@ -319,7 +325,8 @@ function server_url($server)
319 $scheme = 'https'; 325 $scheme = 'https';
320 } 326 }
321 327
322 if (($scheme == 'http' && $port != '80') 328 if (
329 ($scheme == 'http' && $port != '80')
323 || ($scheme == 'https' && $port != '443') 330 || ($scheme == 'https' && $port != '443')
324 ) { 331 ) {
325 $port = ':' . $port; 332 $port = ':' . $port;
@@ -340,22 +347,26 @@ function server_url($server)
340 $host = $server['SERVER_NAME']; 347 $host = $server['SERVER_NAME'];
341 } 348 }
342 349
343 return $scheme.'://'.$host.$port; 350 return $scheme . '://' . $host . $port;
344 } 351 }
345 352
346 // SSL detection 353 // SSL detection
347 if ((! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on') 354 if (
348 || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')) { 355 (! empty($server['HTTPS']) && strtolower($server['HTTPS']) == 'on')
356 || (isset($server['SERVER_PORT']) && $server['SERVER_PORT'] == '443')
357 ) {
349 $scheme = 'https'; 358 $scheme = 'https';
350 } 359 }
351 360
352 // Do not append standard port values 361 // Do not append standard port values
353 if (($scheme == 'http' && $server['SERVER_PORT'] != '80') 362 if (
354 || ($scheme == 'https' && $server['SERVER_PORT'] != '443')) { 363 ($scheme == 'http' && $server['SERVER_PORT'] != '80')
355 $port = ':'.$server['SERVER_PORT']; 364 || ($scheme == 'https' && $server['SERVER_PORT'] != '443')
365 ) {
366 $port = ':' . $server['SERVER_PORT'];
356 } 367 }
357 368
358 return $scheme.'://'.$server['SERVER_NAME'].$port; 369 return $scheme . '://' . $server['SERVER_NAME'] . $port;
359} 370}
360 371
361/** 372/**
@@ -493,6 +504,46 @@ function is_https($server)
493 * Get cURL callback function for CURLOPT_WRITEFUNCTION 504 * Get cURL callback function for CURLOPT_WRITEFUNCTION
494 * 505 *
495 * @param string $charset to extract from the downloaded page (reference) 506 * @param string $charset to extract from the downloaded page (reference)
507 * @param string $curlGetInfo Optionally overrides curl_getinfo function
508 *
509 * @return Closure
510 */
511function get_curl_header_callback(
512 &$charset,
513 $curlGetInfo = 'curl_getinfo'
514) {
515 $isRedirected = false;
516
517 return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
518 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
519 $chunkLength = strlen($data);
520 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
521 $isRedirected = true;
522 return $chunkLength;
523 }
524 if (!empty($responseCode) && $responseCode !== 200) {
525 return false;
526 }
527 // After a redirection, the content type will keep the previous request value
528 // until it finds the next content-type header.
529 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
530 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
531 }
532 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
533 return false;
534 }
535 if (!empty($contentType) && empty($charset)) {
536 $charset = header_extract_charset($contentType);
537 }
538
539 return $chunkLength;
540 };
541}
542
543/**
544 * Get cURL callback function for CURLOPT_WRITEFUNCTION
545 *
546 * @param string $charset to extract from the downloaded page (reference)
496 * @param string $title to extract from the downloaded page (reference) 547 * @param string $title to extract from the downloaded page (reference)
497 * @param string $description to extract from the downloaded page (reference) 548 * @param string $description to extract from the downloaded page (reference)
498 * @param string $keywords to extract from the downloaded page (reference) 549 * @param string $keywords to extract from the downloaded page (reference)
@@ -507,9 +558,8 @@ function get_curl_download_callback(
507 &$description, 558 &$description,
508 &$keywords, 559 &$keywords,
509 $retrieveDescription, 560 $retrieveDescription,
510 $curlGetInfo = 'curl_getinfo' 561 $tagsSeparator
511) { 562) {
512 $isRedirected = false;
513 $currentChunk = 0; 563 $currentChunk = 0;
514 $foundChunk = null; 564 $foundChunk = null;
515 565
@@ -524,37 +574,22 @@ function get_curl_download_callback(
524 * 574 *
525 * @return int|bool length of $data or false if we need to stop the download 575 * @return int|bool length of $data or false if we need to stop the download
526 */ 576 */
527 return function (&$ch, $data) use ( 577 return function (
578 $ch,
579 $data
580 ) use (
528 $retrieveDescription, 581 $retrieveDescription,
529 $curlGetInfo, 582 $tagsSeparator,
530 &$charset, 583 &$charset,
531 &$title, 584 &$title,
532 &$description, 585 &$description,
533 &$keywords, 586 &$keywords,
534 &$isRedirected,
535 &$currentChunk, 587 &$currentChunk,
536 &$foundChunk 588 &$foundChunk
537 ) { 589 ) {
590 $chunkLength = strlen($data);
538 $currentChunk++; 591 $currentChunk++;
539 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE); 592
540 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
541 $isRedirected = true;
542 return strlen($data);
543 }
544 if (!empty($responseCode) && $responseCode !== 200) {
545 return false;
546 }
547 // After a redirection, the content type will keep the previous request value
548 // until it finds the next content-type header.
549 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
550 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
551 }
552 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
553 return false;
554 }
555 if (!empty($contentType) && empty($charset)) {
556 $charset = header_extract_charset($contentType);
557 }
558 if (empty($charset)) { 593 if (empty($charset)) {
559 $charset = html_extract_charset($data); 594 $charset = html_extract_charset($data);
560 } 595 }
@@ -562,6 +597,10 @@ function get_curl_download_callback(
562 $title = html_extract_title($data); 597 $title = html_extract_title($data);
563 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk; 598 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
564 } 599 }
600 if (empty($title)) {
601 $title = html_extract_tag('title', $data);
602 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
603 }
565 if ($retrieveDescription && empty($description)) { 604 if ($retrieveDescription && empty($description)) {
566 $description = html_extract_tag('description', $data); 605 $description = html_extract_tag('description', $data);
567 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk; 606 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
@@ -571,10 +610,10 @@ function get_curl_download_callback(
571 if (! empty($keywords)) { 610 if (! empty($keywords)) {
572 $foundChunk = $currentChunk; 611 $foundChunk = $currentChunk;
573 // Keywords use the format tag1, tag2 multiple words, tag 612 // Keywords use the format tag1, tag2 multiple words, tag
574 // So we format them to match Shaarli's separator and glue multiple words with '-' 613 // So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
575 $keywords = implode(' ', array_map(function($keyword) { 614 $keywords = tags_array2str(array_map(function (string $keyword) use ($tagsSeparator): string {
576 return implode('-', preg_split('/\s+/', trim($keyword))); 615 return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
577 }, explode(',', $keywords))); 616 }, tags_str2array($keywords, ',')), $tagsSeparator);
578 } 617 }
579 } 618 }
580 619
@@ -582,7 +621,8 @@ function get_curl_download_callback(
582 // If we already found either the title, description or keywords, 621 // If we already found either the title, description or keywords,
583 // it's highly unlikely that we'll found the other metas further than 622 // it's highly unlikely that we'll found the other metas further than
584 // in the same chunk of data or the next one. So we also stop the download after that. 623 // in the same chunk of data or the next one. So we also stop the download after that.
585 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null 624 if (
625 (!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
586 && (! $retrieveDescription 626 && (! $retrieveDescription
587 || $foundChunk < $currentChunk 627 || $foundChunk < $currentChunk
588 || (!empty($title) && !empty($description) && !empty($keywords)) 628 || (!empty($title) && !empty($description) && !empty($keywords))
@@ -591,6 +631,6 @@ function get_curl_download_callback(
591 return false; 631 return false;
592 } 632 }
593 633
594 return strlen($data); 634 return $chunkLength;
595 }; 635 };
596} 636}
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644
index 00000000..cfc72583
--- /dev/null
+++ b/application/http/MetadataRetriever.php
@@ -0,0 +1,74 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7use Shaarli\Config\ConfigManager;
8
9/**
10 * HTTP Tool used to extract metadata from external URL (title, description, etc.).
11 */
12class MetadataRetriever
13{
14 /** @var ConfigManager */
15 protected $conf;
16
17 /** @var HttpAccess */
18 protected $httpAccess;
19
20 public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
21 {
22 $this->conf = $conf;
23 $this->httpAccess = $httpAccess;
24 }
25
26 /**
27 * Retrieve metadata for given URL.
28 *
29 * @return array [
30 * 'title' => <remote title>,
31 * 'description' => <remote description>,
32 * 'tags' => <remote keywords>,
33 * ]
34 */
35 public function retrieve(string $url): array
36 {
37 $charset = null;
38 $title = null;
39 $description = null;
40 $tags = null;
41
42 // Short timeout to keep the application responsive
43 // The callback will fill $charset and $title with data from the downloaded page.
44 $this->httpAccess->getHttpResponse(
45 $url,
46 $this->conf->get('general.download_timeout', 30),
47 $this->conf->get('general.download_max_size', 4194304),
48 $this->httpAccess->getCurlHeaderCallback($charset),
49 $this->httpAccess->getCurlDownloadCallback(
50 $charset,
51 $title,
52 $description,
53 $tags,
54 $this->conf->get('general.retrieve_description'),
55 $this->conf->get('general.tags_separator', ' ')
56 )
57 );
58
59 if (!empty($title) && strtolower($charset) !== 'utf-8') {
60 $title = mb_convert_encoding($title, 'utf-8', $charset);
61 }
62
63 return array_map([$this, 'cleanMetadata'], [
64 'title' => $title,
65 'description' => $description,
66 'tags' => $tags,
67 ]);
68 }
69
70 protected function cleanMetadata($data): ?string
71 {
72 return !is_string($data) || empty(trim($data)) ? null : trim($data);
73 }
74}
diff --git a/application/http/Url.php b/application/http/Url.php
index 90444a2f..fe87088f 100644
--- a/application/http/Url.php
+++ b/application/http/Url.php
@@ -17,7 +17,7 @@ namespace Shaarli\Http;
17 */ 17 */
18class Url 18class Url
19{ 19{
20 private static $annoyingQueryParams = array( 20 private static $annoyingQueryParams = [
21 // Facebook 21 // Facebook
22 'action_object_map=', 22 'action_object_map=',
23 'action_ref_map=', 23 'action_ref_map=',
@@ -37,15 +37,15 @@ class Url
37 37
38 // Other 38 // Other
39 'campaign_' 39 'campaign_'
40 ); 40 ];
41 41
42 private static $annoyingFragments = array( 42 private static $annoyingFragments = [
43 // ATInternet 43 // ATInternet
44 'xtor=RSS-', 44 'xtor=RSS-',
45 45
46 // Misc. 46 // Misc.
47 'tk.rss_all' 47 'tk.rss_all'
48 ); 48 ];
49 49
50 /* 50 /*
51 * URL parts represented as an array 51 * URL parts represented as an array
@@ -120,7 +120,7 @@ class Url
120 foreach (self::$annoyingQueryParams as $annoying) { 120 foreach (self::$annoyingQueryParams as $annoying) {
121 foreach ($queryParams as $param) { 121 foreach ($queryParams as $param) {
122 if (startsWith($param, $annoying)) { 122 if (startsWith($param, $annoying)) {
123 $queryParams = array_diff($queryParams, array($param)); 123 $queryParams = array_diff($queryParams, [$param]);
124 continue; 124 continue;
125 } 125 }
126 } 126 }
diff --git a/application/http/UrlUtils.php b/application/http/UrlUtils.php
index e8d1a283..de5b7db1 100644
--- a/application/http/UrlUtils.php
+++ b/application/http/UrlUtils.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Converts an array-represented URL to a string 4 * Converts an array-represented URL to a string
4 * 5 *
@@ -12,15 +13,15 @@
12 */ 13 */
13function unparse_url($parsedUrl) 14function unparse_url($parsedUrl)
14{ 15{
15 $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'].'://' : ''; 16 $scheme = isset($parsedUrl['scheme']) ? $parsedUrl['scheme'] . '://' : '';
16 $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : ''; 17 $host = isset($parsedUrl['host']) ? $parsedUrl['host'] : '';
17 $port = isset($parsedUrl['port']) ? ':'.$parsedUrl['port'] : ''; 18 $port = isset($parsedUrl['port']) ? ':' . $parsedUrl['port'] : '';
18 $user = isset($parsedUrl['user']) ? $parsedUrl['user'] : ''; 19 $user = isset($parsedUrl['user']) ? $parsedUrl['user'] : '';
19 $pass = isset($parsedUrl['pass']) ? ':'.$parsedUrl['pass'] : ''; 20 $pass = isset($parsedUrl['pass']) ? ':' . $parsedUrl['pass'] : '';
20 $pass = ($user || $pass) ? "$pass@" : ''; 21 $pass = ($user || $pass) ? "$pass@" : '';
21 $path = isset($parsedUrl['path']) ? $parsedUrl['path'] : ''; 22 $path = isset($parsedUrl['path']) ? $parsedUrl['path'] : '';
22 $query = isset($parsedUrl['query']) ? '?'.$parsedUrl['query'] : ''; 23 $query = isset($parsedUrl['query']) ? '?' . $parsedUrl['query'] : '';
23 $fragment = isset($parsedUrl['fragment']) ? '#'.$parsedUrl['fragment'] : ''; 24 $fragment = isset($parsedUrl['fragment']) ? '#' . $parsedUrl['fragment'] : '';
24 25
25 return "$scheme$user$pass$host$port$path$query$fragment"; 26 return "$scheme$user$pass$host$port$path$query$fragment";
26} 27}
diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php
index 826604e7..1fed418b 100644
--- a/application/legacy/LegacyController.php
+++ b/application/legacy/LegacyController.php
@@ -51,7 +51,7 @@ class LegacyController extends ShaarliVisitorController
51 51
52 if (!$this->container->loginManager->isLoggedIn()) { 52 if (!$this->container->loginManager->isLoggedIn()) {
53 $parameters = $buildParameters($request->getQueryParams(), true); 53 $parameters = $buildParameters($request->getQueryParams(), true);
54 return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters); 54 return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route . $parameters);
55 } 55 }
56 56
57 $parameters = $buildParameters($request->getQueryParams(), false); 57 $parameters = $buildParameters($request->getQueryParams(), false);
diff --git a/application/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php
index 7bf76fd4..d3beafe0 100644
--- a/application/legacy/LegacyLinkDB.php
+++ b/application/legacy/LegacyLinkDB.php
@@ -8,7 +8,7 @@ use DateTime;
8use Iterator; 8use Iterator;
9use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 9use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
10use Shaarli\Exceptions\IOException; 10use Shaarli\Exceptions\IOException;
11use Shaarli\FileUtils; 11use Shaarli\Helper\FileUtils;
12use Shaarli\Render\PageCacheManager; 12use Shaarli\Render\PageCacheManager;
13 13
14/** 14/**
@@ -62,7 +62,7 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
62 private $datastore; 62 private $datastore;
63 63
64 // Link date storage format 64 // Link date storage format
65 const LINK_DATE_FORMAT = 'Ymd_His'; 65 public const LINK_DATE_FORMAT = 'Ymd_His';
66 66
67 // List of bookmarks (associative array) 67 // List of bookmarks (associative array)
68 // - key: link date (e.g. "20110823_124546"), 68 // - key: link date (e.g. "20110823_124546"),
@@ -240,8 +240,8 @@ class LegacyLinkDB implements Iterator, Countable, ArrayAccess
240 } 240 }
241 241
242 // Create a dummy database for example 242 // Create a dummy database for example
243 $this->links = array(); 243 $this->links = [];
244 $link = array( 244 $link = [
245 'id' => 1, 245 'id' => 1,
246 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), 246 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
247 'url' => 'https://shaarli.readthedocs.io', 247 'url' => 'https://shaarli.readthedocs.io',
@@ -257,11 +257,11 @@ You use the community supported version of the original Shaarli project, by Seba
257 'created' => new DateTime(), 257 'created' => new DateTime(),
258 'tags' => 'opensource software', 258 'tags' => 'opensource software',
259 'sticky' => false, 259 'sticky' => false,
260 ); 260 ];
261 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 261 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
262 $this->links[1] = $link; 262 $this->links[1] = $link;
263 263
264 $link = array( 264 $link = [
265 'id' => 0, 265 'id' => 0,
266 'title' => t('My secret stuff... - Pastebin.com'), 266 'title' => t('My secret stuff... - Pastebin.com'),
267 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', 267 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
@@ -270,7 +270,7 @@ You use the community supported version of the original Shaarli project, by Seba
270 'created' => new DateTime('1 minute ago'), 270 'created' => new DateTime('1 minute ago'),
271 'tags' => 'secretstuff', 271 'tags' => 'secretstuff',
272 'sticky' => false, 272 'sticky' => false,
273 ); 273 ];
274 $link['shorturl'] = link_small_hash($link['created'], $link['id']); 274 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
275 $this->links[0] = $link; 275 $this->links[0] = $link;
276 276
@@ -285,7 +285,7 @@ You use the community supported version of the original Shaarli project, by Seba
285 { 285 {
286 // Public bookmarks are hidden and user not logged in => nothing to show 286 // Public bookmarks are hidden and user not logged in => nothing to show
287 if ($this->hidePublicLinks && !$this->loggedIn) { 287 if ($this->hidePublicLinks && !$this->loggedIn) {
288 $this->links = array(); 288 $this->links = [];
289 return; 289 return;
290 } 290 }
291 291
@@ -293,7 +293,7 @@ You use the community supported version of the original Shaarli project, by Seba
293 $this->ids = []; 293 $this->ids = [];
294 $this->links = FileUtils::readFlatDB($this->datastore, []); 294 $this->links = FileUtils::readFlatDB($this->datastore, []);
295 295
296 $toremove = array(); 296 $toremove = [];
297 foreach ($this->links as $key => &$link) { 297 foreach ($this->links as $key => &$link) {
298 if (!$this->loggedIn && $link['private'] != 0) { 298 if (!$this->loggedIn && $link['private'] != 0) {
299 // Transition for not upgraded databases. 299 // Transition for not upgraded databases.
@@ -414,7 +414,7 @@ You use the community supported version of the original Shaarli project, by Seba
414 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided. 414 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
415 */ 415 */
416 public function filterSearch( 416 public function filterSearch(
417 $filterRequest = array(), 417 $filterRequest = [],
418 $casesensitive = false, 418 $casesensitive = false,
419 $visibility = 'all', 419 $visibility = 'all',
420 $untaggedonly = false 420 $untaggedonly = false
@@ -512,7 +512,7 @@ You use the community supported version of the original Shaarli project, by Seba
512 */ 512 */
513 public function days() 513 public function days()
514 { 514 {
515 $linkDays = array(); 515 $linkDays = [];
516 foreach ($this->links as $link) { 516 foreach ($this->links as $link) {
517 $linkDays[$link['created']->format('Ymd')] = 0; 517 $linkDays[$link['created']->format('Ymd')] = 0;
518 } 518 }
diff --git a/application/legacy/LegacyLinkFilter.php b/application/legacy/LegacyLinkFilter.php
index 7cf93d60..e6d186c4 100644
--- a/application/legacy/LegacyLinkFilter.php
+++ b/application/legacy/LegacyLinkFilter.php
@@ -120,7 +120,7 @@ class LegacyLinkFilter
120 return $this->links; 120 return $this->links;
121 } 121 }
122 122
123 $out = array(); 123 $out = [];
124 foreach ($this->links as $key => $value) { 124 foreach ($this->links as $key => $value) {
125 if ($value['private'] && $visibility === 'private') { 125 if ($value['private'] && $visibility === 'private') {
126 $out[$key] = $value; 126 $out[$key] = $value;
@@ -143,7 +143,7 @@ class LegacyLinkFilter
143 */ 143 */
144 private function filterSmallHash($smallHash) 144 private function filterSmallHash($smallHash)
145 { 145 {
146 $filtered = array(); 146 $filtered = [];
147 foreach ($this->links as $key => $l) { 147 foreach ($this->links as $key => $l) {
148 if ($smallHash == $l['shorturl']) { 148 if ($smallHash == $l['shorturl']) {
149 // Yes, this is ugly and slow 149 // Yes, this is ugly and slow
@@ -186,7 +186,7 @@ class LegacyLinkFilter
186 return $this->noFilter($visibility); 186 return $this->noFilter($visibility);
187 } 187 }
188 188
189 $filtered = array(); 189 $filtered = [];
190 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); 190 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
191 $exactRegex = '/"([^"]+)"/'; 191 $exactRegex = '/"([^"]+)"/';
192 // Retrieve exact search terms. 192 // Retrieve exact search terms.
@@ -198,8 +198,8 @@ class LegacyLinkFilter
198 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); 198 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
199 199
200 // Filter excluding terms and update andSearch. 200 // Filter excluding terms and update andSearch.
201 $excludeSearch = array(); 201 $excludeSearch = [];
202 $andSearch = array(); 202 $andSearch = [];
203 foreach ($explodedSearchAnd as $needle) { 203 foreach ($explodedSearchAnd as $needle) {
204 if ($needle[0] == '-' && strlen($needle) > 1) { 204 if ($needle[0] == '-' && strlen($needle) > 1) {
205 $excludeSearch[] = substr($needle, 1); 205 $excludeSearch[] = substr($needle, 1);
@@ -208,7 +208,7 @@ class LegacyLinkFilter
208 } 208 }
209 } 209 }
210 210
211 $keys = array('title', 'description', 'url', 'tags'); 211 $keys = ['title', 'description', 'url', 'tags'];
212 212
213 // Iterate over every stored link. 213 // Iterate over every stored link.
214 foreach ($this->links as $id => $link) { 214 foreach ($this->links as $id => $link) {
@@ -336,7 +336,7 @@ class LegacyLinkFilter
336 } 336 }
337 337
338 // create resulting array 338 // create resulting array
339 $filtered = array(); 339 $filtered = [];
340 340
341 // iterate over each link 341 // iterate over each link
342 foreach ($this->links as $key => $link) { 342 foreach ($this->links as $key => $link) {
@@ -352,7 +352,7 @@ class LegacyLinkFilter
352 $search = $link['tags']; // build search string, start with tags of current link 352 $search = $link['tags']; // build search string, start with tags of current link
353 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { 353 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) {
354 // description given and at least one possible tag found 354 // description given and at least one possible tag found
355 $descTags = array(); 355 $descTags = [];
356 // find all tags in the form of #tag in the description 356 // find all tags in the form of #tag in the description
357 preg_match_all( 357 preg_match_all(
358 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 358 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -419,7 +419,7 @@ class LegacyLinkFilter
419 throw new Exception('Invalid date format'); 419 throw new Exception('Invalid date format');
420 } 420 }
421 421
422 $filtered = array(); 422 $filtered = [];
423 foreach ($this->links as $key => $l) { 423 foreach ($this->links as $key => $l) {
424 if ($l['created']->format('Ymd') == $day) { 424 if ($l['created']->format('Ymd') == $day) {
425 $filtered[$key] = $l; 425 $filtered[$key] = $l;
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php
index 0ab3a55b..9bda54b8 100644
--- a/application/legacy/LegacyUpdater.php
+++ b/application/legacy/LegacyUpdater.php
@@ -7,7 +7,6 @@ use RainTPL;
7use ReflectionClass; 7use ReflectionClass;
8use ReflectionException; 8use ReflectionException;
9use ReflectionMethod; 9use ReflectionMethod;
10use Shaarli\ApplicationUtils;
11use Shaarli\Bookmark\Bookmark; 10use Shaarli\Bookmark\Bookmark;
12use Shaarli\Bookmark\BookmarkArray; 11use Shaarli\Bookmark\BookmarkArray;
13use Shaarli\Bookmark\BookmarkFilter; 12use Shaarli\Bookmark\BookmarkFilter;
@@ -17,6 +16,7 @@ use Shaarli\Config\ConfigJson;
17use Shaarli\Config\ConfigManager; 16use Shaarli\Config\ConfigManager;
18use Shaarli\Config\ConfigPhp; 17use Shaarli\Config\ConfigPhp;
19use Shaarli\Exceptions\IOException; 18use Shaarli\Exceptions\IOException;
19use Shaarli\Helper\ApplicationUtils;
20use Shaarli\Thumbnailer; 20use Shaarli\Thumbnailer;
21use Shaarli\Updater\Exception\UpdaterException; 21use Shaarli\Updater\Exception\UpdaterException;
22 22
@@ -93,7 +93,7 @@ class LegacyUpdater
93 */ 93 */
94 public function update() 94 public function update()
95 { 95 {
96 $updatesRan = array(); 96 $updatesRan = [];
97 97
98 // If the user isn't logged in, exit without updating. 98 // If the user isn't logged in, exit without updating.
99 if ($this->isLoggedIn !== true) { 99 if ($this->isLoggedIn !== true) {
@@ -106,7 +106,8 @@ class LegacyUpdater
106 106
107 foreach ($this->methods as $method) { 107 foreach ($this->methods as $method) {
108 // Not an update method or already done, pass. 108 // Not an update method or already done, pass.
109 if (!startsWith($method->getName(), 'updateMethod') 109 if (
110 !startsWith($method->getName(), 'updateMethod')
110 || in_array($method->getName(), $this->doneUpdates) 111 || in_array($method->getName(), $this->doneUpdates)
111 ) { 112 ) {
112 continue; 113 continue;
@@ -189,7 +190,7 @@ class LegacyUpdater
189 } 190 }
190 191
191 // Set sub config keys (config and plugins) 192 // Set sub config keys (config and plugins)
192 $subConfig = array('config', 'plugins'); 193 $subConfig = ['config', 'plugins'];
193 foreach ($subConfig as $sub) { 194 foreach ($subConfig as $sub) {
194 foreach ($oldConfig[$sub] as $key => $value) { 195 foreach ($oldConfig[$sub] as $key => $value) {
195 if (isset($legacyMap[$sub . '.' . $key])) { 196 if (isset($legacyMap[$sub . '.' . $key])) {
@@ -259,7 +260,7 @@ class LegacyUpdater
259 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; 260 $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php';
260 copy($this->conf->get('resource.datastore'), $save); 261 copy($this->conf->get('resource.datastore'), $save);
261 262
262 $links = array(); 263 $links = [];
263 foreach ($this->linkDB as $offset => $value) { 264 foreach ($this->linkDB as $offset => $value) {
264 $links[] = $value; 265 $links[] = $value;
265 unset($this->linkDB[$offset]); 266 unset($this->linkDB[$offset]);
@@ -498,7 +499,8 @@ class LegacyUpdater
498 */ 499 */
499 public function updateMethodDownloadSizeAndTimeoutConf() 500 public function updateMethodDownloadSizeAndTimeoutConf()
500 { 501 {
501 if ($this->conf->exists('general.download_max_size') 502 if (
503 $this->conf->exists('general.download_max_size')
502 && $this->conf->exists('general.download_timeout') 504 && $this->conf->exists('general.download_timeout')
503 ) { 505 ) {
504 return true; 506 return true;
@@ -585,7 +587,7 @@ class LegacyUpdater
585 587
586 $linksArray = new BookmarkArray(); 588 $linksArray = new BookmarkArray();
587 foreach ($this->linkDB as $key => $link) { 589 foreach ($this->linkDB as $key => $link) {
588 $linksArray[$key] = (new Bookmark())->fromArray($link); 590 $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' '));
589 } 591 }
590 $linksIo = new BookmarkIO($this->conf); 592 $linksIo = new BookmarkIO($this->conf);
591 $linksIo->write($linksArray); 593 $linksIo->write($linksArray);
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
index b83f16f8..2d97b4c8 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -59,11 +59,11 @@ class NetscapeBookmarkUtils
59 $indexUrl 59 $indexUrl
60 ) { 60 ) {
61 // see tpl/export.html for possible values 61 // see tpl/export.html for possible values
62 if (!in_array($selection, array('all', 'public', 'private'))) { 62 if (!in_array($selection, ['all', 'public', 'private'])) {
63 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); 63 throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"');
64 } 64 }
65 65
66 $bookmarkLinks = array(); 66 $bookmarkLinks = [];
67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) { 67 foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
68 $link = $formatter->format($bookmark); 68 $link = $formatter->format($bookmark);
69 $link['taglist'] = implode(',', $bookmark->getTags()); 69 $link['taglist'] = implode(',', $bookmark->getTags());
@@ -101,11 +101,11 @@ class NetscapeBookmarkUtils
101 101
102 // Add tags to all imported bookmarks? 102 // Add tags to all imported bookmarks?
103 if (empty($post['default_tags'])) { 103 if (empty($post['default_tags'])) {
104 $defaultTags = array(); 104 $defaultTags = [];
105 } else { 105 } else {
106 $defaultTags = preg_split( 106 $defaultTags = tags_str2array(
107 '/[\s,]+/', 107 escape($post['default_tags']),
108 escape($post['default_tags']) 108 $this->conf->get('general.tags_separator', ' ')
109 ); 109 );
110 } 110 }
111 111
@@ -171,7 +171,7 @@ class NetscapeBookmarkUtils
171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); 171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
172 $link->setDescription($bkm['note']); 172 $link->setDescription($bkm['note']);
173 $link->setPrivate($private); 173 $link->setPrivate($private);
174 $link->setTagsString($bkm['tags']); 174 $link->setTags($bkm['tags']);
175 175
176 $this->bookmarkService->addOrSet($link, false); 176 $this->bookmarkService->addOrSet($link, false);
177 $importCount++; 177 $importCount++;
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php
index da66dea3..7fc0cb04 100644
--- a/application/plugin/PluginManager.php
+++ b/application/plugin/PluginManager.php
@@ -1,8 +1,10 @@
1<?php 1<?php
2
2namespace Shaarli\Plugin; 3namespace Shaarli\Plugin;
3 4
4use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
5use Shaarli\Plugin\Exception\PluginFileNotFoundException; 6use Shaarli\Plugin\Exception\PluginFileNotFoundException;
7use Shaarli\Plugin\Exception\PluginInvalidRouteException;
6 8
7/** 9/**
8 * Class PluginManager 10 * Class PluginManager
@@ -23,7 +25,15 @@ class PluginManager
23 * 25 *
24 * @var array $loadedPlugins 26 * @var array $loadedPlugins
25 */ 27 */
26 private $loadedPlugins = array(); 28 private $loadedPlugins = [];
29
30 /** @var array List of registered routes. Contains keys:
31 * - `method`: HTTP method, GET/POST/PUT/PATCH/DELETE
32 * - `route` (path): without prefix, e.g. `/up/{variable}`
33 * It will be later prefixed by `/plugin/<plugin name>/`.
34 * - `callable` string, function name or FQN class's method, e.g. `demo_plugin_custom_controller`.
35 */
36 protected $registeredRoutes = [];
27 37
28 /** 38 /**
29 * @var ConfigManager Configuration Manager instance. 39 * @var ConfigManager Configuration Manager instance.
@@ -57,7 +67,7 @@ class PluginManager
57 public function __construct(&$conf) 67 public function __construct(&$conf)
58 { 68 {
59 $this->conf = $conf; 69 $this->conf = $conf;
60 $this->errors = array(); 70 $this->errors = [];
61 } 71 }
62 72
63 /** 73 /**
@@ -85,6 +95,9 @@ class PluginManager
85 $this->loadPlugin($dirs[$index], $plugin); 95 $this->loadPlugin($dirs[$index], $plugin);
86 } catch (PluginFileNotFoundException $e) { 96 } catch (PluginFileNotFoundException $e) {
87 error_log($e->getMessage()); 97 error_log($e->getMessage());
98 } catch (\Throwable $e) {
99 $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
100 $this->errors = array_unique(array_merge($this->errors, [$error]));
88 } 101 }
89 } 102 }
90 } 103 }
@@ -98,7 +111,7 @@ class PluginManager
98 * 111 *
99 * @return void 112 * @return void
100 */ 113 */
101 public function executeHooks($hook, &$data, $params = array()) 114 public function executeHooks($hook, &$data, $params = [])
102 { 115 {
103 $metadataParameters = [ 116 $metadataParameters = [
104 'target' => '_PAGE_', 117 'target' => '_PAGE_',
@@ -165,6 +178,22 @@ class PluginManager
165 } 178 }
166 } 179 }
167 180
181 $registerRouteFunction = $pluginName . '_register_routes';
182 $routes = null;
183 if (function_exists($registerRouteFunction)) {
184 $routes = call_user_func($registerRouteFunction);
185 }
186
187 if ($routes !== null) {
188 foreach ($routes as $route) {
189 if (static::validateRouteRegistration($route)) {
190 $this->registeredRoutes[$pluginName][] = $route;
191 } else {
192 throw new PluginInvalidRouteException($pluginName);
193 }
194 }
195 }
196
168 $this->loadedPlugins[] = $pluginName; 197 $this->loadedPlugins[] = $pluginName;
169 } 198 }
170 199
@@ -196,7 +225,7 @@ class PluginManager
196 */ 225 */
197 public function getPluginsMeta() 226 public function getPluginsMeta()
198 { 227 {
199 $metaData = array(); 228 $metaData = [];
200 $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK); 229 $dirs = glob(self::$PLUGINS_PATH . '/*', GLOB_ONLYDIR | GLOB_MARK);
201 230
202 // Browse all plugin directories. 231 // Browse all plugin directories.
@@ -217,9 +246,9 @@ class PluginManager
217 if (isset($metaData[$plugin]['parameters'])) { 246 if (isset($metaData[$plugin]['parameters'])) {
218 $params = explode(';', $metaData[$plugin]['parameters']); 247 $params = explode(';', $metaData[$plugin]['parameters']);
219 } else { 248 } else {
220 $params = array(); 249 $params = [];
221 } 250 }
222 $metaData[$plugin]['parameters'] = array(); 251 $metaData[$plugin]['parameters'] = [];
223 foreach ($params as $param) { 252 foreach ($params as $param) {
224 if (empty($param)) { 253 if (empty($param)) {
225 continue; 254 continue;
@@ -237,6 +266,14 @@ class PluginManager
237 } 266 }
238 267
239 /** 268 /**
269 * @return array List of registered custom routes by plugins.
270 */
271 public function getRegisteredRoutes(): array
272 {
273 return $this->registeredRoutes;
274 }
275
276 /**
240 * Return the list of encountered errors. 277 * Return the list of encountered errors.
241 * 278 *
242 * @return array List of errors (empty array if none exists). 279 * @return array List of errors (empty array if none exists).
@@ -245,4 +282,32 @@ class PluginManager
245 { 282 {
246 return $this->errors; 283 return $this->errors;
247 } 284 }
285
286 /**
287 * Checks whether provided input is valid to register a new route.
288 * It must contain keys `method`, `route`, `callable` (all strings).
289 *
290 * @param string[] $input
291 *
292 * @return bool
293 */
294 protected static function validateRouteRegistration(array $input): bool
295 {
296 if (
297 !array_key_exists('method', $input)
298 || !in_array(strtoupper($input['method']), ['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
299 ) {
300 return false;
301 }
302
303 if (!array_key_exists('route', $input) || !preg_match('#^[a-z\d/\.\-_]+$#', $input['route'])) {
304 return false;
305 }
306
307 if (!array_key_exists('callable', $input)) {
308 return false;
309 }
310
311 return true;
312 }
248} 313}
diff --git a/application/plugin/exception/PluginFileNotFoundException.php b/application/plugin/exception/PluginFileNotFoundException.php
index e5386f02..21ac6604 100644
--- a/application/plugin/exception/PluginFileNotFoundException.php
+++ b/application/plugin/exception/PluginFileNotFoundException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Plugin\Exception; 3namespace Shaarli\Plugin\Exception;
3 4
4use Exception; 5use Exception;
diff --git a/application/plugin/exception/PluginInvalidRouteException.php b/application/plugin/exception/PluginInvalidRouteException.php
new file mode 100644
index 00000000..6ba9bc43
--- /dev/null
+++ b/application/plugin/exception/PluginInvalidRouteException.php
@@ -0,0 +1,26 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Plugin\Exception;
6
7use Exception;
8
9/**
10 * Class PluginFileNotFoundException
11 *
12 * Raise when plugin files can't be found.
13 */
14class PluginInvalidRouteException extends Exception
15{
16 /**
17 * Construct exception with plugin name.
18 * Generate message.
19 *
20 * @param string $pluginName name of the plugin not found
21 */
22 public function __construct()
23 {
24 $this->message = 'trying to register invalid route.';
25 }
26}
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php
index 2d6d2dbe..bf0ae326 100644
--- a/application/render/PageBuilder.php
+++ b/application/render/PageBuilder.php
@@ -3,11 +3,11 @@
3namespace Shaarli\Render; 3namespace Shaarli\Render;
4 4
5use Exception; 5use Exception;
6use exceptions\MissingBasePathException; 6use Psr\Log\LoggerInterface;
7use RainTPL; 7use RainTPL;
8use Shaarli\ApplicationUtils;
9use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
10use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
10use Shaarli\Helper\ApplicationUtils;
11use Shaarli\Security\SessionManager; 11use Shaarli\Security\SessionManager;
12use Shaarli\Thumbnailer; 12use Shaarli\Thumbnailer;
13 13
@@ -35,6 +35,9 @@ class PageBuilder
35 */ 35 */
36 protected $session; 36 protected $session;
37 37
38 /** @var LoggerInterface */
39 protected $logger;
40
38 /** 41 /**
39 * @var BookmarkServiceInterface $bookmarkService instance. 42 * @var BookmarkServiceInterface $bookmarkService instance.
40 */ 43 */
@@ -54,17 +57,25 @@ class PageBuilder
54 * PageBuilder constructor. 57 * PageBuilder constructor.
55 * $tpl is initialized at false for lazy loading. 58 * $tpl is initialized at false for lazy loading.
56 * 59 *
57 * @param ConfigManager $conf Configuration Manager instance (reference). 60 * @param ConfigManager $conf Configuration Manager instance (reference).
58 * @param array $session $_SESSION array 61 * @param array $session $_SESSION array
59 * @param BookmarkServiceInterface $linkDB instance. 62 * @param LoggerInterface $logger
60 * @param string $token Session token 63 * @param null $linkDB instance.
61 * @param bool $isLoggedIn 64 * @param null $token Session token
65 * @param bool $isLoggedIn
62 */ 66 */
63 public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) 67 public function __construct(
64 { 68 ConfigManager &$conf,
69 array $session,
70 LoggerInterface $logger,
71 $linkDB = null,
72 $token = null,
73 $isLoggedIn = false
74 ) {
65 $this->tpl = false; 75 $this->tpl = false;
66 $this->conf = $conf; 76 $this->conf = $conf;
67 $this->session = $session; 77 $this->session = $session;
78 $this->logger = $logger;
68 $this->bookmarkService = $linkDB; 79 $this->bookmarkService = $linkDB;
69 $this->token = $token; 80 $this->token = $token;
70 $this->isLoggedIn = $isLoggedIn; 81 $this->isLoggedIn = $isLoggedIn;
@@ -98,7 +109,7 @@ class PageBuilder
98 $this->tpl->assign('newVersion', escape($version)); 109 $this->tpl->assign('newVersion', escape($version));
99 $this->tpl->assign('versionError', ''); 110 $this->tpl->assign('versionError', '');
100 } catch (Exception $exc) { 111 } catch (Exception $exc) {
101 logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage()); 112 $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER)));
102 $this->tpl->assign('newVersion', ''); 113 $this->tpl->assign('newVersion', '');
103 $this->tpl->assign('versionError', escape($exc->getMessage())); 114 $this->tpl->assign('versionError', escape($exc->getMessage()));
104 } 115 }
@@ -149,7 +160,8 @@ class PageBuilder
149 160
150 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); 161 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
151 162
152 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']); 163 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
164 $this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' '));
153 165
154 // To be removed with a proper theme configuration. 166 // To be removed with a proper theme configuration.
155 $this->tpl->assign('conf', $this->conf); 167 $this->tpl->assign('conf', $this->conf);
diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php
index 97805c35..fe74bf27 100644
--- a/application/render/PageCacheManager.php
+++ b/application/render/PageCacheManager.php
@@ -2,6 +2,7 @@
2 2
3namespace Shaarli\Render; 3namespace Shaarli\Render;
4 4
5use DatePeriod;
5use Shaarli\Feed\CachedPage; 6use Shaarli\Feed\CachedPage;
6 7
7/** 8/**
@@ -49,12 +50,21 @@ class PageCacheManager
49 $this->purgeCachedPages(); 50 $this->purgeCachedPages();
50 } 51 }
51 52
52 public function getCachePage(string $pageUrl): CachedPage 53 /**
54 * Get CachedPage instance for provided URL.
55 *
56 * @param string $pageUrl
57 * @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache
58 *
59 * @return CachedPage
60 */
61 public function getCachePage(string $pageUrl, DatePeriod $validityPeriod = null): CachedPage
53 { 62 {
54 return new CachedPage( 63 return new CachedPage(
55 $this->pageCacheDir, 64 $this->pageCacheDir,
56 $pageUrl, 65 $pageUrl,
57 false === $this->isLoggedIn 66 false === $this->isLoggedIn,
67 $validityPeriod
58 ); 68 );
59 } 69 }
60} 70}
diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php
index 8af8228a..03b424f3 100644
--- a/application/render/TemplatePage.php
+++ b/application/render/TemplatePage.php
@@ -14,6 +14,7 @@ interface TemplatePage
14 public const DAILY = 'daily'; 14 public const DAILY = 'daily';
15 public const DAILY_RSS = 'dailyrss'; 15 public const DAILY_RSS = 'dailyrss';
16 public const EDIT_LINK = 'editlink'; 16 public const EDIT_LINK = 'editlink';
17 public const EDIT_LINK_BATCH = 'editlink.batch';
17 public const ERROR = 'error'; 18 public const ERROR = 'error';
18 public const EXPORT = 'export'; 19 public const EXPORT = 'export';
19 public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks'; 20 public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
diff --git a/application/render/ThemeUtils.php b/application/render/ThemeUtils.php
index 86096c64..18471f0a 100644
--- a/application/render/ThemeUtils.php
+++ b/application/render/ThemeUtils.php
@@ -23,10 +23,10 @@ class ThemeUtils
23 public static function getThemes($tplDir) 23 public static function getThemes($tplDir)
24 { 24 {
25 $tplDir = rtrim($tplDir, '/'); 25 $tplDir = rtrim($tplDir, '/');
26 $allTheme = glob($tplDir.'/*', GLOB_ONLYDIR); 26 $allTheme = glob($tplDir . '/*', GLOB_ONLYDIR);
27 $themes = []; 27 $themes = [];
28 foreach ($allTheme as $value) { 28 foreach ($allTheme as $value) {
29 $themes[] = str_replace($tplDir.'/', '', $value); 29 $themes[] = str_replace($tplDir . '/', '', $value);
30 } 30 }
31 31
32 return $themes; 32 return $themes;
diff --git a/application/security/BanManager.php b/application/security/BanManager.php
index 68190c54..7077af5b 100644
--- a/application/security/BanManager.php
+++ b/application/security/BanManager.php
@@ -1,9 +1,9 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Security; 3namespace Shaarli\Security;
5 4
6use Shaarli\FileUtils; 5use Psr\Log\LoggerInterface;
6use Shaarli\Helper\FileUtils;
7 7
8/** 8/**
9 * Class BanManager 9 * Class BanManager
@@ -28,8 +28,8 @@ class BanManager
28 /** @var string Path to the file containing IP bans and failures */ 28 /** @var string Path to the file containing IP bans and failures */
29 protected $banFile; 29 protected $banFile;
30 30
31 /** @var string Path to the log file, used to log bans */ 31 /** @var LoggerInterface Path to the log file, used to log bans */
32 protected $logFile; 32 protected $logger;
33 33
34 /** @var array List of IP with their associated number of failed attempts */ 34 /** @var array List of IP with their associated number of failed attempts */
35 protected $failures = []; 35 protected $failures = [];
@@ -40,18 +40,20 @@ class BanManager
40 /** 40 /**
41 * BanManager constructor. 41 * BanManager constructor.
42 * 42 *
43 * @param array $trustedProxies List of allowed proxies IP 43 * @param array $trustedProxies List of allowed proxies IP
44 * @param int $nbAttempts Number of allowed failed attempt before the ban 44 * @param int $nbAttempts Number of allowed failed attempt before the ban
45 * @param int $banDuration Ban duration in seconds 45 * @param int $banDuration Ban duration in seconds
46 * @param string $banFile Path to the file containing IP bans and failures 46 * @param string $banFile Path to the file containing IP bans and failures
47 * @param string $logFile Path to the log file, used to log bans 47 * @param LoggerInterface $logger PSR-3 logger to save login attempts in log directory
48 */ 48 */
49 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) { 49 public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger)
50 {
50 $this->trustedProxies = $trustedProxies; 51 $this->trustedProxies = $trustedProxies;
51 $this->nbAttempts = $nbAttempts; 52 $this->nbAttempts = $nbAttempts;
52 $this->banDuration = $banDuration; 53 $this->banDuration = $banDuration;
53 $this->banFile = $banFile; 54 $this->banFile = $banFile;
54 $this->logFile = $logFile; 55 $this->logger = $logger;
56
55 $this->readBanFile(); 57 $this->readBanFile();
56 } 58 }
57 59
@@ -78,11 +80,7 @@ class BanManager
78 80
79 if ($this->failures[$ip] >= $this->nbAttempts) { 81 if ($this->failures[$ip] >= $this->nbAttempts) {
80 $this->bans[$ip] = time() + $this->banDuration; 82 $this->bans[$ip] = time() + $this->banDuration;
81 logm( 83 $this->logger->info(format_log('IP address banned from login: ' . $ip, $ip));
82 $this->logFile,
83 $server['REMOTE_ADDR'],
84 'IP address banned from login: '. $ip
85 );
86 } 84 }
87 $this->writeBanFile(); 85 $this->writeBanFile();
88 } 86 }
@@ -138,7 +136,7 @@ class BanManager
138 unset($this->failures[$ip]); 136 unset($this->failures[$ip]);
139 } 137 }
140 unset($this->bans[$ip]); 138 unset($this->bans[$ip]);
141 logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip); 139 $this->logger->info(format_log('Ban lifted for: ' . $ip, $ip));
142 140
143 $this->writeBanFile(); 141 $this->writeBanFile();
144 return false; 142 return false;
diff --git a/application/security/LoginManager.php b/application/security/LoginManager.php
index 65048f10..b795b80e 100644
--- a/application/security/LoginManager.php
+++ b/application/security/LoginManager.php
@@ -1,7 +1,9 @@
1<?php 1<?php
2
2namespace Shaarli\Security; 3namespace Shaarli\Security;
3 4
4use Exception; 5use Exception;
6use Psr\Log\LoggerInterface;
5use Shaarli\Config\ConfigManager; 7use Shaarli\Config\ConfigManager;
6 8
7/** 9/**
@@ -31,26 +33,30 @@ class LoginManager
31 protected $staySignedInToken = ''; 33 protected $staySignedInToken = '';
32 /** @var CookieManager */ 34 /** @var CookieManager */
33 protected $cookieManager; 35 protected $cookieManager;
36 /** @var LoggerInterface */
37 protected $logger;
34 38
35 /** 39 /**
36 * Constructor 40 * Constructor
37 * 41 *
38 * @param ConfigManager $configManager Configuration Manager instance 42 * @param ConfigManager $configManager Configuration Manager instance
39 * @param SessionManager $sessionManager SessionManager instance 43 * @param SessionManager $sessionManager SessionManager instance
40 * @param CookieManager $cookieManager CookieManager instance 44 * @param CookieManager $cookieManager CookieManager instance
45 * @param BanManager $banManager
46 * @param LoggerInterface $logger Used to log login attempts
41 */ 47 */
42 public function __construct($configManager, $sessionManager, $cookieManager) 48 public function __construct(
43 { 49 ConfigManager $configManager,
50 SessionManager $sessionManager,
51 CookieManager $cookieManager,
52 BanManager $banManager,
53 LoggerInterface $logger
54 ) {
44 $this->configManager = $configManager; 55 $this->configManager = $configManager;
45 $this->sessionManager = $sessionManager; 56 $this->sessionManager = $sessionManager;
46 $this->cookieManager = $cookieManager; 57 $this->cookieManager = $cookieManager;
47 $this->banManager = new BanManager( 58 $this->banManager = $banManager;
48 $this->configManager->get('security.trusted_proxies', []), 59 $this->logger = $logger;
49 $this->configManager->get('security.ban_after'),
50 $this->configManager->get('security.ban_duration'),
51 $this->configManager->get('resource.ban_file', 'data/ipbans.php'),
52 $this->configManager->get('resource.log')
53 );
54 60
55 if ($this->configManager->get('security.open_shaarli') === true) { 61 if ($this->configManager->get('security.open_shaarli') === true) {
56 $this->openShaarli = true; 62 $this->openShaarli = true;
@@ -101,7 +107,8 @@ class LoginManager
101 // The user client has a valid stay-signed-in cookie 107 // The user client has a valid stay-signed-in cookie
102 // Session information is updated with the current client information 108 // Session information is updated with the current client information
103 $this->sessionManager->storeLoginInfo($clientIpId); 109 $this->sessionManager->storeLoginInfo($clientIpId);
104 } elseif ($this->sessionManager->hasSessionExpired() 110 } elseif (
111 $this->sessionManager->hasSessionExpired()
105 || $this->sessionManager->hasClientIpChanged($clientIpId) 112 || $this->sessionManager->hasClientIpChanged($clientIpId)
106 ) { 113 ) {
107 $this->sessionManager->logout(); 114 $this->sessionManager->logout();
@@ -129,48 +136,35 @@ class LoginManager
129 /** 136 /**
130 * Check user credentials are valid 137 * Check user credentials are valid
131 * 138 *
132 * @param string $remoteIp Remote client IP address
133 * @param string $clientIpId Client IP address identifier 139 * @param string $clientIpId Client IP address identifier
134 * @param string $login Username 140 * @param string $login Username
135 * @param string $password Password 141 * @param string $password Password
136 * 142 *
137 * @return bool true if the provided credentials are valid, false otherwise 143 * @return bool true if the provided credentials are valid, false otherwise
138 */ 144 */
139 public function checkCredentials($remoteIp, $clientIpId, $login, $password) 145 public function checkCredentials($clientIpId, $login, $password)
140 { 146 {
141 // Check login matches config
142 if ($login !== $this->configManager->get('credentials.login')) {
143 return false;
144 }
145
146 // Check credentials 147 // Check credentials
147 try { 148 try {
148 $useLdapLogin = !empty($this->configManager->get('ldap.host')); 149 $useLdapLogin = !empty($this->configManager->get('ldap.host'));
149 if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password)) 150 if (
150 || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password)) 151 $login === $this->configManager->get('credentials.login')
152 && (
153 (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
154 || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
155 )
151 ) { 156 ) {
152 $this->sessionManager->storeLoginInfo($clientIpId); 157 $this->sessionManager->storeLoginInfo($clientIpId);
153 logm( 158 $this->logger->info(format_log('Login successful', $clientIpId));
154 $this->configManager->get('resource.log'), 159
155 $remoteIp, 160 return true;
156 'Login successful'
157 );
158 return true;
159 } 161 }
160 } 162 } catch (Exception $exception) {
161 catch(Exception $exception) { 163 $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId));
162 logm(
163 $this->configManager->get('resource.log'),
164 $remoteIp,
165 'Exception while checking credentials: ' . $exception
166 );
167 } 164 }
168 165
169 logm( 166 $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId));
170 $this->configManager->get('resource.log'), 167
171 $remoteIp,
172 'Login failed for user ' . $login
173 );
174 return false; 168 return false;
175 } 169 }
176 170
@@ -183,7 +177,8 @@ class LoginManager
183 * 177 *
184 * @return bool true if the provided credentials are valid, false otherwise 178 * @return bool true if the provided credentials are valid, false otherwise
185 */ 179 */
186 public function checkCredentialsFromLocalConfig($login, $password) { 180 public function checkCredentialsFromLocalConfig($login, $password)
181 {
187 $hash = sha1($password . $login . $this->configManager->get('credentials.salt')); 182 $hash = sha1($password . $login . $this->configManager->get('credentials.salt'));
188 183
189 return $login == $this->configManager->get('credentials.login') 184 return $login == $this->configManager->get('credentials.login')
@@ -202,14 +197,14 @@ class LoginManager
202 */ 197 */
203 public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null) 198 public function checkCredentialsFromLdap($login, $password, $connect = null, $bind = null)
204 { 199 {
205 $connect = $connect ?? function($host) { 200 $connect = $connect ?? function ($host) {
206 $resource = ldap_connect($host); 201 $resource = ldap_connect($host);
207 202
208 ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3); 203 ldap_set_option($resource, LDAP_OPT_PROTOCOL_VERSION, 3);
209 204
210 return $resource; 205 return $resource;
211 }; 206 };
212 $bind = $bind ?? function($handle, $dn, $password) { 207 $bind = $bind ?? function ($handle, $dn, $password) {
213 return ldap_bind($handle, $dn, $password); 208 return ldap_bind($handle, $dn, $password);
214 }; 209 };
215 210
diff --git a/application/security/SessionManager.php b/application/security/SessionManager.php
index 36df8c1c..f957b91a 100644
--- a/application/security/SessionManager.php
+++ b/application/security/SessionManager.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Security; 3namespace Shaarli\Security;
3 4
4use Shaarli\Config\ConfigManager; 5use Shaarli\Config\ConfigManager;
@@ -79,7 +80,7 @@ class SessionManager
79 */ 80 */
80 public function generateToken() 81 public function generateToken()
81 { 82 {
82 $token = sha1(uniqid('', true) .'_'. mt_rand() . $this->conf->get('credentials.salt')); 83 $token = sha1(uniqid('', true) . '_' . mt_rand() . $this->conf->get('credentials.salt'));
83 $this->session['tokens'][$token] = 1; 84 $this->session['tokens'][$token] = 1;
84 return $token; 85 return $token;
85 } 86 }
@@ -293,9 +294,12 @@ class SessionManager
293 return session_start(); 294 return session_start();
294 } 295 }
295 296
296 public function cookieParameters(int $lifeTime, string $path, string $domain): bool 297 /**
298 * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
299 */
300 public function cookieParameters(int $lifeTime, string $path, string $domain): void
297 { 301 {
298 return session_set_cookie_params($lifeTime, $path, $domain); 302 session_set_cookie_params($lifeTime, $path, $domain);
299 } 303 }
300 304
301 public function regenerateId(bool $deleteOldSession = false): bool 305 public function regenerateId(bool $deleteOldSession = false): bool
diff --git a/application/updater/Updater.php b/application/updater/Updater.php
index 88a7bc7b..4f557d0f 100644
--- a/application/updater/Updater.php
+++ b/application/updater/Updater.php
@@ -88,7 +88,8 @@ class Updater
88 88
89 foreach ($this->methods as $method) { 89 foreach ($this->methods as $method) {
90 // Not an update method or already done, pass. 90 // Not an update method or already done, pass.
91 if (! startsWith($method->getName(), 'updateMethod') 91 if (
92 ! startsWith($method->getName(), 'updateMethod')
92 || in_array($method->getName(), $this->doneUpdates) 93 || in_array($method->getName(), $this->doneUpdates)
93 ) { 94 ) {
94 continue; 95 continue;
@@ -121,12 +122,12 @@ class Updater
121 122
122 public function readUpdates(string $updatesFilepath): array 123 public function readUpdates(string $updatesFilepath): array
123 { 124 {
124 return UpdaterUtils::read_updates_file($updatesFilepath); 125 return UpdaterUtils::readUpdatesFile($updatesFilepath);
125 } 126 }
126 127
127 public function writeUpdates(string $updatesFilepath, array $updates): void 128 public function writeUpdates(string $updatesFilepath, array $updates): void
128 { 129 {
129 UpdaterUtils::write_updates_file($updatesFilepath, $updates); 130 UpdaterUtils::writeUpdatesFile($updatesFilepath, $updates);
130 } 131 }
131 132
132 /** 133 /**
@@ -152,7 +153,8 @@ class Updater
152 $updated = false; 153 $updated = false;
153 154
154 foreach ($this->bookmarkService->search() as $bookmark) { 155 foreach ($this->bookmarkService->search() as $bookmark) {
155 if ($bookmark->isNote() 156 if (
157 $bookmark->isNote()
156 && startsWith($bookmark->getUrl(), '?') 158 && startsWith($bookmark->getUrl(), '?')
157 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match) 159 && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
158 ) { 160 ) {
diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php
index 828a49fc..206f826e 100644
--- a/application/updater/UpdaterUtils.php
+++ b/application/updater/UpdaterUtils.php
@@ -11,7 +11,7 @@ class UpdaterUtils
11 * 11 *
12 * @return array Already done update methods. 12 * @return array Already done update methods.
13 */ 13 */
14 public static function read_updates_file($updatesFilepath) 14 public static function readUpdatesFile($updatesFilepath)
15 { 15 {
16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) { 16 if (! empty($updatesFilepath) && is_file($updatesFilepath)) {
17 $content = file_get_contents($updatesFilepath); 17 $content = file_get_contents($updatesFilepath);
@@ -19,7 +19,7 @@ class UpdaterUtils
19 return explode(';', $content); 19 return explode(';', $content);
20 } 20 }
21 } 21 }
22 return array(); 22 return [];
23 } 23 }
24 24
25 /** 25 /**
@@ -30,7 +30,7 @@ class UpdaterUtils
30 * 30 *
31 * @throws \Exception Couldn't write version number. 31 * @throws \Exception Couldn't write version number.
32 */ 32 */
33 public static function write_updates_file($updatesFilepath, $updates) 33 public static function writeUpdatesFile($updatesFilepath, $updates)
34 { 34 {
35 if (empty($updatesFilepath)) { 35 if (empty($updatesFilepath)) {
36 throw new \Exception('Updates file path is not set, can\'t write updates.'); 36 throw new \Exception('Updates file path is not set, can\'t write updates.');
@@ -38,7 +38,7 @@ class UpdaterUtils
38 38
39 $res = file_put_contents($updatesFilepath, implode(';', $updates)); 39 $res = file_put_contents($updatesFilepath, implode(';', $updates));
40 if ($res === false) { 40 if ($res === false) {
41 throw new \Exception('Unable to write updates in '. $updatesFilepath . '.'); 41 throw new \Exception('Unable to write updates in ' . $updatesFilepath . '.');
42 } 42 }
43 } 43 }
44} 44}
diff --git a/assets/common/js/metadata.js b/assets/common/js/metadata.js
new file mode 100644
index 00000000..d5a28a35
--- /dev/null
+++ b/assets/common/js/metadata.js
@@ -0,0 +1,107 @@
1import he from 'he';
2
3/**
4 * This script is used to retrieve bookmarks metadata asynchronously:
5 * - title, description and keywords while creating a new bookmark
6 * - thumbnails while visiting the bookmark list
7 *
8 * Note: it should only be included if the user is logged in
9 * and the setting general.enable_async_metadata is enabled.
10 */
11
12/**
13 * Removes given input loaders - used in edit link template.
14 *
15 * @param {object} loaders List of input DOM element that need to be cleared
16 */
17function clearLoaders(loaders) {
18 if (loaders != null && loaders.length > 0) {
19 [...loaders].forEach((loader) => {
20 loader.classList.remove('loading-input');
21 });
22 }
23}
24
25/**
26 * AJAX request to update the thumbnail of a bookmark with the provided ID.
27 * If a thumbnail is retrieved, it updates the divElement with the image src, and displays it.
28 *
29 * @param {string} basePath Shaarli subfolder for XHR requests
30 * @param {object} divElement Main <div> DOM element containing the thumbnail placeholder
31 * @param {int} id Bookmark ID to update
32 */
33function updateThumb(basePath, divElement, id) {
34 const xhr = new XMLHttpRequest();
35 xhr.open('PATCH', `${basePath}/admin/shaare/${id}/update-thumbnail`);
36 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
37 xhr.responseType = 'json';
38 xhr.onload = () => {
39 if (xhr.status !== 200) {
40 alert(`An error occurred. Return code: ${xhr.status}`);
41 } else {
42 const { response } = xhr;
43
44 if (response.thumbnail !== false) {
45 const imgElement = divElement.querySelector('img');
46
47 imgElement.src = response.thumbnail;
48 imgElement.dataset.src = response.thumbnail;
49 imgElement.style.opacity = '1';
50 divElement.classList.remove('hidden');
51 }
52 }
53 };
54 xhr.send();
55}
56
57(() => {
58 const basePath = document.querySelector('input[name="js_base_path"]').value;
59
60 /*
61 * METADATA FOR EDIT BOOKMARK PAGE
62 */
63 const inputTitles = document.querySelectorAll('input[name="lf_title"]');
64 if (inputTitles != null) {
65 [...inputTitles].forEach((inputTitle) => {
66 const form = inputTitle.closest('form[name="linkform"]');
67 const loaders = form.querySelectorAll('.loading-input');
68
69 if (inputTitle.value.length > 0) {
70 clearLoaders(loaders);
71 return;
72 }
73
74 const url = form.querySelector('input[name="lf_url"]').value;
75
76 const xhr = new XMLHttpRequest();
77 xhr.open('GET', `${basePath}/admin/metadata?url=${encodeURI(url)}`, true);
78 xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
79 xhr.onload = () => {
80 const result = JSON.parse(xhr.response);
81 Object.keys(result).forEach((key) => {
82 if (result[key] !== null && result[key].length) {
83 const element = form.querySelector(`input[name="lf_${key}"], textarea[name="lf_${key}"]`);
84 if (element != null && element.value.length === 0) {
85 element.value = he.decode(result[key]);
86 }
87 }
88 });
89 clearLoaders(loaders);
90 };
91
92 xhr.send();
93 });
94 }
95
96 /*
97 * METADATA FOR THUMBNAIL RETRIEVAL
98 */
99 const thumbsToLoad = document.querySelectorAll('div[data-async-thumbnail]');
100 if (thumbsToLoad != null) {
101 [...thumbsToLoad].forEach((divElement) => {
102 const { id } = divElement.closest('[data-id]').dataset;
103
104 updateThumb(basePath, divElement, id);
105 });
106 }
107})();
diff --git a/assets/common/js/shaare-batch.js b/assets/common/js/shaare-batch.js
new file mode 100644
index 00000000..557325ee
--- /dev/null
+++ b/assets/common/js/shaare-batch.js
@@ -0,0 +1,121 @@
1const sendBookmarkForm = (basePath, formElement) => {
2 const inputs = formElement
3 .querySelectorAll('input[type="text"], textarea, input[type="checkbox"], input[type="hidden"]');
4
5 const formData = new FormData();
6 [...inputs].forEach((input) => {
7 formData.append(input.getAttribute('name'), input.value);
8 });
9
10 return new Promise((resolve, reject) => {
11 const xhr = new XMLHttpRequest();
12 xhr.open('POST', `${basePath}/admin/shaare`);
13 xhr.onload = () => {
14 if (xhr.status !== 200) {
15 alert(`An error occurred. Return code: ${xhr.status}`);
16 reject();
17 } else {
18 formElement.closest('.edit-link-container').remove();
19 resolve();
20 }
21 };
22 xhr.send(formData);
23 });
24};
25
26const sendBookmarkDelete = (buttonElement, formElement) => (
27 new Promise((resolve, reject) => {
28 const xhr = new XMLHttpRequest();
29 xhr.open('GET', buttonElement.href);
30 xhr.onload = () => {
31 if (xhr.status !== 200) {
32 alert(`An error occurred. Return code: ${xhr.status}`);
33 reject();
34 } else {
35 formElement.closest('.edit-link-container').remove();
36 resolve();
37 }
38 };
39 xhr.send();
40 })
41);
42
43const redirectIfEmptyBatch = (basePath, formElements, path) => {
44 if (formElements == null || formElements.length === 0) {
45 window.location.href = `${basePath}${path}`;
46 }
47};
48
49(() => {
50 const basePath = document.querySelector('input[name="js_base_path"]').value;
51 const getForms = () => document.querySelectorAll('form[name="linkform"]');
52
53 const cancelButtons = document.querySelectorAll('[name="cancel-batch-link"]');
54 if (cancelButtons != null) {
55 [...cancelButtons].forEach((cancelButton) => {
56 cancelButton.addEventListener('click', (e) => {
57 e.preventDefault();
58 e.target.closest('form[name="linkform"]').remove();
59 redirectIfEmptyBatch(basePath, getForms(), '/admin/add-shaare');
60 });
61 });
62 }
63
64 const saveButtons = document.querySelectorAll('[name="save_edit"]');
65 if (saveButtons != null) {
66 [...saveButtons].forEach((saveButton) => {
67 saveButton.addEventListener('click', (e) => {
68 e.preventDefault();
69
70 const formElement = e.target.closest('form[name="linkform"]');
71 sendBookmarkForm(basePath, formElement)
72 .then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
73 });
74 });
75 }
76
77 const saveAllButtons = document.querySelectorAll('[name="save_edit_batch"]');
78 if (saveAllButtons != null) {
79 [...saveAllButtons].forEach((saveAllButton) => {
80 saveAllButton.addEventListener('click', (e) => {
81 e.preventDefault();
82
83 const forms = [...getForms()];
84 const nbForm = forms.length;
85 let current = 0;
86 const progressBar = document.querySelector('.progressbar > div');
87 const progressBarCurrent = document.querySelector('.progressbar-current');
88
89 document.querySelector('.dark-layer').style.display = 'block';
90 document.querySelector('.progressbar-max').innerHTML = nbForm;
91 progressBarCurrent.innerHTML = current;
92
93 const promises = [];
94 forms.forEach((formElement) => {
95 promises.push(sendBookmarkForm(basePath, formElement).then(() => {
96 current += 1;
97 progressBar.style.width = `${(current * 100) / nbForm}%`;
98 progressBarCurrent.innerHTML = current;
99 }));
100 });
101
102 Promise.all(promises).then(() => {
103 window.location.href = basePath || '/';
104 });
105 });
106 });
107 }
108
109 const deleteButtons = document.querySelectorAll('[name="delete_link"]');
110 if (deleteButtons != null) {
111 [...deleteButtons].forEach((deleteButton) => {
112 deleteButton.addEventListener('click', (e) => {
113 e.preventDefault();
114
115 const formElement = e.target.closest('form[name="linkform"]');
116 sendBookmarkDelete(e.target, formElement)
117 .then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
118 });
119 });
120 }
121})();
diff --git a/assets/default/js/base.js b/assets/default/js/base.js
index aadffc13..dd532bb7 100644
--- a/assets/default/js/base.js
+++ b/assets/default/js/base.js
@@ -1,4 +1,5 @@
1import Awesomplete from 'awesomplete'; 1import Awesomplete from 'awesomplete';
2import he from 'he';
2 3
3/** 4/**
4 * Find a parent element according to its tag and its attributes 5 * Find a parent element according to its tag and its attributes
@@ -41,19 +42,21 @@ function refreshToken(basePath, callback) {
41 xhr.send(); 42 xhr.send();
42} 43}
43 44
44function createAwesompleteInstance(element, tags = []) { 45function createAwesompleteInstance(element, separator, tags = []) {
45 const awesome = new Awesomplete(Awesomplete.$(element)); 46 const awesome = new Awesomplete(Awesomplete.$(element));
46 // Tags are separated by a space 47
47 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); 48 // Tags are separated by separator
49 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
48 // Insert new selected tag in the input 50 // Insert new selected tag in the input
49 awesome.replace = (text) => { 51 awesome.replace = (text) => {
50 const before = awesome.input.value.match(/^.+ \s*|/)[0]; 52 const before = awesome.input.value.match(new RegExp(`^.+${separator}+|`))[0];
51 awesome.input.value = `${before}${text} `; 53 awesome.input.value = `${before}${text}${separator}`;
52 }; 54 };
53 // Highlight found items 55 // Highlight found items
54 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]); 56 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
55 // Don't display already selected items 57 // Don't display already selected items
56 const reg = /(\w+) /g; 58 // WARNING: pseudo classes does not seem to work with string litterals...
59 const reg = new RegExp(`([^${separator}]+)${separator}`, 'g');
57 let match; 60 let match;
58 awesome.data = (item, input) => { 61 awesome.data = (item, input) => {
59 while ((match = reg.exec(input))) { 62 while ((match = reg.exec(input))) {
@@ -77,13 +80,14 @@ function createAwesompleteInstance(element, tags = []) {
77 * @param selector CSS selector 80 * @param selector CSS selector
78 * @param tags Array of tags 81 * @param tags Array of tags
79 * @param instances List of existing awesomplete instances 82 * @param instances List of existing awesomplete instances
83 * @param separator Tags separator character
80 */ 84 */
81function updateAwesompleteList(selector, tags, instances) { 85function updateAwesompleteList(selector, tags, instances, separator) {
82 if (instances.length === 0) { 86 if (instances.length === 0) {
83 // First load: create Awesomplete instances 87 // First load: create Awesomplete instances
84 const elements = document.querySelectorAll(selector); 88 const elements = document.querySelectorAll(selector);
85 [...elements].forEach((element) => { 89 [...elements].forEach((element) => {
86 instances.push(createAwesompleteInstance(element, tags)); 90 instances.push(createAwesompleteInstance(element, separator, tags));
87 }); 91 });
88 } else { 92 } else {
89 // Update awesomplete tag list 93 // Update awesomplete tag list
@@ -96,15 +100,6 @@ function updateAwesompleteList(selector, tags, instances) {
96} 100}
97 101
98/** 102/**
99 * html_entities in JS
100 *
101 * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
102 */
103function htmlEntities(str) {
104 return str.replace(/[\u00A0-\u9999<>&]/gim, (i) => `&#${i.charCodeAt(0)};`);
105}
106
107/**
108 * Add the class 'hidden' to city options not attached to the current selected continent. 103 * Add the class 'hidden' to city options not attached to the current selected continent.
109 * 104 *
110 * @param cities List of <option> elements 105 * @param cities List of <option> elements
@@ -222,6 +217,8 @@ function init(description) {
222 217
223(() => { 218(() => {
224 const basePath = document.querySelector('input[name="js_base_path"]').value; 219 const basePath = document.querySelector('input[name="js_base_path"]').value;
220 const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
221 const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
225 222
226 /** 223 /**
227 * Handle responsive menu. 224 * Handle responsive menu.
@@ -302,7 +299,8 @@ function init(description) {
302 const deleteLinks = document.querySelectorAll('.confirm-delete'); 299 const deleteLinks = document.querySelectorAll('.confirm-delete');
303 [...deleteLinks].forEach((deleteLink) => { 300 [...deleteLinks].forEach((deleteLink) => {
304 deleteLink.addEventListener('click', (event) => { 301 deleteLink.addEventListener('click', (event) => {
305 if (!confirm(document.getElementById('translation-delete-tag').innerHTML)) { 302 const type = event.currentTarget.getAttribute('data-type') || 'link';
303 if (!confirm(document.getElementById(`translation-delete-${type}`).innerHTML)) {
306 event.preventDefault(); 304 event.preventDefault();
307 } 305 }
308 }); 306 });
@@ -569,7 +567,7 @@ function init(description) {
569 input.setAttribute('name', totag); 567 input.setAttribute('name', totag);
570 input.setAttribute('value', totag); 568 input.setAttribute('value', totag);
571 findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none'; 569 findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
572 block.querySelector('a.tag-link').innerHTML = htmlEntities(totag); 570 block.querySelector('a.tag-link').innerHTML = he.encode(totag);
573 block 571 block
574 .querySelector('a.tag-link') 572 .querySelector('a.tag-link')
575 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`); 573 .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
@@ -582,7 +580,7 @@ function init(description) {
582 580
583 // Refresh awesomplete values 581 // Refresh awesomplete values
584 existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag)); 582 existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
585 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); 583 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
586 } 584 }
587 }; 585 };
588 xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`); 586 xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
@@ -622,14 +620,14 @@ function init(description) {
622 refreshToken(basePath); 620 refreshToken(basePath);
623 621
624 existingTags = existingTags.filter((tagItem) => tagItem !== tag); 622 existingTags = existingTags.filter((tagItem) => tagItem !== tag);
625 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); 623 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
626 } 624 }
627 }); 625 });
628 }); 626 });
629 627
630 const autocompleteFields = document.querySelectorAll('input[data-multiple]'); 628 const autocompleteFields = document.querySelectorAll('input[data-multiple]');
631 [...autocompleteFields].forEach((autocompleteField) => { 629 [...autocompleteFields].forEach((autocompleteField) => {
632 awesomepletes.push(createAwesompleteInstance(autocompleteField)); 630 awesomepletes.push(createAwesompleteInstance(autocompleteField, tagsSeparator));
633 }); 631 });
634 632
635 const exportForm = document.querySelector('#exportform'); 633 const exportForm = document.querySelector('#exportform');
@@ -642,4 +640,33 @@ function init(description) {
642 }); 640 });
643 }); 641 });
644 } 642 }
643
644 const bulkCreationButton = document.querySelector('.addlink-batch-show-more-block');
645 if (bulkCreationButton != null) {
646 const toggleBulkCreationVisibility = (showMoreBlockElement, formElement) => {
647 if (bulkCreationButton.classList.contains('pure-u-0')) {
648 showMoreBlockElement.classList.remove('pure-u-0');
649 formElement.classList.add('pure-u-0');
650 } else {
651 showMoreBlockElement.classList.add('pure-u-0');
652 formElement.classList.remove('pure-u-0');
653 }
654 };
655
656 const bulkCreationForm = document.querySelector('.addlink-batch-form-block');
657
658 toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
659 bulkCreationButton.querySelector('a').addEventListener('click', (e) => {
660 e.preventDefault();
661 toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
662 });
663
664 // Force to send falsy value if the checkbox is not checked.
665 const privateButton = bulkCreationForm.querySelector('input[type="checkbox"][name="private"]');
666 const privateHiddenButton = bulkCreationForm.querySelector('input[type="hidden"][name="private"]');
667 privateButton.addEventListener('click', () => {
668 privateHiddenButton.disabled = !privateHiddenButton.disabled;
669 });
670 privateHiddenButton.disabled = privateButton.checked;
671 }
645})(); 672})();
diff --git a/assets/default/scss/shaarli.scss b/assets/default/scss/shaarli.scss
index 2f49bbd2..cc8ccc1e 100644
--- a/assets/default/scss/shaarli.scss
+++ b/assets/default/scss/shaarli.scss
@@ -139,6 +139,16 @@ body,
139 } 139 }
140} 140}
141 141
142.page-form,
143.pure-alert {
144 code {
145 display: inline-block;
146 padding: 0 2px;
147 color: $dark-grey;
148 background-color: var(--background-color);
149 }
150}
151
142// Make pure-extras alert closable. 152// Make pure-extras alert closable.
143.pure-alert-closable { 153.pure-alert-closable {
144 .fa-times { 154 .fa-times {
@@ -1023,6 +1033,10 @@ body,
1023 &.button-red { 1033 &.button-red {
1024 background: $red; 1034 background: $red;
1025 } 1035 }
1036
1037 &.button-grey {
1038 background: $light-grey;
1039 }
1026 } 1040 }
1027 1041
1028 .submit-buttons { 1042 .submit-buttons {
@@ -1047,7 +1061,7 @@ body,
1047 } 1061 }
1048 1062
1049 table { 1063 table {
1050 margin: auto; 1064 margin: 10px auto 25px auto;
1051 width: 90%; 1065 width: 90%;
1052 1066
1053 .order { 1067 .order {
@@ -1083,6 +1097,11 @@ body,
1083 position: absolute; 1097 position: absolute;
1084 right: 5%; 1098 right: 5%;
1085 } 1099 }
1100
1101 &.button-grey {
1102 position: absolute;
1103 left: 5%;
1104 }
1086 } 1105 }
1087 } 1106 }
1088 } 1107 }
@@ -1257,11 +1276,15 @@ form {
1257 margin: 70px 0 25px; 1276 margin: 70px 0 25px;
1258 } 1277 }
1259 1278
1279 a {
1280 color: var(--main-color);
1281 }
1282
1260 pre { 1283 pre {
1261 margin: 0 20%; 1284 margin: 0 20%;
1262 padding: 20px 0; 1285 padding: 20px 0;
1263 text-align: left; 1286 text-align: left;
1264 line-height: .7em; 1287 line-height: 1em;
1265 } 1288 }
1266} 1289}
1267 1290
@@ -1273,6 +1296,57 @@ form {
1273 } 1296 }
1274} 1297}
1275 1298
1299.loading-input {
1300 position: relative;
1301
1302 @keyframes around {
1303 0% {
1304 transform: rotate(0deg);
1305 }
1306
1307 100% {
1308 transform: rotate(360deg);
1309 }
1310 }
1311
1312 .icon-container {
1313 position: absolute;
1314 right: 60px;
1315 top: calc(50% - 10px);
1316 }
1317
1318 .loader {
1319 position: relative;
1320 height: 20px;
1321 width: 20px;
1322 display: inline-block;
1323 animation: around 5.4s infinite;
1324
1325 &::after,
1326 &::before {
1327 content: "";
1328 background: $form-input-background;
1329 position: absolute;
1330 display: inline-block;
1331 width: 100%;
1332 height: 100%;
1333 border-width: 2px;
1334 border-color: #333 #333 transparent transparent;
1335 border-style: solid;
1336 border-radius: 20px;
1337 box-sizing: border-box;
1338 top: 0;
1339 left: 0;
1340 animation: around 0.7s ease-in-out infinite;
1341 }
1342
1343 &::after {
1344 animation: around 0.7s ease-in-out 0.1s infinite;
1345 background: transparent;
1346 }
1347 }
1348}
1349
1276// LOGIN 1350// LOGIN
1277.login-form-container { 1351.login-form-container {
1278 .remember-me { 1352 .remember-me {
@@ -1645,6 +1719,123 @@ form {
1645 } 1719 }
1646} 1720}
1647 1721
1722// SERVER PAGE
1723
1724.server-tables-page,
1725.server-tables {
1726 .window-subtitle {
1727 &::before {
1728 display: block;
1729 margin: 8px auto;
1730 background: linear-gradient(to right, var(--background-color), $dark-grey, var(--background-color));
1731 width: 50%;
1732 height: 1px;
1733 content: '';
1734 }
1735 }
1736
1737 .server-row {
1738 p {
1739 height: 25px;
1740 padding: 0 10px;
1741 }
1742 }
1743
1744 .server-label {
1745 text-align: right;
1746 font-weight: bold;
1747 }
1748
1749 i {
1750 &.fa-color-green {
1751 color: $main-green;
1752 }
1753
1754 &.fa-color-orange {
1755 color: $orange;
1756 }
1757
1758 &.fa-color-red {
1759 color: $red;
1760 }
1761 }
1762
1763 @media screen and (max-width: 64em) {
1764 .server-label {
1765 text-align: center;
1766 }
1767
1768 .server-row {
1769 p {
1770 text-align: center;
1771 }
1772 }
1773 }
1774}
1775
1776// Batch creation
1777input[name='save_edit_batch'] {
1778 @extend %page-form-button;
1779}
1780
1781.addlink-batch-show-more {
1782 display: flex;
1783 align-items: center;
1784 margin: 20px 0 8px;
1785
1786 a {
1787 color: var(--main-color);
1788 text-decoration: none;
1789 }
1790
1791 &::before,
1792 &::after {
1793 content: "";
1794 flex-grow: 1;
1795 background: rgba(0, 0, 0, 0.35);
1796 height: 1px;
1797 font-size: 0;
1798 line-height: 0;
1799 }
1800
1801 &::before {
1802 margin: 0 16px 0 0;
1803 }
1804
1805 &::after {
1806 margin: 0 0 0 16px;
1807 }
1808}
1809
1810.dark-layer {
1811 display: none;
1812 position: fixed;
1813 height: 100%;
1814 width: 100%;
1815 z-index: 998;
1816 background-color: rgba(0, 0, 0, .75);
1817 color: #fff;
1818
1819 .screen-center {
1820 display: flex;
1821 flex-direction: column;
1822 justify-content: center;
1823 align-items: center;
1824 text-align: center;
1825 min-height: 100vh;
1826 }
1827
1828 .progressbar {
1829 width: 33%;
1830 }
1831}
1832
1833.addlink-batch-form-block {
1834 .pure-alert {
1835 margin: 25px 0 0 0;
1836 }
1837}
1838
1648// Print rules 1839// Print rules
1649@media print { 1840@media print {
1650 .shaarli-menu { 1841 .shaarli-menu {
diff --git a/assets/vintage/css/shaarli.css b/assets/vintage/css/shaarli.css
index 1688dce0..33e178af 100644
--- a/assets/vintage/css/shaarli.css
+++ b/assets/vintage/css/shaarli.css
@@ -1122,6 +1122,16 @@ ul.errors {
1122 float: left; 1122 float: left;
1123} 1123}
1124 1124
1125ul.warnings {
1126 color: orange;
1127 float: left;
1128}
1129
1130ul.successes {
1131 color: green;
1132 float: left;
1133}
1134
1125#pluginsadmin { 1135#pluginsadmin {
1126 width: 80%; 1136 width: 80%;
1127 padding: 20px 0 0 20px; 1137 padding: 20px 0 0 20px;
@@ -1248,3 +1258,54 @@ ul.errors {
1248 width: 0%; 1258 width: 0%;
1249 height: 10px; 1259 height: 10px;
1250} 1260}
1261
1262.loading-input {
1263 position: relative;
1264}
1265
1266@keyframes around {
1267 0% {
1268 transform: rotate(0deg);
1269 }
1270
1271 100% {
1272 transform: rotate(360deg);
1273 }
1274}
1275
1276.loading-input .icon-container {
1277 position: absolute;
1278 right: 60px;
1279 top: calc(50% - 10px);
1280}
1281
1282.loading-input .loader {
1283 position: relative;
1284 height: 20px;
1285 width: 20px;
1286 display: inline-block;
1287 animation: around 5.4s infinite;
1288}
1289
1290.loading-input .loader::after,
1291.loading-input .loader::before {
1292 content: "";
1293 background: #eee;
1294 position: absolute;
1295 display: inline-block;
1296 width: 100%;
1297 height: 100%;
1298 border-width: 2px;
1299 border-color: #333 #333 transparent transparent;
1300 border-style: solid;
1301 border-radius: 20px;
1302 box-sizing: border-box;
1303 top: 0;
1304 left: 0;
1305 animation: around 0.7s ease-in-out infinite;
1306}
1307
1308.loading-input .loader::after {
1309 animation: around 0.7s ease-in-out 0.1s infinite;
1310 background: transparent;
1311}
diff --git a/assets/vintage/js/base.js b/assets/vintage/js/base.js
index 66830b59..55f1c37d 100644
--- a/assets/vintage/js/base.js
+++ b/assets/vintage/js/base.js
@@ -2,29 +2,38 @@ import Awesomplete from 'awesomplete';
2import 'awesomplete/awesomplete.css'; 2import 'awesomplete/awesomplete.css';
3 3
4(() => { 4(() => {
5 const awp = Awesomplete.$;
6 const autocompleteFields = document.querySelectorAll('input[data-multiple]'); 5 const autocompleteFields = document.querySelectorAll('input[data-multiple]');
6 const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
7 const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
8
7 [...autocompleteFields].forEach((autocompleteField) => { 9 [...autocompleteFields].forEach((autocompleteField) => {
8 const awesomplete = new Awesomplete(awp(autocompleteField)); 10 const awesome = new Awesomplete(Awesomplete.$(autocompleteField));
9 awesomplete.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); 11
10 awesomplete.replace = (text) => { 12 // Tags are separated by separator
11 const before = awesomplete.input.value.match(/^.+ \s*|/)[0]; 13 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(
12 awesomplete.input.value = `${before}${text} `; 14 text,
15 input.match(new RegExp(`[^${tagsSeparator}]*$`))[0],
16 );
17 // Insert new selected tag in the input
18 awesome.replace = (text) => {
19 const before = awesome.input.value.match(new RegExp(`^.+${tagsSeparator}+|`))[0];
20 awesome.input.value = `${before}${text}${tagsSeparator}`;
13 }; 21 };
14 awesomplete.minChars = 1; 22 // Highlight found items
23 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${tagsSeparator}]*$`))[0]);
15 24
16 autocompleteField.addEventListener('input', () => { 25 // Don't display already selected items
17 const proposedTags = autocompleteField.getAttribute('data-list').replace(/,/g, '').split(' '); 26 // WARNING: pseudo classes does not seem to work with string litterals...
18 const reg = /(\w+) /g; 27 const reg = new RegExp(`([^${tagsSeparator}]+)${tagsSeparator}`, 'g');
19 let match; 28 let match;
20 while ((match = reg.exec(autocompleteField.value)) !== null) { 29 awesome.data = (item, input) => {
21 const id = proposedTags.indexOf(match[1]); 30 while ((match = reg.exec(input))) {
22 if (id !== -1) { 31 if (item === match[1]) {
23 proposedTags.splice(id, 1); 32 return '';
24 } 33 }
25 } 34 }
26 35 return item;
27 awesomplete.list = proposedTags; 36 };
28 }); 37 awesome.minChars = 1;
29 }); 38 });
30})(); 39})();
diff --git a/composer.json b/composer.json
index c0855e47..138319ca 100644
--- a/composer.json
+++ b/composer.json
@@ -23,9 +23,10 @@
23 "erusev/parsedown": "^1.6", 23 "erusev/parsedown": "^1.6",
24 "erusev/parsedown-extra": "^0.8.1", 24 "erusev/parsedown-extra": "^0.8.1",
25 "gettext/gettext": "^4.4", 25 "gettext/gettext": "^4.4",
26 "katzgrau/klogger": "^1.2",
26 "malkusch/lock": "^2.1", 27 "malkusch/lock": "^2.1",
27 "pubsubhubbub/publisher": "dev-master", 28 "pubsubhubbub/publisher": "dev-master",
28 "shaarli/netscape-bookmark-parser": "^2.1", 29 "shaarli/netscape-bookmark-parser": "^3.0",
29 "slim/slim": "^3.0" 30 "slim/slim": "^3.0"
30 }, 31 },
31 "require-dev": { 32 "require-dev": {
@@ -58,6 +59,7 @@
58 "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin", 59 "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
59 "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor", 60 "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
60 "Shaarli\\Front\\Exception\\": "application/front/exceptions", 61 "Shaarli\\Front\\Exception\\": "application/front/exceptions",
62 "Shaarli\\Helper\\": "application/helper",
61 "Shaarli\\Http\\": "application/http", 63 "Shaarli\\Http\\": "application/http",
62 "Shaarli\\Legacy\\": "application/legacy", 64 "Shaarli\\Legacy\\": "application/legacy",
63 "Shaarli\\Netscape\\": "application/netscape", 65 "Shaarli\\Netscape\\": "application/netscape",
diff --git a/composer.lock b/composer.lock
index c379d8e7..0023df88 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
4 "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 4 "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
5 "This file is @generated automatically" 5 "This file is @generated automatically"
6 ], 6 ],
7 "content-hash": "932b191006135ff8be495aa0b4ba7e09", 7 "content-hash": "83852dec81e299a117a81206a5091472",
8 "packages": [ 8 "packages": [
9 { 9 {
10 "name": "arthurhoaro/web-thumbnailer", 10 "name": "arthurhoaro/web-thumbnailer",
@@ -786,24 +786,25 @@
786 }, 786 },
787 { 787 {
788 "name": "shaarli/netscape-bookmark-parser", 788 "name": "shaarli/netscape-bookmark-parser",
789 "version": "v2.2.0", 789 "version": "v3.0.1",
790 "source": { 790 "source": {
791 "type": "git", 791 "type": "git",
792 "url": "https://github.com/shaarli/netscape-bookmark-parser.git", 792 "url": "https://github.com/shaarli/netscape-bookmark-parser.git",
793 "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df" 793 "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305"
794 }, 794 },
795 "dist": { 795 "dist": {
796 "type": "zip", 796 "type": "zip",
797 "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/432a010af2bb1832d6fbc4763e6b0100b980a1df", 797 "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/d2321f30413944b2d0a9844bf8cc588c71ae6305",
798 "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df", 798 "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305",
799 "shasum": "" 799 "shasum": ""
800 }, 800 },
801 "require": { 801 "require": {
802 "katzgrau/klogger": "~1.0", 802 "katzgrau/klogger": "~1.0",
803 "php": ">=5.6" 803 "php": ">=7.1"
804 }, 804 },
805 "require-dev": { 805 "require-dev": {
806 "phpunit/phpunit": "^5.0" 806 "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
807 "squizlabs/php_codesniffer": "^3.5"
807 }, 808 },
808 "type": "library", 809 "type": "library",
809 "autoload": { 810 "autoload": {
@@ -839,9 +840,9 @@
839 ], 840 ],
840 "support": { 841 "support": {
841 "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues", 842 "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues",
842 "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v2.2.0" 843 "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v3.0.1"
843 }, 844 },
844 "time": "2020-06-06T15:53:53+00:00" 845 "time": "2020-11-03T12:27:58+00:00"
845 }, 846 },
846 { 847 {
847 "name": "slim/slim", 848 "name": "slim/slim",
@@ -1713,12 +1714,12 @@
1713 "source": { 1714 "source": {
1714 "type": "git", 1715 "type": "git",
1715 "url": "https://github.com/Roave/SecurityAdvisories.git", 1716 "url": "https://github.com/Roave/SecurityAdvisories.git",
1716 "reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff" 1717 "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6"
1717 }, 1718 },
1718 "dist": { 1719 "dist": {
1719 "type": "zip", 1720 "type": "zip",
1720 "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ba5d234b3a1559321b816b64aafc2ce6728799ff", 1721 "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
1721 "reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff", 1722 "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
1722 "shasum": "" 1723 "shasum": ""
1723 }, 1724 },
1724 "conflict": { 1725 "conflict": {
@@ -1734,7 +1735,7 @@
1734 "bagisto/bagisto": "<0.1.5", 1735 "bagisto/bagisto": "<0.1.5",
1735 "barrelstrength/sprout-base-email": "<1.2.7", 1736 "barrelstrength/sprout-base-email": "<1.2.7",
1736 "barrelstrength/sprout-forms": "<3.9", 1737 "barrelstrength/sprout-forms": "<3.9",
1737 "baserproject/basercms": ">=4,<=4.3.6", 1738 "baserproject/basercms": ">=4,<=4.3.6|>=4.4,<4.4.1",
1738 "bolt/bolt": "<3.7.1", 1739 "bolt/bolt": "<3.7.1",
1739 "brightlocal/phpwhois": "<=4.2.5", 1740 "brightlocal/phpwhois": "<=4.2.5",
1740 "buddypress/buddypress": "<5.1.2", 1741 "buddypress/buddypress": "<5.1.2",
@@ -1818,6 +1819,7 @@
1818 "magento/magento1ee": ">=1,<1.14.4.3", 1819 "magento/magento1ee": ">=1,<1.14.4.3",
1819 "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", 1820 "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2",
1820 "marcwillmann/turn": "<0.3.3", 1821 "marcwillmann/turn": "<0.3.3",
1822 "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",
1821 "mittwald/typo3_forum": "<1.2.1", 1823 "mittwald/typo3_forum": "<1.2.1",
1822 "monolog/monolog": ">=1.8,<1.12", 1824 "monolog/monolog": ">=1.8,<1.12",
1823 "namshi/jose": "<2.2", 1825 "namshi/jose": "<2.2",
@@ -1832,7 +1834,8 @@
1832 "onelogin/php-saml": "<2.10.4", 1834 "onelogin/php-saml": "<2.10.4",
1833 "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5", 1835 "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5",
1834 "openid/php-openid": "<2.3", 1836 "openid/php-openid": "<2.3",
1835 "openmage/magento-lts": "<19.4.6|>=20,<20.0.2", 1837 "openmage/magento-lts": "<19.4.8|>=20,<20.0.4",
1838 "orchid/platform": ">=9,<9.4.4",
1836 "oro/crm": ">=1.7,<1.7.4", 1839 "oro/crm": ">=1.7,<1.7.4",
1837 "oro/platform": ">=1.7,<1.7.4", 1840 "oro/platform": ">=1.7,<1.7.4",
1838 "padraic/humbug_get_contents": "<1.1.2", 1841 "padraic/humbug_get_contents": "<1.1.2",
@@ -1867,8 +1870,8 @@
1867 "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", 1870 "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11",
1868 "sensiolabs/connect": "<4.2.3", 1871 "sensiolabs/connect": "<4.2.3",
1869 "serluck/phpwhois": "<=4.2.6", 1872 "serluck/phpwhois": "<=4.2.6",
1870 "shopware/core": "<=6.3.1", 1873 "shopware/core": "<=6.3.2",
1871 "shopware/platform": "<=6.3.1", 1874 "shopware/platform": "<=6.3.2",
1872 "shopware/shopware": "<5.3.7", 1875 "shopware/shopware": "<5.3.7",
1873 "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1", 1876 "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1",
1874 "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2", 1877 "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2",
@@ -1901,7 +1904,7 @@
1901 "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", 1904 "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",
1902 "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", 1905 "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",
1903 "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", 1906 "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
1904 "sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5", 1907 "sylius/sylius": "<1.6.9|>=1.7,<1.7.9|>=1.8,<1.8.3",
1905 "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99", 1908 "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99",
1906 "symbiote/silverstripe-versionedfiles": "<=2.0.3", 1909 "symbiote/silverstripe-versionedfiles": "<=2.0.3",
1907 "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", 1910 "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
@@ -2018,7 +2021,7 @@
2018 "type": "tidelift" 2021 "type": "tidelift"
2019 } 2022 }
2020 ], 2023 ],
2021 "time": "2020-10-08T21:02:27+00:00" 2024 "time": "2020-11-01T20:01:47+00:00"
2022 }, 2025 },
2023 { 2026 {
2024 "name": "sebastian/code-unit-reverse-lookup", 2027 "name": "sebastian/code-unit-reverse-lookup",
@@ -2632,16 +2635,16 @@
2632 }, 2635 },
2633 { 2636 {
2634 "name": "squizlabs/php_codesniffer", 2637 "name": "squizlabs/php_codesniffer",
2635 "version": "3.5.6", 2638 "version": "3.5.8",
2636 "source": { 2639 "source": {
2637 "type": "git", 2640 "type": "git",
2638 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", 2641 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
2639 "reference": "e97627871a7eab2f70e59166072a6b767d5834e0" 2642 "reference": "9d583721a7157ee997f235f327de038e7ea6dac4"
2640 }, 2643 },
2641 "dist": { 2644 "dist": {
2642 "type": "zip", 2645 "type": "zip",
2643 "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0", 2646 "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/9d583721a7157ee997f235f327de038e7ea6dac4",
2644 "reference": "e97627871a7eab2f70e59166072a6b767d5834e0", 2647 "reference": "9d583721a7157ee997f235f327de038e7ea6dac4",
2645 "shasum": "" 2648 "shasum": ""
2646 }, 2649 },
2647 "require": { 2650 "require": {
@@ -2684,24 +2687,24 @@
2684 "source": "https://github.com/squizlabs/PHP_CodeSniffer", 2687 "source": "https://github.com/squizlabs/PHP_CodeSniffer",
2685 "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" 2688 "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
2686 }, 2689 },
2687 "time": "2020-08-10T04:50:15+00:00" 2690 "time": "2020-10-23T02:01:07+00:00"
2688 }, 2691 },
2689 { 2692 {
2690 "name": "symfony/polyfill-ctype", 2693 "name": "symfony/polyfill-ctype",
2691 "version": "v1.18.1", 2694 "version": "v1.20.0",
2692 "source": { 2695 "source": {
2693 "type": "git", 2696 "type": "git",
2694 "url": "https://github.com/symfony/polyfill-ctype.git", 2697 "url": "https://github.com/symfony/polyfill-ctype.git",
2695 "reference": "1c302646f6efc070cd46856e600e5e0684d6b454" 2698 "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
2696 }, 2699 },
2697 "dist": { 2700 "dist": {
2698 "type": "zip", 2701 "type": "zip",
2699 "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454", 2702 "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
2700 "reference": "1c302646f6efc070cd46856e600e5e0684d6b454", 2703 "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
2701 "shasum": "" 2704 "shasum": ""
2702 }, 2705 },
2703 "require": { 2706 "require": {
2704 "php": ">=5.3.3" 2707 "php": ">=7.1"
2705 }, 2708 },
2706 "suggest": { 2709 "suggest": {
2707 "ext-ctype": "For best performance" 2710 "ext-ctype": "For best performance"
@@ -2709,7 +2712,7 @@
2709 "type": "library", 2712 "type": "library",
2710 "extra": { 2713 "extra": {
2711 "branch-alias": { 2714 "branch-alias": {
2712 "dev-master": "1.18-dev" 2715 "dev-main": "1.20-dev"
2713 }, 2716 },
2714 "thanks": { 2717 "thanks": {
2715 "name": "symfony/polyfill", 2718 "name": "symfony/polyfill",
@@ -2747,7 +2750,7 @@
2747 "portable" 2750 "portable"
2748 ], 2751 ],
2749 "support": { 2752 "support": {
2750 "source": "https://github.com/symfony/polyfill-ctype/tree/v1.18.0" 2753 "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
2751 }, 2754 },
2752 "funding": [ 2755 "funding": [
2753 { 2756 {
@@ -2763,7 +2766,7 @@
2763 "type": "tidelift" 2766 "type": "tidelift"
2764 } 2767 }
2765 ], 2768 ],
2766 "time": "2020-07-14T12:35:20+00:00" 2769 "time": "2020-10-23T14:02:19+00:00"
2767 }, 2770 },
2768 { 2771 {
2769 "name": "theseer/tokenizer", 2772 "name": "theseer/tokenizer",
diff --git a/doc/md/Docker.md b/doc/md/Docker.md
index c152fe92..fc406c00 100644
--- a/doc/md/Docker.md
+++ b/doc/md/Docker.md
@@ -1,3 +1,4 @@
1
1# Docker 2# Docker
2 3
3[Docker](https://docs.docker.com/get-started/overview/) is an open platform for developing, shipping, and running applications 4[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
113# Download the latest version of Shaarli's docker-compose.yml 114# Download the latest version of Shaarli's docker-compose.yml
114$ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/latest/docker-compose.yml -o docker-compose.yml 115$ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/latest/docker-compose.yml -o docker-compose.yml
115# Create the .env file and fill in your VPS and domain information 116# Create the .env file and fill in your VPS and domain information
116# (replace <MY_SHAARLI_DOMAIN> and <MY_CONTACT_EMAIL> with your actual information) 117# (replace <shaarli.mydomain.org>, <admin@mydomain.org> and <latest> with your actual information)
117$ echo 'SHAARLI_VIRTUAL_HOST=shaarli.mydomain.org' > .env 118$ echo 'SHAARLI_VIRTUAL_HOST=shaarli.mydomain.org' > .env
118$ echo 'SHAARLI_LETSENCRYPT_EMAIL=admin@mydomain.org' >> .env 119$ echo 'SHAARLI_LETSENCRYPT_EMAIL=admin@mydomain.org' >> .env
120# Available Docker tags can be found at https://hub.docker.com/r/shaarli/shaarli/tags
121$ echo 'SHAARLI_DOCKER_TAG=latest' >> .env
119# Pull the Docker images 122# Pull the Docker images
120$ docker-compose pull 123$ docker-compose pull
121# Run! 124# Run!
@@ -224,4 +227,4 @@ $ docker system prune
224- [docker pull](https://docs.docker.com/engine/reference/commandline/pull/) 227- [docker pull](https://docs.docker.com/engine/reference/commandline/pull/)
225- [docker run](https://docs.docker.com/engine/reference/commandline/run/) 228- [docker run](https://docs.docker.com/engine/reference/commandline/run/)
226- [docker-compose logs](https://docs.docker.com/compose/reference/logs/) 229- [docker-compose logs](https://docs.docker.com/compose/reference/logs/)
227- 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 230- Træfik: [Getting Started](https://docs.traefik.io/), [Docker backend](https://docs.traefik.io/configuration/backends/docker/), [Let's Encrypt](https://docs.traefik.io/user-guide/docker-and-lets-encrypt/), [Docker image](https://hub.docker.com/_/traefik/)
diff --git a/doc/md/REST-API.md b/doc/md/REST-API.md
index 01071d8e..2a36ea29 100644
--- a/doc/md/REST-API.md
+++ b/doc/md/REST-API.md
@@ -73,7 +73,7 @@ var_dump(getInfo($baseUrl, $secret));
73### Authentication 73### Authentication
74 74
75- All requests to Shaarli's API must include a **JWT token** to verify their authenticity. 75- All requests to Shaarli's API must include a **JWT token** to verify their authenticity.
76- This token must be included as an HTTP header called `Authentication: Bearer <jwt token>`. 76- This token must be included as an HTTP header called `Authorization: Bearer <jwt token>`.
77- JWT tokens are composed by three parts, separated by a dot `.` and encoded in base64: 77- JWT tokens are composed by three parts, separated by a dot `.` and encoded in base64:
78 78
79``` 79```
diff --git a/doc/md/Server-configuration.md b/doc/md/Server-configuration.md
index 8cb39934..a49b6033 100644
--- a/doc/md/Server-configuration.md
+++ b/doc/md/Server-configuration.md
@@ -193,19 +193,24 @@ sudo nano /etc/apache2/sites-available/shaarli.mydomain.org.conf
193 Require all granted 193 Require all granted
194 </Directory> 194 </Directory>
195 195
196 <LocationMatch "/\."> 196 # BE CAREFUL: directives order matter!
197 # Prevent accessing dotfiles
198 RedirectMatch 404 ".*"
199 </LocationMatch>
200 197
201 <LocationMatch "\.(?:ico|css|js|gif|jpe?g|png)$"> 198 <FilesMatch ".*\.(?!(ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$)[^\.]*$">
199 Require all denied
200 </FilesMatch>
201
202 <Files "index.php">
203 Require all granted
204 </Files>
205
206 <FilesMatch "\.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2)$">
202 # allow client-side caching of static files 207 # allow client-side caching of static files
203 Header set Cache-Control "max-age=2628000, public, must-revalidate, proxy-revalidate" 208 Header set Cache-Control "max-age=2628000, public, must-revalidate, proxy-revalidate"
204 </LocationMatch> 209 </FilesMatch>
210
205 211
206 # serve the Shaarli favicon from its custom location 212 # serve the Shaarli favicon from its custom location
207 Alias favicon.ico /var/www/shaarli.mydomain.org/images/favicon.ico 213 Alias favicon.ico /var/www/shaarli.mydomain.org/images/favicon.ico
208
209</VirtualHost> 214</VirtualHost>
210``` 215```
211 216
@@ -296,7 +301,7 @@ server {
296 location / { 301 location / {
297 # default index file when no file URI is requested 302 # default index file when no file URI is requested
298 index index.php; 303 index index.php;
299 try_files $uri /index.php$is_args$args; 304 try_files _ /index.php$is_args$args;
300 } 305 }
301 306
302 location ~ (index)\.php$ { 307 location ~ (index)\.php$ {
@@ -309,20 +314,9 @@ server {
309 include fastcgi.conf; 314 include fastcgi.conf;
310 } 315 }
311 316
312 location ~ \.php$ { 317 location ~ /doc/html/ {
313 # deny access to all other PHP scripts 318 default_type "text/html";
314 # disable this if you host other PHP applications on the same virtualhost 319 try_files $uri $uri/ $uri.html =404;
315 deny all;
316 }
317
318 location ~ /\. {
319 # deny access to dotfiles
320 deny all;
321 }
322
323 location ~ ~$ {
324 # deny access to temp editor files, e.g. "script.php~"
325 deny all;
326 } 320 }
327 321
328 location = /favicon.ico { 322 location = /favicon.ico {
@@ -331,13 +325,12 @@ server {
331 } 325 }
332 326
333 # allow client-side caching of static files 327 # allow client-side caching of static files
334 location ~* \.(?:ico|css|js|gif|jpe?g|png)$ { 328 location ~* \.(?:ico|css|js|gif|jpe?g|png|ttf|oet|woff2?)$ {
335 expires max; 329 expires max;
336 add_header Cache-Control "public, must-revalidate, proxy-revalidate"; 330 add_header Cache-Control "public, must-revalidate, proxy-revalidate";
337 # HTTP 1.0 compatibility 331 # HTTP 1.0 compatibility
338 add_header Pragma public; 332 add_header Pragma public;
339 } 333 }
340
341} 334}
342``` 335```
343 336
diff --git a/doc/md/Shaarli-configuration.md b/doc/md/Shaarli-configuration.md
index 263fb761..b1326cce 100644
--- a/doc/md/Shaarli-configuration.md
+++ b/doc/md/Shaarli-configuration.md
@@ -74,6 +74,7 @@ Some settings can be configured directly from a web browser by accesing the `Too
74 "timezone": "Europe\/Paris", 74 "timezone": "Europe\/Paris",
75 "title": "My Shaarli", 75 "title": "My Shaarli",
76 "header_link": "?" 76 "header_link": "?"
77 "tags_separator": " "
77 }, 78 },
78 "dev": { 79 "dev": {
79 "debug": false, 80 "debug": false,
@@ -150,8 +151,10 @@ _These settings should not be edited_
150- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php). 151- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
151- **enabled_plugins**: List of enabled plugins. 152- **enabled_plugins**: List of enabled plugins.
152- **default_note_title**: Default title of a new note. 153- **default_note_title**: Default title of a new note.
154- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
153- **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. 155- **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.
154- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`. 156- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
157- **tags_separator**: Defines your tags separator (default: whitespace).
155 158
156### Security 159### Security
157 160
@@ -163,6 +166,22 @@ _These settings should not be edited_
163- **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy. 166- **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy.
164- **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"]`). 167- **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"]`).
165 168
169### Formatter
170
171Single string value. Default available:
172
173 - `default`: supports line breaks, URL and hashtag auto-links.
174 - `markdown`: supports [Markdown](https://daringfireball.net/projects/markdown/syntax).
175 - `markdownExtra`: adds [extra](https://michelf.ca/projects/php-markdown/extra/) flavor to Markdown.
176
177### Formatter Settings
178
179Additional settings applied to formatters.
180
181#### default
182
183 - **autolink**: boolean to enable or disable automatic linkification of URL and hashtags.
184
166### Resources 185### Resources
167 186
168- **data_dir**: Data directory. 187- **data_dir**: Data directory.
diff --git a/doc/md/dev/Development.md b/doc/md/dev/Development.md
index 5c085e03..c42e8ffe 100644
--- a/doc/md/dev/Development.md
+++ b/doc/md/dev/Development.md
@@ -6,7 +6,7 @@ Please read [Contributing to Shaarli](https://github.com/shaarli/Shaarli/tree/ma
6 6
7 7
8- [Unit tests](Unit-tests) 8- [Unit tests](Unit-tests)
9- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). 9- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript).
10Run `make eslint` to check JS style. 10Run `make eslint` to check JS style.
11- [GnuPG signature](GnuPG-signature) for tags/releases 11- [GnuPG signature](GnuPG-signature) for tags/releases
12 12
@@ -51,12 +51,12 @@ PHP (managed through [`composer.json`](https://github.com/shaarli/Shaarli/blob/m
51 51
52## Link structure 52## Link structure
53 53
54Every link available through the `LinkDB` object is represented as an array 54Every link available through the `LinkDB` object is represented as an array
55containing the following fields: 55containing the following fields:
56 56
57 * `id` (integer): Unique identifier. 57 * `id` (integer): Unique identifier.
58 * `title` (string): Title of the link. 58 * `title` (string): Title of the link.
59 * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.). 59 * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.).
60 Can be absolute or relative for Notes. 60 Can be absolute or relative for Notes.
61 * `real_url` (string): Real destination URL, can be redirected, encoded, etc. 61 * `real_url` (string): Real destination URL, can be redirected, encoded, etc.
62 * `shorturl` (string): Permalink small hash. 62 * `shorturl` (string): Permalink small hash.
@@ -66,7 +66,7 @@ containing the following fields:
66 * `thumbnail` (string|boolean): relative path of the thumbnail cache file, or false if there isn't any. 66 * `thumbnail` (string|boolean): relative path of the thumbnail cache file, or false if there isn't any.
67 * `created` (DateTime): link creation date time. 67 * `created` (DateTime): link creation date time.
68 * `updated` (DateTime): last modification date time. 68 * `updated` (DateTime): last modification date time.
69 69
70Small 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 `@`. 70Small 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 `@`.
71 71
72 72
@@ -163,11 +163,13 @@ See [`.travis.yml`](https://github.com/shaarli/Shaarli/blob/master/.travis.yml).
163 163
164## Static analysis 164## Static analysis
165 165
166Patches should try to stick to the [PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), especially: 166Patches should try to stick to the [PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), and must follow:
167 167
168- [PSR-1](http://www.php-fig.org/psr/psr-1/) - Basic Coding Standard 168- [PSR-1](http://www.php-fig.org/psr/psr-1/) - Basic Coding Standard
169- [PSR-2](http://www.php-fig.org/psr/psr-2/) - Coding Style Guide 169- [PSR-2](http://www.php-fig.org/psr/psr-2/) - Coding Style Guide
170- [PSR-12](http://www.php-fig.org/psr/psr-12/) - Extended Coding Style Guide
170 171
172These are enforced on pull requests using our Continuous Integration tools.
171 173
172**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) 174**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)
173 175
diff --git a/doc/md/dev/Plugin-system.md b/doc/md/dev/Plugin-system.md
index f09fadc2..79654011 100644
--- a/doc/md/dev/Plugin-system.md
+++ b/doc/md/dev/Plugin-system.md
@@ -139,6 +139,31 @@ Each file contain two keys:
139 139
140> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file. 140> Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
141 141
142### Register plugin's routes
143
144Shaarli lets you register custom Slim routes for your plugin.
145
146To register a route, the plugin must include a function called `function <plugin_name>_register_routes(): array`.
147
148This method must return an array of routes, each entry must contain the following keys:
149
150 - `method`: HTTP method, `GET/POST/PUT/PATCH/DELETE`
151 - `route` (path): without prefix, e.g. `/up/{variable}`
152 It will be later prefixed by `/plugin/<plugin name>/`.
153 - `callable` string, function name or FQN class's method to execute, e.g. `demo_plugin_custom_controller`.
154
155Callable functions or methods must have `Slim\Http\Request` and `Slim\Http\Response` parameters
156and return a `Slim\Http\Response`. We recommend creating a dedicated class and extend either
157`ShaarliVisitorController` or `ShaarliAdminController` to use helper functions they provide.
158
159A dedicated plugin template is available for rendering content: `pluginscontent.html` using `content` placeholder.
160
161> **Warning**: plugins are not able to use RainTPL template engine for their content due to technical restrictions.
162> RainTPL does not allow to register multiple template folders, so all HTML rendering must be done within plugin
163> custom controller.
164
165Check out the `demo_plugin` for a live example: `GET <shaarli_url>/plugin/demo_plugin/custom`.
166
142### Understanding relative paths 167### Understanding relative paths
143 168
144Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder. 169Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
diff --git a/doc/md/dev/Release-Shaarli.md b/doc/md/dev/Release-Shaarli.md
index 2c772406..d79be9ce 100644
--- a/doc/md/dev/Release-Shaarli.md
+++ b/doc/md/dev/Release-Shaarli.md
@@ -64,6 +64,14 @@ git pull upstream master
64 64
65# If releasing a new minor version, create a release branch 65# If releasing a new minor version, create a release branch
66$ git checkout -b v0.x 66$ git checkout -b v0.x
67# Otherwise just use the existing one
68$ git checkout v0.x
69
70# Get the latest changes
71$ git merge master
72
73# Check that everything went fine:
74$ make test
67 75
68# Bump shaarli_version.php from dev to 0.x.0, **without the v** 76# Bump shaarli_version.php from dev to 0.x.0, **without the v**
69$ vim shaarli_version.php 77$ vim shaarli_version.php
diff --git a/docker-compose.yml b/docker-compose.yml
index a3de4b1c..4ebae447 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -2,12 +2,13 @@
2# Shaarli - Docker Compose example configuration 2# Shaarli - Docker Compose example configuration
3# 3#
4# See: 4# See:
5# - https://shaarli.readthedocs.io/en/master/docker/shaarli-images/ 5# - https://shaarli.readthedocs.io/en/master/Docker/#docker-compose
6# - https://shaarli.readthedocs.io/en/master/guides/install-shaarli-with-debian9-and-docker/
7# 6#
8# Environment variables: 7# Environment variables:
9# - SHAARLI_VIRTUAL_HOST Fully Qualified Domain Name for the Shaarli instance 8# - SHAARLI_VIRTUAL_HOST Fully Qualified Domain Name for the Shaarli instance
10# - SHAARLI_LETSENCRYPT_EMAIL Contact email for certificate renewal 9# - SHAARLI_LETSENCRYPT_EMAIL Contact email for certificate renewal
10# - SHAARLI_DOCKER_TAG Shaarli docker tag to use
11# See: https://hub.docker.com/r/shaarli/shaarli/tags
11version: '3' 12version: '3'
12 13
13networks: 14networks:
@@ -20,7 +21,7 @@ volumes:
20 21
21services: 22services:
22 shaarli: 23 shaarli:
23 image: shaarli/shaarli:master 24 image: shaarli/shaarli:${SHAARLI_DOCKER_TAG}
24 build: ./ 25 build: ./
25 networks: 26 networks:
26 - http-proxy 27 - http-proxy
@@ -40,7 +41,7 @@ services:
40 - "--entrypoints=Name:https Address::443 TLS" 41 - "--entrypoints=Name:https Address::443 TLS"
41 - "--retry" 42 - "--retry"
42 - "--docker" 43 - "--docker"
43 - "--docker.domain=docker.localhost" 44 - "--docker.domain=${SHAARLI_VIRTUAL_HOST}"
44 - "--docker.exposedbydefault=true" 45 - "--docker.exposedbydefault=true"
45 - "--docker.watch=true" 46 - "--docker.watch=true"
46 - "--acme" 47 - "--acme"
diff --git a/inc/languages/fr/LC_MESSAGES/shaarli.po b/inc/languages/fr/LC_MESSAGES/shaarli.po
index f7baedfb..01492af4 100644
--- a/inc/languages/fr/LC_MESSAGES/shaarli.po
+++ b/inc/languages/fr/LC_MESSAGES/shaarli.po
@@ -1,8 +1,8 @@
1msgid "" 1msgid ""
2msgstr "" 2msgstr ""
3"Project-Id-Version: Shaarli\n" 3"Project-Id-Version: Shaarli\n"
4"POT-Creation-Date: 2020-10-16 20:01+0200\n" 4"POT-Creation-Date: 2020-11-24 13:13+0100\n"
5"PO-Revision-Date: 2020-10-16 20:02+0200\n" 5"PO-Revision-Date: 2020-11-24 13:14+0100\n"
6"Last-Translator: \n" 6"Last-Translator: \n"
7"Language-Team: Shaarli\n" 7"Language-Team: Shaarli\n"
8"Language: fr_FR\n" 8"Language: fr_FR\n"
@@ -20,58 +20,31 @@ msgstr ""
20"X-Poedit-SearchPath-3: init.php\n" 20"X-Poedit-SearchPath-3: init.php\n"
21"X-Poedit-SearchPath-4: plugins\n" 21"X-Poedit-SearchPath-4: plugins\n"
22 22
23#: application/ApplicationUtils.php:161 23#: application/History.php:181
24#, php-format
25msgid ""
26"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
27"cannot run. Your PHP version has known security vulnerabilities and should "
28"be updated as soon as possible."
29msgstr ""
30"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
31"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
32"connues et devrait être mise à jour au plus tôt."
33
34#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204
35msgid "directory is not readable"
36msgstr "le répertoire n'est pas accessible en lecture"
37
38#: application/ApplicationUtils.php:207
39msgid "directory is not writable"
40msgstr "le répertoire n'est pas accessible en écriture"
41
42#: application/ApplicationUtils.php:225
43msgid "file is not readable"
44msgstr "le fichier n'est pas accessible en lecture"
45
46#: application/ApplicationUtils.php:228
47msgid "file is not writable"
48msgstr "le fichier n'est pas accessible en écriture"
49
50#: application/History.php:179
51msgid "History file isn't readable or writable" 24msgid "History file isn't readable or writable"
52msgstr "Le fichier d'historique n'est pas accessible en lecture ou en écriture" 25msgstr "Le fichier d'historique n'est pas accessible en lecture ou en écriture"
53 26
54#: application/History.php:190 27#: application/History.php:192
55msgid "Could not parse history file" 28msgid "Could not parse history file"
56msgstr "Format incorrect pour le fichier d'historique" 29msgstr "Format incorrect pour le fichier d'historique"
57 30
58#: application/Languages.php:181 31#: application/Languages.php:184
59msgid "Automatic" 32msgid "Automatic"
60msgstr "Automatique" 33msgstr "Automatique"
61 34
62#: application/Languages.php:182 35#: application/Languages.php:185
63msgid "German" 36msgid "German"
64msgstr "Allemand" 37msgstr "Allemand"
65 38
66#: application/Languages.php:183 39#: application/Languages.php:186
67msgid "English" 40msgid "English"
68msgstr "Anglais" 41msgstr "Anglais"
69 42
70#: application/Languages.php:184 43#: application/Languages.php:187
71msgid "French" 44msgid "French"
72msgstr "Français" 45msgstr "Français"
73 46
74#: application/Languages.php:185 47#: application/Languages.php:188
75msgid "Japanese" 48msgid "Japanese"
76msgstr "Japonais" 49msgstr "Japonais"
77 50
@@ -83,46 +56,46 @@ msgstr ""
83"l'extension php-gd doit être chargée pour utiliser les miniatures. Les " 56"l'extension php-gd doit être chargée pour utiliser les miniatures. Les "
84"miniatures sont désormais désactivées. Rechargez la page." 57"miniatures sont désormais désactivées. Rechargez la page."
85 58
86#: application/Utils.php:383 59#: application/Utils.php:405
87msgid "Setting not set" 60msgid "Setting not set"
88msgstr "Paramètre non défini" 61msgstr "Paramètre non défini"
89 62
90#: application/Utils.php:390 63#: application/Utils.php:412
91msgid "Unlimited" 64msgid "Unlimited"
92msgstr "Illimité" 65msgstr "Illimité"
93 66
94#: application/Utils.php:393 67#: application/Utils.php:415
95msgid "B" 68msgid "B"
96msgstr "o" 69msgstr "o"
97 70
98#: application/Utils.php:393 71#: application/Utils.php:415
99msgid "kiB" 72msgid "kiB"
100msgstr "ko" 73msgstr "ko"
101 74
102#: application/Utils.php:393 75#: application/Utils.php:415
103msgid "MiB" 76msgid "MiB"
104msgstr "Mo" 77msgstr "Mo"
105 78
106#: application/Utils.php:393 79#: application/Utils.php:415
107msgid "GiB" 80msgid "GiB"
108msgstr "Go" 81msgstr "Go"
109 82
110#: application/bookmark/BookmarkFileService.php:180 83#: application/bookmark/BookmarkFileService.php:185
111#: application/bookmark/BookmarkFileService.php:202 84#: application/bookmark/BookmarkFileService.php:207
112#: application/bookmark/BookmarkFileService.php:224 85#: application/bookmark/BookmarkFileService.php:229
113#: application/bookmark/BookmarkFileService.php:238 86#: application/bookmark/BookmarkFileService.php:243
114msgid "You're not authorized to alter the datastore" 87msgid "You're not authorized to alter the datastore"
115msgstr "Vous n'êtes pas autorisé à modifier les données" 88msgstr "Vous n'êtes pas autorisé à modifier les données"
116 89
117#: application/bookmark/BookmarkFileService.php:205 90#: application/bookmark/BookmarkFileService.php:210
118msgid "This bookmarks already exists" 91msgid "This bookmarks already exists"
119msgstr "Ce marque-page existe déjà." 92msgstr "Ce marque-page existe déjà"
120 93
121#: application/bookmark/BookmarkInitializer.php:39 94#: application/bookmark/BookmarkInitializer.php:42
122msgid "(private bookmark with thumbnail demo)" 95msgid "(private bookmark with thumbnail demo)"
123msgstr "(marque page privé avec une miniature)" 96msgstr "(marque page privé avec une miniature)"
124 97
125#: application/bookmark/BookmarkInitializer.php:42 98#: application/bookmark/BookmarkInitializer.php:45
126msgid "" 99msgid ""
127"Shaarli will automatically pick up the thumbnail for links to a variety of " 100"Shaarli will automatically pick up the thumbnail for links to a variety of "
128"websites.\n" 101"websites.\n"
@@ -145,11 +118,11 @@ msgstr ""
145"\n" 118"\n"
146"Maintenant, vous pouvez modifier ou supprimer les shaares créés par défaut.\n" 119"Maintenant, vous pouvez modifier ou supprimer les shaares créés par défaut.\n"
147 120
148#: application/bookmark/BookmarkInitializer.php:55 121#: application/bookmark/BookmarkInitializer.php:58
149msgid "Note: Shaare descriptions" 122msgid "Note: Shaare descriptions"
150msgstr "Note : Description des Shaares" 123msgstr "Note : Description des Shaares"
151 124
152#: application/bookmark/BookmarkInitializer.php:57 125#: application/bookmark/BookmarkInitializer.php:60
153msgid "" 126msgid ""
154"Adding a shaare without entering a URL creates a text-only \"note\" post " 127"Adding a shaare without entering a URL creates a text-only \"note\" post "
155"such as this one.\n" 128"such as this one.\n"
@@ -213,19 +186,19 @@ msgstr ""
213"| Citron | Fruit | Jaune | 30 |\n" 186"| Citron | Fruit | Jaune | 30 |\n"
214"| Carotte | Légume | Orange | 14 |\n" 187"| Carotte | Légume | Orange | 14 |\n"
215 188
216#: application/bookmark/BookmarkInitializer.php:91 189#: application/bookmark/BookmarkInitializer.php:94
217#: application/legacy/LegacyLinkDB.php:246 190#: application/legacy/LegacyLinkDB.php:246
218#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 191#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
219#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49 192#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
220#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15 193#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
221#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:49 194#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
222msgid "" 195msgid ""
223"The personal, minimalist, super-fast, database free, bookmarking service" 196"The personal, minimalist, super-fast, database free, bookmarking service"
224msgstr "" 197msgstr ""
225"Le gestionnaire de marque-pages personnel, minimaliste, et sans base de " 198"Le gestionnaire de marque-pages personnel, minimaliste, et sans base de "
226"données" 199"données"
227 200
228#: application/bookmark/BookmarkInitializer.php:94 201#: application/bookmark/BookmarkInitializer.php:97
229msgid "" 202msgid ""
230"Welcome to Shaarli!\n" 203"Welcome to Shaarli!\n"
231"\n" 204"\n"
@@ -274,11 +247,11 @@ msgstr ""
274"issues) si vous avez une suggestion ou si vous rencontrez un problème.\n" 247"issues) si vous avez une suggestion ou si vous rencontrez un problème.\n"
275" \n" 248" \n"
276 249
277#: application/bookmark/exception/BookmarkNotFoundException.php:13 250#: application/bookmark/exception/BookmarkNotFoundException.php:14
278msgid "The link you are trying to reach does not exist or has been deleted." 251msgid "The link you are trying to reach does not exist or has been deleted."
279msgstr "Le lien que vous essayez de consulter n'existe pas ou a été supprimé." 252msgstr "Le lien que vous essayez de consulter n'existe pas ou a été supprimé."
280 253
281#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:129 254#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:131
282msgid "" 255msgid ""
283"Shaarli could not create the config file. Please make sure Shaarli has the " 256"Shaarli could not create the config file. Please make sure Shaarli has the "
284"right to write in the folder is it installed in." 257"right to write in the folder is it installed in."
@@ -286,12 +259,12 @@ msgstr ""
286"Shaarli n'a pas pu créer le fichier de configuration. Merci de vérifier que " 259"Shaarli n'a pas pu créer le fichier de configuration. Merci de vérifier que "
287"Shaarli a les droits d'écriture dans le dossier dans lequel il est installé." 260"Shaarli a les droits d'écriture dans le dossier dans lequel il est installé."
288 261
289#: application/config/ConfigManager.php:136 262#: application/config/ConfigManager.php:137
290#: application/config/ConfigManager.php:163 263#: application/config/ConfigManager.php:164
291msgid "Invalid setting key parameter. String expected, got: " 264msgid "Invalid setting key parameter. String expected, got: "
292msgstr "Clé de paramétrage invalide. Chaîne de caractères obtenue, attendu : " 265msgstr "Clé de paramétrage invalide. Chaîne de caractères obtenue, attendu : "
293 266
294#: application/config/exception/MissingFieldConfigException.php:21 267#: application/config/exception/MissingFieldConfigException.php:20
295#, php-format 268#, php-format
296msgid "Configuration value is required for %s" 269msgid "Configuration value is required for %s"
297msgstr "Le paramètre %s est obligatoire" 270msgstr "Le paramètre %s est obligatoire"
@@ -301,46 +274,48 @@ msgid "An error occurred while trying to save plugins loading order."
301msgstr "" 274msgstr ""
302"Une erreur s'est produite lors de la sauvegarde de l'ordre des extensions." 275"Une erreur s'est produite lors de la sauvegarde de l'ordre des extensions."
303 276
304#: application/config/exception/UnauthorizedConfigException.php:16 277#: application/config/exception/UnauthorizedConfigException.php:15
305msgid "You are not authorized to alter config." 278msgid "You are not authorized to alter config."
306msgstr "Vous n'êtes pas autorisé à modifier la configuration." 279msgstr "Vous n'êtes pas autorisé à modifier la configuration."
307 280
308#: application/exceptions/IOException.php:22 281#: application/exceptions/IOException.php:23
309msgid "Error accessing" 282msgid "Error accessing"
310msgstr "Une erreur s'est produite en accédant à" 283msgstr "Une erreur s'est produite en accédant à"
311 284
312#: application/feed/FeedBuilder.php:179 285#: application/feed/FeedBuilder.php:180
313msgid "Direct link" 286msgid "Direct link"
314msgstr "Liens directs" 287msgstr "Liens directs"
315 288
316#: application/feed/FeedBuilder.php:181 289#: application/feed/FeedBuilder.php:182
317#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96 290#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
291#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
318#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179 292#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
319msgid "Permalink" 293msgid "Permalink"
320msgstr "Permalien" 294msgstr "Permalien"
321 295
322#: application/front/controller/admin/ConfigureController.php:54 296#: application/front/controller/admin/ConfigureController.php:56
323#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 297#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
324msgid "Configure" 298msgid "Configure"
325msgstr "Configurer" 299msgstr "Configurer"
326 300
327#: application/front/controller/admin/ConfigureController.php:102 301#: application/front/controller/admin/ConfigureController.php:106
328#: application/legacy/LegacyUpdater.php:537 302#: application/legacy/LegacyUpdater.php:539
329msgid "You have enabled or changed thumbnails mode." 303msgid "You have enabled or changed thumbnails mode."
330msgstr "Vous avez activé ou changé le mode de miniatures." 304msgstr "Vous avez activé ou changé le mode de miniatures."
331 305
332#: application/front/controller/admin/ConfigureController.php:103 306#: application/front/controller/admin/ConfigureController.php:108
333#: application/legacy/LegacyUpdater.php:538 307#: application/front/controller/admin/ServerController.php:76
308#: application/legacy/LegacyUpdater.php:540
334msgid "Please synchronize them." 309msgid "Please synchronize them."
335msgstr "Merci de les synchroniser." 310msgstr "Merci de les synchroniser."
336 311
337#: application/front/controller/admin/ConfigureController.php:113 312#: application/front/controller/admin/ConfigureController.php:119
338#: application/front/controller/visitor/InstallController.php:136 313#: application/front/controller/visitor/InstallController.php:149
339msgid "Error while writing config file after configuration update." 314msgid "Error while writing config file after configuration update."
340msgstr "" 315msgstr ""
341"Une erreur s'est produite lors de la sauvegarde du fichier de configuration." 316"Une erreur s'est produite lors de la sauvegarde du fichier de configuration."
342 317
343#: application/front/controller/admin/ConfigureController.php:122 318#: application/front/controller/admin/ConfigureController.php:128
344msgid "Configuration was saved." 319msgid "Configuration was saved."
345msgstr "La configuration a été sauvegardée." 320msgstr "La configuration a été sauvegardée."
346 321
@@ -372,70 +347,47 @@ msgstr ""
372"le serveur web peut accepter (%s). Merci de l'envoyer en parties plus " 347"le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
373"légères." 348"légères."
374 349
375#: application/front/controller/admin/ManageShaareController.php:29 350#: application/front/controller/admin/ManageTagController.php:30
376#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 351msgid "whitespace"
377msgid "Shaare a new link" 352msgstr "espace"
378msgstr "Partager un nouveau lien"
379 353
380#: application/front/controller/admin/ManageShaareController.php:78 354#: application/front/controller/admin/ManageTagController.php:35
381msgid "Note: "
382msgstr "Note : "
383
384#: application/front/controller/admin/ManageShaareController.php:109
385#: application/front/controller/admin/ManageShaareController.php:206
386#: application/front/controller/admin/ManageShaareController.php:275
387#: application/front/controller/admin/ManageShaareController.php:315
388#, php-format
389msgid "Bookmark with identifier %s could not be found."
390msgstr "Le lien avec l'identifiant %s n'a pas pu être trouvé."
391
392#: application/front/controller/admin/ManageShaareController.php:194
393#: application/front/controller/admin/ManageShaareController.php:252
394msgid "Invalid bookmark ID provided."
395msgstr "ID du lien non valide."
396
397#: application/front/controller/admin/ManageShaareController.php:260
398msgid "Invalid visibility provided."
399msgstr "Visibilité du lien non valide."
400
401#: application/front/controller/admin/ManageShaareController.php:363
402#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
403msgid "Edit"
404msgstr "Modifier"
405
406#: application/front/controller/admin/ManageShaareController.php:366
407#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
408#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
409msgid "Shaare"
410msgstr "Shaare"
411
412#: application/front/controller/admin/ManageTagController.php:29
413#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 355#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
414#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36 356#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
415msgid "Manage tags" 357msgid "Manage tags"
416msgstr "Gérer les tags" 358msgstr "Gérer les tags"
417 359
418#: application/front/controller/admin/ManageTagController.php:48 360#: application/front/controller/admin/ManageTagController.php:54
419msgid "Invalid tags provided." 361msgid "Invalid tags provided."
420msgstr "Les tags fournis ne sont pas valides." 362msgstr "Les tags fournis ne sont pas valides."
421 363
422#: application/front/controller/admin/ManageTagController.php:72 364#: application/front/controller/admin/ManageTagController.php:78
423#, php-format 365#, php-format
424msgid "The tag was removed from %d bookmark." 366msgid "The tag was removed from %d bookmark."
425msgid_plural "The tag was removed from %d bookmarks." 367msgid_plural "The tag was removed from %d bookmarks."
426msgstr[0] "Le tag a été supprimé du %d lien." 368msgstr[0] "Le tag a été supprimé du %d lien."
427msgstr[1] "Le tag a été supprimé de %d liens." 369msgstr[1] "Le tag a été supprimé de %d liens."
428 370
429#: application/front/controller/admin/ManageTagController.php:77 371#: application/front/controller/admin/ManageTagController.php:83
430#, php-format 372#, php-format
431msgid "The tag was renamed in %d bookmark." 373msgid "The tag was renamed in %d bookmark."
432msgid_plural "The tag was renamed in %d bookmarks." 374msgid_plural "The tag was renamed in %d bookmarks."
433msgstr[0] "Le tag a été renommé dans %d lien." 375msgstr[0] "Le tag a été renommé dans %d lien."
434msgstr[1] "Le tag a été renommé dans %d liens." 376msgstr[1] "Le tag a été renommé dans %d liens."
435 377
378#: application/front/controller/admin/ManageTagController.php:105
379msgid "Tags separator must be a single character."
380msgstr "Un séparateur de tags doit contenir un seul caractère."
381
382#: application/front/controller/admin/ManageTagController.php:111
383msgid "These characters are reserved and can't be used as tags separator: "
384msgstr ""
385"Ces caractères sont réservés et ne peuvent être utilisés comme des "
386"séparateurs de tags : "
387
436#: application/front/controller/admin/PasswordController.php:28 388#: application/front/controller/admin/PasswordController.php:28
437#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13 389#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
438#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 390#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
439msgid "Change password" 391msgid "Change password"
440msgstr "Modifier le mot de passe" 392msgstr "Modifier le mot de passe"
441 393
@@ -467,6 +419,61 @@ msgstr ""
467"Une erreur s'est produite lors de la sauvegarde de la configuration des " 419"Une erreur s'est produite lors de la sauvegarde de la configuration des "
468"plugins : " 420"plugins : "
469 421
422#: application/front/controller/admin/ServerController.php:35
423msgid "Check disabled"
424msgstr "Vérification désactivée"
425
426#: application/front/controller/admin/ServerController.php:57
427#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
428#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
429msgid "Server administration"
430msgstr "Administration serveur"
431
432#: application/front/controller/admin/ServerController.php:74
433msgid "Thumbnails cache has been cleared."
434msgstr "Le cache des miniatures a été vidé."
435
436#: application/front/controller/admin/ServerController.php:85
437msgid "Shaarli's cache folder has been cleared!"
438msgstr "Le dossier de cache de Shaarli a été vidé !"
439
440#: application/front/controller/admin/ShaareAddController.php:26
441#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
442msgid "Shaare a new link"
443msgstr "Partagez un nouveau lien"
444
445#: application/front/controller/admin/ShaareManageController.php:35
446#: application/front/controller/admin/ShaareManageController.php:93
447msgid "Invalid bookmark ID provided."
448msgstr "L'ID du marque-page fourni n'est pas valide."
449
450#: application/front/controller/admin/ShaareManageController.php:47
451#: application/front/controller/admin/ShaareManageController.php:116
452#: application/front/controller/admin/ShaareManageController.php:156
453#: application/front/controller/admin/ShaarePublishController.php:82
454#, php-format
455msgid "Bookmark with identifier %s could not be found."
456msgstr "Le lien avec l'identifiant %s n'a pas pu être trouvé."
457
458#: application/front/controller/admin/ShaareManageController.php:101
459msgid "Invalid visibility provided."
460msgstr "Visibilité du lien non valide."
461
462#: application/front/controller/admin/ShaarePublishController.php:173
463#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
464msgid "Edit"
465msgstr "Modifier"
466
467#: application/front/controller/admin/ShaarePublishController.php:176
468#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
469#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
470msgid "Shaare"
471msgstr "Shaare"
472
473#: application/front/controller/admin/ShaarePublishController.php:208
474msgid "Note: "
475msgstr "Note : "
476
470#: application/front/controller/admin/ThumbnailsController.php:37 477#: application/front/controller/admin/ThumbnailsController.php:37
471#: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 478#: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
472msgid "Thumbnails update" 479msgid "Thumbnails update"
@@ -478,33 +485,62 @@ msgstr "Mise à jour des miniatures"
478msgid "Tools" 485msgid "Tools"
479msgstr "Outils" 486msgstr "Outils"
480 487
481#: application/front/controller/visitor/BookmarkListController.php:116 488#: application/front/controller/visitor/BookmarkListController.php:121
482msgid "Search: " 489msgid "Search: "
483msgstr "Recherche : " 490msgstr "Recherche : "
484 491
485#: application/front/controller/visitor/DailyController.php:45 492#: application/front/controller/visitor/DailyController.php:200
486msgid "Today" 493msgid "day"
487msgstr "Aujourd'hui" 494msgstr "jour"
488
489#: application/front/controller/visitor/DailyController.php:47
490msgid "Yesterday"
491msgstr "Hier"
492 495
493#: application/front/controller/visitor/DailyController.php:85 496#: application/front/controller/visitor/DailyController.php:200
497#: application/front/controller/visitor/DailyController.php:203
498#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
494#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 499#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
495#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48 500#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48
496msgid "Daily" 501msgid "Daily"
497msgstr "Quotidien" 502msgstr "Quotidien"
498 503
499#: application/front/controller/visitor/ErrorController.php:36 504#: application/front/controller/visitor/DailyController.php:201
505msgid "week"
506msgstr "semaine"
507
508#: application/front/controller/visitor/DailyController.php:201
509#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
510msgid "Weekly"
511msgstr "Hebdomadaire"
512
513#: application/front/controller/visitor/DailyController.php:202
514msgid "month"
515msgstr "mois"
516
517#: application/front/controller/visitor/DailyController.php:202
518#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
519msgid "Monthly"
520msgstr "Mensuel"
521
522#: application/front/controller/visitor/ErrorController.php:30
523msgid "Error: "
524msgstr "Erreur : "
525
526#: application/front/controller/visitor/ErrorController.php:34
527msgid "Please report it on Github."
528msgstr "Merci de la rapporter sur Github."
529
530#: application/front/controller/visitor/ErrorController.php:39
500msgid "An unexpected error occurred." 531msgid "An unexpected error occurred."
501msgstr "Une erreur inattendue s'est produite." 532msgstr "Une erreur inattendue s'est produite."
502 533
503#: application/front/controller/visitor/ErrorNotFoundController.php:25 534#: application/front/controller/visitor/ErrorNotFoundController.php:25
504msgid "Requested page could not be found." 535msgid "Requested page could not be found."
505msgstr "" 536msgstr "La page demandée n'a pas pu être trouvée."
537
538#: application/front/controller/visitor/InstallController.php:65
539#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
540msgid "Install Shaarli"
541msgstr "Installation de Shaarli"
506 542
507#: application/front/controller/visitor/InstallController.php:73 543#: application/front/controller/visitor/InstallController.php:85
508#, php-format 544#, php-format
509msgid "" 545msgid ""
510"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the " 546"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
@@ -523,14 +559,14 @@ msgstr ""
523"des cookies. Nous vous recommandons d'accéder à votre serveur depuis son " 559"des cookies. Nous vous recommandons d'accéder à votre serveur depuis son "
524"adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>" 560"adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>"
525 561
526#: application/front/controller/visitor/InstallController.php:144 562#: application/front/controller/visitor/InstallController.php:157
527msgid "" 563msgid ""
528"Shaarli is now configured. Please login and start shaaring your bookmarks!" 564"Shaarli is now configured. Please login and start shaaring your bookmarks!"
529msgstr "" 565msgstr ""
530"Shaarli est maintenant configuré. Vous pouvez vous connecter et commencez à " 566"Shaarli est maintenant configuré. Vous pouvez vous connecter et commencez à "
531"shaare vos liens !" 567"shaare vos liens !"
532 568
533#: application/front/controller/visitor/InstallController.php:158 569#: application/front/controller/visitor/InstallController.php:171
534msgid "Insufficient permissions:" 570msgid "Insufficient permissions:"
535msgstr "Permissions insuffisantes :" 571msgstr "Permissions insuffisantes :"
536 572
@@ -554,9 +590,9 @@ msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
554msgid "Picture wall" 590msgid "Picture wall"
555msgstr "Mur d'images" 591msgstr "Mur d'images"
556 592
557#: application/front/controller/visitor/TagCloudController.php:88 593#: application/front/controller/visitor/TagCloudController.php:90
558msgid "Tag " 594msgid "Tag "
559msgstr "Tag" 595msgstr "Tag "
560 596
561#: application/front/exceptions/AlreadyInstalledException.php:11 597#: application/front/exceptions/AlreadyInstalledException.php:11
562msgid "Shaarli has already been installed. Login to edit the configuration." 598msgid "Shaarli has already been installed. Login to edit the configuration."
@@ -584,6 +620,94 @@ msgstr ""
584msgid "Wrong token." 620msgid "Wrong token."
585msgstr "Jeton invalide." 621msgstr "Jeton invalide."
586 622
623#: application/helper/ApplicationUtils.php:165
624#, php-format
625msgid ""
626"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
627"cannot run. Your PHP version has known security vulnerabilities and should "
628"be updated as soon as possible."
629msgstr ""
630"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
631"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
632"connues et devrait être mise à jour au plus tôt."
633
634#: application/helper/ApplicationUtils.php:200
635#: application/helper/ApplicationUtils.php:220
636msgid "directory is not readable"
637msgstr "le répertoire n'est pas accessible en lecture"
638
639#: application/helper/ApplicationUtils.php:223
640msgid "directory is not writable"
641msgstr "le répertoire n'est pas accessible en écriture"
642
643#: application/helper/ApplicationUtils.php:247
644msgid "file is not readable"
645msgstr "le fichier n'est pas accessible en lecture"
646
647#: application/helper/ApplicationUtils.php:250
648msgid "file is not writable"
649msgstr "le fichier n'est pas accessible en écriture"
650
651#: application/helper/ApplicationUtils.php:260
652msgid ""
653"Lock can not be acquired on the datastore. You might encounter concurrent "
654"access issues."
655msgstr ""
656"Le fichier datastore ne peut pas être verrouillé. Vous pourriez rencontrer "
657"des problèmes d'accès concurrents."
658
659#: application/helper/ApplicationUtils.php:293
660msgid "Configuration parsing"
661msgstr "Chargement de la configuration"
662
663#: application/helper/ApplicationUtils.php:294
664msgid "Slim Framework (routing, etc.)"
665msgstr "Slim Framwork (routage, etc.)"
666
667#: application/helper/ApplicationUtils.php:295
668msgid "Multibyte (Unicode) string support"
669msgstr "Support des chaînes de caractère multibytes (Unicode)"
670
671#: application/helper/ApplicationUtils.php:296
672msgid "Required to use thumbnails"
673msgstr "Obligatoire pour utiliser les miniatures"
674
675#: application/helper/ApplicationUtils.php:297
676msgid "Localized text sorting (e.g. e->è->f)"
677msgstr "Tri des textes traduits (ex : e->è->f)"
678
679#: application/helper/ApplicationUtils.php:298
680msgid "Better retrieval of bookmark metadata and thumbnail"
681msgstr "Meilleure récupération des meta-données des marque-pages et minatures"
682
683#: application/helper/ApplicationUtils.php:299
684msgid "Use the translation system in gettext mode"
685msgstr "Utiliser le système de traduction en mode gettext"
686
687#: application/helper/ApplicationUtils.php:300
688msgid "Login using LDAP server"
689msgstr "Authentification via un serveur LDAP"
690
691#: application/helper/DailyPageHelper.php:172
692msgid "Week"
693msgstr "Semaine"
694
695#: application/helper/DailyPageHelper.php:176
696msgid "Today"
697msgstr "Aujourd'hui"
698
699#: application/helper/DailyPageHelper.php:178
700msgid "Yesterday"
701msgstr "Hier"
702
703#: application/helper/FileUtils.php:100
704msgid "Provided path is not a directory."
705msgstr "Le chemin fourni n'est pas un dossier."
706
707#: application/helper/FileUtils.php:104
708msgid "Trying to delete a folder outside of Shaarli path."
709msgstr "Tentative de supprimer un dossier en dehors du chemin de Shaarli."
710
587#: application/legacy/LegacyLinkDB.php:131 711#: application/legacy/LegacyLinkDB.php:131
588msgid "You are not authorized to add a link." 712msgid "You are not authorized to add a link."
589msgstr "Vous n'êtes pas autorisé à ajouter un lien." 713msgstr "Vous n'êtes pas autorisé à ajouter un lien."
@@ -634,7 +758,7 @@ msgstr ""
634msgid "Couldn't retrieve updater class methods." 758msgid "Couldn't retrieve updater class methods."
635msgstr "Impossible de récupérer les méthodes de la classe Updater." 759msgstr "Impossible de récupérer les méthodes de la classe Updater."
636 760
637#: application/legacy/LegacyUpdater.php:538 761#: application/legacy/LegacyUpdater.php:540
638msgid "<a href=\"./admin/thumbnails\">" 762msgid "<a href=\"./admin/thumbnails\">"
639msgstr "<a href=\"./admin/thumbnails\">" 763msgstr "<a href=\"./admin/thumbnails\">"
640 764
@@ -660,11 +784,11 @@ msgstr ""
660"a été importé avec succès en %d secondes : %d liens importés, %d liens " 784"a été importé avec succès en %d secondes : %d liens importés, %d liens "
661"écrasés, %d liens ignorés." 785"écrasés, %d liens ignorés."
662 786
663#: application/plugin/PluginManager.php:124 787#: application/plugin/PluginManager.php:125
664msgid " [plugin incompatibility]: " 788msgid " [plugin incompatibility]: "
665msgstr " [incompatibilité de l'extension] : " 789msgstr " [incompatibilité de l'extension] : "
666 790
667#: application/plugin/exception/PluginFileNotFoundException.php:21 791#: application/plugin/exception/PluginFileNotFoundException.php:22
668#, php-format 792#, php-format
669msgid "Plugin \"%s\" files not found." 793msgid "Plugin \"%s\" files not found."
670msgstr "Les fichiers de l'extension \"%s\" sont introuvables." 794msgstr "Les fichiers de l'extension \"%s\" sont introuvables."
@@ -678,7 +802,7 @@ msgstr "Impossible de purger %s : le répertoire n'existe pas"
678msgid "An error occurred while running the update " 802msgid "An error occurred while running the update "
679msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour " 803msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour "
680 804
681#: index.php:65 805#: index.php:81
682msgid "Shared bookmarks on " 806msgid "Shared bookmarks on "
683msgstr "Liens partagés sur " 807msgstr "Liens partagés sur "
684 808
@@ -695,11 +819,11 @@ msgstr "Shaare"
695msgid "Adds the addlink input on the linklist page." 819msgid "Adds the addlink input on the linklist page."
696msgstr "Ajoute le formulaire d'ajout de liens sur la page principale." 820msgstr "Ajoute le formulaire d'ajout de liens sur la page principale."
697 821
698#: plugins/archiveorg/archiveorg.php:28 822#: plugins/archiveorg/archiveorg.php:29
699msgid "View on archive.org" 823msgid "View on archive.org"
700msgstr "Voir sur archive.org" 824msgstr "Voir sur archive.org"
701 825
702#: plugins/archiveorg/archiveorg.php:41 826#: plugins/archiveorg/archiveorg.php:42
703msgid "For each link, add an Archive.org icon." 827msgid "For each link, add an Archive.org icon."
704msgstr "Pour chaque lien, ajoute une icône pour Archive.org." 828msgstr "Pour chaque lien, ajoute une icône pour Archive.org."
705 829
@@ -729,7 +853,7 @@ msgstr "Couleur de fond (gris léger)"
729msgid "Dark main color (e.g. visited links)" 853msgid "Dark main color (e.g. visited links)"
730msgstr "Couleur principale sombre (ex : les liens visités)" 854msgstr "Couleur principale sombre (ex : les liens visités)"
731 855
732#: plugins/demo_plugin/demo_plugin.php:477 856#: plugins/demo_plugin/demo_plugin.php:478
733msgid "" 857msgid ""
734"A demo plugin covering all use cases for template designers and plugin " 858"A demo plugin covering all use cases for template designers and plugin "
735"developers." 859"developers."
@@ -737,11 +861,11 @@ msgstr ""
737"Une extension de démonstration couvrant tous les cas d'utilisation pour les " 861"Une extension de démonstration couvrant tous les cas d'utilisation pour les "
738"designers de thèmes et les développeurs d'extensions." 862"designers de thèmes et les développeurs d'extensions."
739 863
740#: plugins/demo_plugin/demo_plugin.php:478 864#: plugins/demo_plugin/demo_plugin.php:479
741msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed." 865msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
742msgstr "Ceci est un paramètre dédié au plugin de démo. Il sera suffixé." 866msgstr "Ceci est un paramètre dédié au plugin de démo. Il sera suffixé."
743 867
744#: plugins/demo_plugin/demo_plugin.php:479 868#: plugins/demo_plugin/demo_plugin.php:480
745msgid "Other demo parameter" 869msgid "Other demo parameter"
746msgstr "Un autre paramètre de démo" 870msgstr "Un autre paramètre de démo"
747 871
@@ -763,7 +887,7 @@ msgstr ""
763msgid "Isso server URL (without 'http://')" 887msgid "Isso server URL (without 'http://')"
764msgstr "URL du serveur Isso (sans 'http://')" 888msgstr "URL du serveur Isso (sans 'http://')"
765 889
766#: plugins/piwik/piwik.php:23 890#: plugins/piwik/piwik.php:24
767msgid "" 891msgid ""
768"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin " 892"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
769"administration page." 893"administration page."
@@ -771,27 +895,27 @@ msgstr ""
771"Erreur de l'extension Piwik : Merci de définir les paramètres PIWIK_URL et " 895"Erreur de l'extension Piwik : Merci de définir les paramètres PIWIK_URL et "
772"PIWIK_SITEID dans la page d'administration des extensions." 896"PIWIK_SITEID dans la page d'administration des extensions."
773 897
774#: plugins/piwik/piwik.php:72 898#: plugins/piwik/piwik.php:73
775msgid "A plugin that adds Piwik tracking code to Shaarli pages." 899msgid "A plugin that adds Piwik tracking code to Shaarli pages."
776msgstr "Ajoute le code de traçage de Piwik sur les pages de Shaarli." 900msgstr "Ajoute le code de traçage de Piwik sur les pages de Shaarli."
777 901
778#: plugins/piwik/piwik.php:73 902#: plugins/piwik/piwik.php:74
779msgid "Piwik URL" 903msgid "Piwik URL"
780msgstr "URL de Piwik" 904msgstr "URL de Piwik"
781 905
782#: plugins/piwik/piwik.php:74 906#: plugins/piwik/piwik.php:75
783msgid "Piwik site ID" 907msgid "Piwik site ID"
784msgstr "Site ID de Piwik" 908msgstr "Site ID de Piwik"
785 909
786#: plugins/playvideos/playvideos.php:25 910#: plugins/playvideos/playvideos.php:26
787msgid "Video player" 911msgid "Video player"
788msgstr "Lecteur vidéo" 912msgstr "Lecteur vidéo"
789 913
790#: plugins/playvideos/playvideos.php:28 914#: plugins/playvideos/playvideos.php:29
791msgid "Play Videos" 915msgid "Play Videos"
792msgstr "Jouer les vidéos" 916msgstr "Jouer les vidéos"
793 917
794#: plugins/playvideos/playvideos.php:59 918#: plugins/playvideos/playvideos.php:60
795msgid "Add a button in the toolbar allowing to watch all videos." 919msgid "Add a button in the toolbar allowing to watch all videos."
796msgstr "" 920msgstr ""
797"Ajoute un bouton dans la barre de menu pour regarder toutes les vidéos." 921"Ajoute un bouton dans la barre de menu pour regarder toutes les vidéos."
@@ -819,11 +943,11 @@ msgstr "Mauvaise réponse du hub %s"
819msgid "Enable PubSubHubbub feed publishing." 943msgid "Enable PubSubHubbub feed publishing."
820msgstr "Active la publication de flux vers PubSubHubbub." 944msgstr "Active la publication de flux vers PubSubHubbub."
821 945
822#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70 946#: plugins/qrcode/qrcode.php:74 plugins/wallabag/wallabag.php:72
823msgid "For each link, add a QRCode icon." 947msgid "For each link, add a QRCode icon."
824msgstr "Pour chaque lien, ajouter une icône de QRCode." 948msgstr "Pour chaque lien, ajouter une icône de QRCode."
825 949
826#: plugins/wallabag/wallabag.php:21 950#: plugins/wallabag/wallabag.php:22
827msgid "" 951msgid ""
828"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the " 952"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the "
829"plugin administration page." 953"plugin administration page."
@@ -831,15 +955,15 @@ msgstr ""
831"Erreur de l'extension Wallabag : Merci de définir le paramètre « " 955"Erreur de l'extension Wallabag : Merci de définir le paramètre « "
832"WALLABAG_URL » dans la page d'administration des extensions." 956"WALLABAG_URL » dans la page d'administration des extensions."
833 957
834#: plugins/wallabag/wallabag.php:47 958#: plugins/wallabag/wallabag.php:49
835msgid "Save to wallabag" 959msgid "Save to wallabag"
836msgstr "Sauvegarder dans Wallabag" 960msgstr "Sauvegarder dans Wallabag"
837 961
838#: plugins/wallabag/wallabag.php:71 962#: plugins/wallabag/wallabag.php:73
839msgid "Wallabag API URL" 963msgid "Wallabag API URL"
840msgstr "URL de l'API Wallabag" 964msgstr "URL de l'API Wallabag"
841 965
842#: plugins/wallabag/wallabag.php:72 966#: plugins/wallabag/wallabag.php:74
843msgid "Wallabag API version (1 or 2)" 967msgid "Wallabag API version (1 or 2)"
844msgstr "Version de l'API Wallabag (1 ou 2)" 968msgstr "Version de l'API Wallabag (1 ou 2)"
845 969
@@ -851,6 +975,48 @@ msgstr "Désolé, il y a rien à voir ici."
851msgid "URL or leave empty to post a note" 975msgid "URL or leave empty to post a note"
852msgstr "URL ou laisser vide pour créer une note" 976msgstr "URL ou laisser vide pour créer une note"
853 977
978#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
979msgid "BULK CREATION"
980msgstr "CRÉATION DE MASSE"
981
982#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
983msgid "Metadata asynchronous retrieval is disabled."
984msgstr "La récupération asynchrone des meta-données est désactivée."
985
986#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
987msgid ""
988"We recommend that you enable the setting <em>general > "
989"enable_async_metadata</em> in your configuration file to use bulk link "
990"creation."
991msgstr ""
992"Nous recommandons d'activer le paramètre <em>general > "
993"enable_async_metadata</em> dans votre fichier de configuration pour utiliser "
994"la création de masse."
995
996#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
997msgid "Shaare multiple new links"
998msgstr "Partagez plusieurs nouveaux liens"
999
1000#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59
1001msgid "Add one URL per line to create multiple bookmarks."
1002msgstr "Ajouter une URL par ligne pour créer plusieurs marque-pages."
1003
1004#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
1005#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
1006msgid "Tags"
1007msgstr "Tags"
1008
1009#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73
1010#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
1011#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
1012#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
1013msgid "Private"
1014msgstr "Privé"
1015
1016#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
1017msgid "Add links"
1018msgstr "Ajouter des liens"
1019
854#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 1020#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
855msgid "Current password" 1021msgid "Current password"
856msgstr "Mot de passe actuel" 1022msgstr "Mot de passe actuel"
@@ -885,14 +1051,40 @@ msgstr "Renommer le tag"
885msgid "Delete tag" 1051msgid "Delete tag"
886msgstr "Supprimer le tag" 1052msgstr "Supprimer le tag"
887 1053
888#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 1054#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
889msgid "You can also edit tags in the" 1055msgid "You can also edit tags in the"
890msgstr "Vous pouvez aussi modifier les tags dans la" 1056msgstr "Vous pouvez aussi modifier les tags dans la"
891 1057
892#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39 1058#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
893msgid "tag list" 1059msgid "tag list"
894msgstr "liste des tags" 1060msgstr "liste des tags"
895 1061
1062#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
1063msgid "Change tags separator"
1064msgstr "Changer le séparateur de tags"
1065
1066#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
1067msgid "Your current tag separator is"
1068msgstr "Votre séparateur actuel est"
1069
1070#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
1071msgid "New separator"
1072msgstr "Nouveau séparateur"
1073
1074#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1075#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
1076#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
1077#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
1078#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
1079msgid "Save"
1080msgstr "Enregistrer"
1081
1082#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61
1083msgid "Note that hashtags won't fully work with a non-whitespace separator."
1084msgstr ""
1085"Notez que les hashtags ne sont pas complètement fonctionnels avec un "
1086"séparateur qui n'est pas un espace."
1087
896#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 1088#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
897msgid "title" 1089msgid "title"
898msgstr "titre" 1090msgstr "titre"
@@ -1016,71 +1208,72 @@ msgstr ""
1016"miniatures." 1208"miniatures."
1017 1209
1018#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328 1210#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
1019#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56 1211#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
1020msgid "Synchronize thumbnails" 1212msgid "Synchronize thumbnails"
1021msgstr "Synchroniser les miniatures" 1213msgstr "Synchroniser les miniatures"
1022 1214
1023#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339 1215#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
1024#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30 1216#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
1217#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
1025msgid "All" 1218msgid "All"
1026msgstr "Tous" 1219msgstr "Tous"
1027 1220
1028#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343 1221#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
1222#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
1029msgid "Only common media hosts" 1223msgid "Only common media hosts"
1030msgstr "Seulement les hébergeurs de média connus" 1224msgstr "Seulement les hébergeurs de média connus"
1031 1225
1032#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347 1226#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
1227#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
1033msgid "None" 1228msgid "None"
1034msgstr "Aucune" 1229msgstr "Aucune"
1035 1230
1036#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355 1231#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
1037#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 1232msgid "1 RSS entry per :type"
1038#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139 1233msgid_plural ""
1039#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199 1234msgstr[0] "1 entrée RSS par :type"
1040msgid "Save" 1235msgstr[1] ""
1041msgstr "Enregistrer" 1236
1042 1237#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
1043#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 1238msgid "Previous :type"
1044msgid "The Daily Shaarli" 1239msgid_plural ""
1045msgstr "Le Quotidien Shaarli" 1240msgstr[0] ":type précédent"
1046 1241msgstr[1] "Jour précédent"
1047#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17 1242
1048msgid "1 RSS entry per day" 1243#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
1049msgstr "1 entrée RSS par jour" 1244#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
1050 1245msgid "All links of one :type in a single page."
1051#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37 1246msgid_plural ""
1052msgid "Previous day" 1247msgstr[0] "Tous les liens d'un :type sur une page."
1053msgstr "Jour précédent" 1248msgstr[1] "Tous les liens d'un jour sur une page."
1054 1249
1055#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 1250#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
1056msgid "All links of one day in a single page." 1251msgid "Next :type"
1057msgstr "Tous les liens d'un jour sur une page." 1252msgid_plural ""
1058 1253msgstr[0] ":type suivant"
1059#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51 1254msgstr[1] ""
1060msgid "Next day" 1255
1061msgstr "Jour suivant" 1256#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
1062
1063#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
1064msgid "Edit Shaare" 1257msgid "Edit Shaare"
1065msgstr "Modifier le Shaare" 1258msgstr "Modifier le Shaare"
1066 1259
1067#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18 1260#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
1068msgid "New Shaare" 1261msgid "New Shaare"
1069msgstr "Nouveau Shaare" 1262msgstr "Nouveau Shaare"
1070 1263
1071#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26 1264#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
1072msgid "Created:" 1265msgid "Created:"
1073msgstr "Création :" 1266msgstr "Création :"
1074 1267
1075#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29 1268#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1076msgid "URL" 1269msgid "URL"
1077msgstr "URL" 1270msgstr "URL"
1078 1271
1079#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 1272#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
1080msgid "Title" 1273msgid "Title"
1081msgstr "Titre" 1274msgstr "Titre"
1082 1275
1083#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 1276#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1084#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 1277#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
1085#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75 1278#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
1086#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99 1279#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
@@ -1088,33 +1281,27 @@ msgstr "Titre"
1088msgid "Description" 1281msgid "Description"
1089msgstr "Description" 1282msgstr "Description"
1090 1283
1091#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 1284#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
1092msgid "Tags"
1093msgstr "Tags"
1094
1095#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
1096#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
1097#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
1098msgid "Private"
1099msgstr "Privé"
1100
1101#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
1102msgid "Description will be rendered with" 1285msgid "Description will be rendered with"
1103msgstr "La description sera générée avec" 1286msgstr "La description sera générée avec"
1104 1287
1105#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68 1288#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
1106msgid "Markdown syntax documentation" 1289msgid "Markdown syntax documentation"
1107msgstr "Documentation sur la syntaxe Markdown" 1290msgstr "Documentation sur la syntaxe Markdown"
1108 1291
1109#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69 1292#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
1110msgid "Markdown syntax" 1293msgid "Markdown syntax"
1111msgstr "la syntaxe Markdown" 1294msgstr "la syntaxe Markdown"
1112 1295
1113#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88 1296#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:115
1297msgid "Cancel"
1298msgstr "Annuler"
1299
1300#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
1114msgid "Apply Changes" 1301msgid "Apply Changes"
1115msgstr "Appliquer les changements" 1302msgstr "Appliquer les changements"
1116 1303
1117#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:93 1304#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:126
1118#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173 1305#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
1119#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147 1306#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
1120#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147 1307#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
@@ -1122,6 +1309,11 @@ msgstr "Appliquer les changements"
1122msgid "Delete" 1309msgid "Delete"
1123msgstr "Supprimer" 1310msgstr "Supprimer"
1124 1311
1312#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
1313#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
1314msgid "Save all"
1315msgstr "Tout enregistrer"
1316
1125#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16 1317#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1126msgid "Export Database" 1318msgid "Export Database"
1127msgstr "Exporter les données" 1319msgstr "Exporter les données"
@@ -1179,10 +1371,6 @@ msgstr "Les doublons s'appuient sur les URL"
1179msgid "Add default tags" 1371msgid "Add default tags"
1180msgstr "Ajouter des tags par défaut" 1372msgstr "Ajouter des tags par défaut"
1181 1373
1182#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
1183msgid "Install Shaarli"
1184msgstr "Installation de Shaarli"
1185
1186#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25 1374#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
1187msgid "It looks like it's the first time you run Shaarli. Please configure it." 1375msgid "It looks like it's the first time you run Shaarli. Please configure it."
1188msgstr "" 1376msgstr ""
@@ -1215,6 +1403,10 @@ msgstr "Mes liens"
1215msgid "Install" 1403msgid "Install"
1216msgstr "Installer" 1404msgstr "Installer"
1217 1405
1406#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190
1407msgid "Server requirements"
1408msgstr "Pré-requis serveur"
1409
1218#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14 1410#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
1219#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79 1411#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
1220msgid "shaare" 1412msgid "shaare"
@@ -1288,8 +1480,8 @@ msgid "without any tag"
1288msgstr "sans tag" 1480msgstr "sans tag"
1289 1481
1290#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175 1482#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
1291#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43 1483#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1292#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43 1484#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:41
1293msgid "Fold" 1485msgid "Fold"
1294msgstr "Replier" 1486msgstr "Replier"
1295 1487
@@ -1313,6 +1505,10 @@ msgstr "Changer statut épinglé"
1313msgid "Sticky" 1505msgid "Sticky"
1314msgstr "Épinglé" 1506msgstr "Épinglé"
1315 1507
1508#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
1509msgid "Share a private link"
1510msgstr "Partager un lien privé"
1511
1316#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5 1512#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
1317#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5 1513#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5
1318msgid "Filters" 1514msgid "Filters"
@@ -1331,7 +1527,7 @@ msgstr "Afficher uniquement les liens publics"
1331#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18 1527#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
1332#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18 1528#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18
1333msgid "Filter untagged links" 1529msgid "Filter untagged links"
1334msgstr "Filtrer par liens privés" 1530msgstr "Filtrer par liens sans tag"
1335 1531
1336#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24 1532#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
1337#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24 1533#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24
@@ -1342,8 +1538,8 @@ msgstr "Tout sélectionner"
1342#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89 1538#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
1343#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:29 1539#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:29
1344#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:89 1540#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:89
1345#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44 1541#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
1346#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44 1542#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
1347msgid "Fold all" 1543msgid "Fold all"
1348msgstr "Replier tout" 1544msgstr "Replier tout"
1349 1545
@@ -1359,9 +1555,9 @@ msgid "Remember me"
1359msgstr "Rester connecté" 1555msgstr "Rester connecté"
1360 1556
1361#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15 1557#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
1362#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49 1558#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
1363#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15 1559#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
1364#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:49 1560#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
1365msgid "by the Shaarli community" 1561msgid "by the Shaarli community"
1366msgstr "par la communauté Shaarli" 1562msgstr "par la communauté Shaarli"
1367 1563
@@ -1370,18 +1566,23 @@ msgstr "par la communauté Shaarli"
1370msgid "Documentation" 1566msgid "Documentation"
1371msgstr "Documentation" 1567msgstr "Documentation"
1372 1568
1373#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45 1569#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
1374#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45 1570#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
1375msgid "Expand" 1571msgid "Expand"
1376msgstr "Déplier" 1572msgstr "Déplier"
1377 1573
1378#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46 1574#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
1379#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46 1575#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
1380msgid "Expand all" 1576msgid "Expand all"
1381msgstr "Déplier tout" 1577msgstr "Déplier tout"
1382 1578
1383#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 1579#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
1384#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:47 1580#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45
1581msgid "Are you sure you want to delete this link?"
1582msgstr "Êtes-vous sûr de vouloir supprimer ce lien ?"
1583
1584#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
1585#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46
1385msgid "Are you sure you want to delete this tag?" 1586msgid "Are you sure you want to delete this tag?"
1386msgstr "Êtes-vous sûr de vouloir supprimer ce tag ?" 1587msgstr "Êtes-vous sûr de vouloir supprimer ce tag ?"
1387 1588
@@ -1511,6 +1712,100 @@ msgstr "Configuration des extensions"
1511msgid "No parameter available." 1712msgid "No parameter available."
1512msgstr "Aucun paramètre disponible." 1713msgstr "Aucun paramètre disponible."
1513 1714
1715#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1716msgid "General"
1717msgstr "Général"
1718
1719#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
1720msgid "Index URL"
1721msgstr "URL de l'index"
1722
1723#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
1724msgid "Base path"
1725msgstr "Chemin de base"
1726
1727#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
1728msgid "Client IP"
1729msgstr "IP du client"
1730
1731#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
1732msgid "Trusted reverse proxies"
1733msgstr "Reverse proxies de confiance"
1734
1735#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1736msgid "N/A"
1737msgstr "N/A"
1738
1739#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
1740msgid "Visit releases page on Github"
1741msgstr "Visiter la page des releases sur Github"
1742
1743#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
1744msgid "Synchronize all link thumbnails"
1745msgstr "Synchroniser toutes les miniatures"
1746
1747#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:2
1748#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:2
1749msgid "Permissions"
1750msgstr "Permissions"
1751
1752#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:8
1753#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:8
1754msgid "There are permissions that need to be fixed."
1755msgstr "Il y a des permissions qui doivent être corrigées."
1756
1757#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
1758#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:23
1759msgid "All read/write permissions are properly set."
1760msgstr "Toutes les permissions de lecture/écriture sont définies correctement."
1761
1762#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
1763#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:32
1764msgid "Running PHP"
1765msgstr "Fonctionnant avec PHP"
1766
1767#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
1768#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:36
1769msgid "End of life: "
1770msgstr "Fin de vie : "
1771
1772#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
1773#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:48
1774msgid "Extension"
1775msgstr "Extension"
1776
1777#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
1778#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:49
1779msgid "Usage"
1780msgstr "Utilisation"
1781
1782#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
1783#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:50
1784msgid "Status"
1785msgstr "Statut"
1786
1787#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
1788#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
1789#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:51
1790#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:66
1791msgid "Loaded"
1792msgstr "Chargé"
1793
1794#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
1795#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
1796msgid "Required"
1797msgstr "Obligatoire"
1798
1799#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
1800#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
1801msgid "Optional"
1802msgstr "Optionnel"
1803
1804#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
1805#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:70
1806msgid "Not loaded"
1807msgstr "Non chargé"
1808
1514#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 1809#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
1515#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19 1810#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
1516msgid "tags" 1811msgid "tags"
@@ -1561,15 +1856,19 @@ msgstr "Configurer Shaarli"
1561msgid "Enable, disable and configure plugins" 1856msgid "Enable, disable and configure plugins"
1562msgstr "Activer, désactiver et configurer les extensions" 1857msgstr "Activer, désactiver et configurer les extensions"
1563 1858
1564#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28 1859#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
1860msgid "Check instance's server configuration"
1861msgstr "Vérifier la configuration serveur de l'instance"
1862
1863#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
1565msgid "Change your password" 1864msgid "Change your password"
1566msgstr "Modifier le mot de passe" 1865msgstr "Modifier le mot de passe"
1567 1866
1568#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35 1867#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1569msgid "Rename or delete a tag in all links" 1868msgid "Rename or delete a tag in all links"
1570msgstr "Renommer ou supprimer un tag dans tous les liens" 1869msgstr "Renommer ou supprimer un tag dans tous les liens"
1571 1870
1572#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41 1871#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
1573msgid "" 1872msgid ""
1574"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, " 1873"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
1575"delicious...)" 1874"delicious...)"
@@ -1577,11 +1876,11 @@ msgstr ""
1577"Importer des marques pages au format Netscape HTML (comme exportés depuis " 1876"Importer des marques pages au format Netscape HTML (comme exportés depuis "
1578"Firefox, Chrome, Opera, delicious...)" 1877"Firefox, Chrome, Opera, delicious...)"
1579 1878
1580#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42 1879#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
1581msgid "Import links" 1880msgid "Import links"
1582msgstr "Importer des liens" 1881msgstr "Importer des liens"
1583 1882
1584#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47 1883#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
1585msgid "" 1884msgid ""
1586"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, " 1885"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
1587"Opera, delicious...)" 1886"Opera, delicious...)"
@@ -1589,15 +1888,11 @@ msgstr ""
1589"Exporter les marques pages au format Netscape HTML (comme exportés depuis " 1888"Exporter les marques pages au format Netscape HTML (comme exportés depuis "
1590"Firefox, Chrome, Opera, delicious...)" 1889"Firefox, Chrome, Opera, delicious...)"
1591 1890
1592#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48 1891#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54
1593msgid "Export database" 1892msgid "Export database"
1594msgstr "Exporter les données" 1893msgstr "Exporter les données"
1595 1894
1596#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:55 1895#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
1597msgid "Synchronize all link thumbnails"
1598msgstr "Synchroniser toutes les miniatures"
1599
1600#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
1601msgid "" 1896msgid ""
1602"Drag one of these button to your bookmarks toolbar or right-click it and " 1897"Drag one of these button to your bookmarks toolbar or right-click it and "
1603"\"Bookmark This Link\"" 1898"\"Bookmark This Link\""
@@ -1605,13 +1900,13 @@ msgstr ""
1605"Glisser un de ces boutons dans votre barre de favoris ou cliquer droit " 1900"Glisser un de ces boutons dans votre barre de favoris ou cliquer droit "
1606"dessus et « Ajouter aux favoris »" 1901"dessus et « Ajouter aux favoris »"
1607 1902
1608#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82 1903#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
1609msgid "then click on the bookmarklet in any page you want to share." 1904msgid "then click on the bookmarklet in any page you want to share."
1610msgstr "" 1905msgstr ""
1611"puis cliquer sur le marque-page depuis un site que vous souhaitez partager." 1906"puis cliquer sur le marque-page depuis un site que vous souhaitez partager."
1612 1907
1613#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86 1908#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
1614#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110 1909#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
1615msgid "" 1910msgid ""
1616"Drag this link to your bookmarks toolbar or right-click it and Bookmark This " 1911"Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
1617"Link" 1912"Link"
@@ -1619,40 +1914,40 @@ msgstr ""
1619"Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « " 1914"Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « "
1620"Ajouter aux favoris »" 1915"Ajouter aux favoris »"
1621 1916
1622#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87 1917#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
1623msgid "then click ✚Shaare link button in any page you want to share" 1918msgid "then click ✚Shaare link button in any page you want to share"
1624msgstr "puis cliquer sur ✚Shaare depuis un site que vous souhaitez partager" 1919msgstr "puis cliquer sur ✚Shaare depuis un site que vous souhaitez partager"
1625 1920
1626#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96 1921#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
1627#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118 1922#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
1628msgid "The selected text is too long, it will be truncated." 1923msgid "The selected text is too long, it will be truncated."
1629msgstr "Le texte sélectionné est trop long, il sera tronqué." 1924msgstr "Le texte sélectionné est trop long, il sera tronqué."
1630 1925
1631#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106 1926#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
1632msgid "Shaare link" 1927msgid "Shaare link"
1633msgstr "Shaare" 1928msgstr "Shaare"
1634 1929
1635#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111 1930#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:107
1636msgid "" 1931msgid ""
1637"Then click ✚Add Note button anytime to start composing a private Note (text " 1932"Then click ✚Add Note button anytime to start composing a private Note (text "
1638"post) to your Shaarli" 1933"post) to your Shaarli"
1639msgstr "" 1934msgstr ""
1640"Puis cliquer sur ✚Add Note pour commencer à rédiger une Note sur Shaarli" 1935"Puis cliquer sur ✚Add Note pour commencer à rédiger une Note sur Shaarli"
1641 1936
1642#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127 1937#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
1643msgid "Add Note" 1938msgid "Add Note"
1644msgstr "Ajouter une Note" 1939msgstr "Ajouter une Note"
1645 1940
1646#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136 1941#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:132
1647msgid "3rd party" 1942msgid "3rd party"
1648msgstr "Applications tierces" 1943msgstr "Applications tierces"
1649 1944
1650#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139 1945#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135
1651#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144 1946#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:140
1652msgid "plugin" 1947msgid "plugin"
1653msgstr "extension" 1948msgstr "extension"
1654 1949
1655#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169 1950#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
1656msgid "" 1951msgid ""
1657"Drag this link to your bookmarks toolbar, or right-click it and choose " 1952"Drag this link to your bookmarks toolbar, or right-click it and choose "
1658"Bookmark This Link" 1953"Bookmark This Link"
@@ -1660,11 +1955,11 @@ msgstr ""
1660"Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « " 1955"Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « "
1661"Ajouter aux favoris »" 1956"Ajouter aux favoris »"
1662 1957
1663#~ msgid "Provided data is invalid" 1958#~ msgid "Display:"
1664#~ msgstr "Les informations fournies ne sont pas valides" 1959#~ msgstr "Afficher :"
1665 1960
1666#~ msgid "Rename" 1961#~ msgid "The Daily Shaarli"
1667#~ msgstr "Renommer" 1962#~ msgstr "Le Quotidien Shaarli"
1668 1963
1669#, fuzzy 1964#, fuzzy
1670#~| msgid "Selection" 1965#~| msgid "Selection"
diff --git a/inc/languages/ru/LC_MESSAGES/shaarli.po b/inc/languages/ru/LC_MESSAGES/shaarli.po
new file mode 100644
index 00000000..98e70425
--- /dev/null
+++ b/inc/languages/ru/LC_MESSAGES/shaarli.po
@@ -0,0 +1,1944 @@
1msgid ""
2msgstr ""
3"Project-Id-Version: Shaarli\n"
4"POT-Creation-Date: 2020-11-14 07:47+0500\n"
5"PO-Revision-Date: 2020-11-15 06:16+0500\n"
6"Last-Translator: progit <pash.vld@gmail.com>\n"
7"Language-Team: Shaarli\n"
8"Language: ru_RU\n"
9"MIME-Version: 1.0\n"
10"Content-Type: text/plain; charset=UTF-8\n"
11"Content-Transfer-Encoding: 8bit\n"
12"X-Generator: Poedit 2.0.1\n"
13"X-Poedit-Basepath: ../../../..\n"
14"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
15"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
16"X-Poedit-SourceCharset: UTF-8\n"
17"X-Poedit-KeywordsList: t:1,2;t\n"
18"X-Poedit-SearchPath-0: application\n"
19"X-Poedit-SearchPath-1: tmp\n"
20"X-Poedit-SearchPath-2: index.php\n"
21"X-Poedit-SearchPath-3: init.php\n"
22"X-Poedit-SearchPath-4: plugins\n"
23
24#: application/History.php:181
25msgid "History file isn't readable or writable"
26msgstr "Файл истории не доступен для чтения или записи"
27
28#: application/History.php:192
29msgid "Could not parse history file"
30msgstr "Не удалось разобрать файл истории"
31
32#: application/Languages.php:184
33msgid "Automatic"
34msgstr "Автоматический"
35
36#: application/Languages.php:185
37msgid "German"
38msgstr "Немецкий"
39
40#: application/Languages.php:186
41msgid "English"
42msgstr "Английский"
43
44#: application/Languages.php:187
45msgid "French"
46msgstr "Французский"
47
48#: application/Languages.php:188
49msgid "Japanese"
50msgstr "Японский"
51
52#: application/Languages.php:189
53msgid "Russian"
54msgstr "Русский"
55
56#: application/Thumbnailer.php:62
57msgid ""
58"php-gd extension must be loaded to use thumbnails. Thumbnails are now "
59"disabled. Please reload the page."
60msgstr ""
61"для использования миниатюр необходимо загрузить расширение php-gd. Миниатюры "
62"сейчас отключены. Перезагрузите страницу."
63
64#: application/Utils.php:405
65msgid "Setting not set"
66msgstr "Настройка не задана"
67
68#: application/Utils.php:412
69msgid "Unlimited"
70msgstr "Неограниченно"
71
72#: application/Utils.php:415
73msgid "B"
74msgstr "Б"
75
76#: application/Utils.php:415
77msgid "kiB"
78msgstr "КБ"
79
80#: application/Utils.php:415
81msgid "MiB"
82msgstr "МБ"
83
84#: application/Utils.php:415
85msgid "GiB"
86msgstr "ГБ"
87
88#: application/bookmark/BookmarkFileService.php:185
89#: application/bookmark/BookmarkFileService.php:207
90#: application/bookmark/BookmarkFileService.php:229
91#: application/bookmark/BookmarkFileService.php:243
92msgid "You're not authorized to alter the datastore"
93msgstr "У вас нет прав на изменение хранилища данных"
94
95#: application/bookmark/BookmarkFileService.php:210
96msgid "This bookmarks already exists"
97msgstr "Эта закладка уже существует"
98
99#: application/bookmark/BookmarkInitializer.php:42
100msgid "(private bookmark with thumbnail demo)"
101msgstr "(личная закладка с показом миниатюр)"
102
103#: application/bookmark/BookmarkInitializer.php:45
104msgid ""
105"Shaarli will automatically pick up the thumbnail for links to a variety of "
106"websites.\n"
107"\n"
108"Explore your new Shaarli instance by trying out controls and menus.\n"
109"Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the "
110"documentation](https://shaarli.readthedocs.io/en/master/) to learn more "
111"about Shaarli.\n"
112"\n"
113"Now you can edit or delete the default shaares.\n"
114msgstr ""
115"Shaarli автоматически подберет миниатюру для ссылок на различные сайты.\n"
116"\n"
117"Изучите Shaarli, попробовав элементы управления и меню.\n"
118"Посетите проект [Github](https://github.com/shaarli/Shaarli) или "
119"[документацию](https://shaarli.readthedocs.io/en/master/),чтобы узнать "
120"больше о Shaarli.\n"
121"\n"
122"Теперь вы можете редактировать или удалять шаары по умолчанию.\n"
123
124#: application/bookmark/BookmarkInitializer.php:58
125msgid "Note: Shaare descriptions"
126msgstr "Примечание: описания Шаар"
127
128#: application/bookmark/BookmarkInitializer.php:60
129msgid ""
130"Adding a shaare without entering a URL creates a text-only \"note\" post "
131"such as this one.\n"
132"This note is private, so you are the only one able to see it while logged "
133"in.\n"
134"\n"
135"You can use this to keep notes, post articles, code snippets, and much "
136"more.\n"
137"\n"
138"The Markdown formatting setting allows you to format your notes and bookmark "
139"description:\n"
140"\n"
141"### Title headings\n"
142"\n"
143"#### Multiple headings levels\n"
144" * bullet lists\n"
145" * _italic_ text\n"
146" * **bold** text\n"
147" * ~~strike through~~ text\n"
148" * `code` blocks\n"
149" * images\n"
150" * [links](https://en.wikipedia.org/wiki/Markdown)\n"
151"\n"
152"Markdown also supports tables:\n"
153"\n"
154"| Name | Type | Color | Qty |\n"
155"| ------- | --------- | ------ | ----- |\n"
156"| Orange | Fruit | Orange | 126 |\n"
157"| Apple | Fruit | Any | 62 |\n"
158"| Lemon | Fruit | Yellow | 30 |\n"
159"| Carrot | Vegetable | Red | 14 |\n"
160msgstr ""
161"При добавлении закладки без ввода URL адреса создается текстовая \"заметка"
162"\", такая как эта.\n"
163"Эта заметка является личной, поэтому вы единственный, кто может ее увидеть, "
164"находясь в системе.\n"
165"\n"
166"Вы можете использовать это для хранения заметок, публикации статей, "
167"фрагментов кода и многого другого.\n"
168"\n"
169"Параметр форматирования Markdown позволяет форматировать заметки и описание "
170"закладок:\n"
171"\n"
172"### Заголовок заголовков\n"
173"\n"
174"#### Multiple headings levels\n"
175" * маркированные списки\n"
176" * _наклонный_ текст\n"
177" * **жирный** текст\n"
178" * ~~зачеркнутый~~ текст\n"
179" * блоки `кода`\n"
180" * изображения\n"
181" * [ссылки](https://en.wikipedia.org/wiki/Markdown)\n"
182"\n"
183"Markdown также поддерживает таблицы:\n"
184"\n"
185"| Имя | Тип | Цвет | Количество |\n"
186"| ------- | --------- | ------ | ----- |\n"
187"| Апельсин | Фрукт | Оранжевый | 126 |\n"
188"| Яблоко | Фрукт | Любой | 62 |\n"
189"| Лимон | Фрукт | Желтый | 30 |\n"
190"| Морковь | Овощ | Красный | 14 |\n"
191
192#: application/bookmark/BookmarkInitializer.php:94
193#: application/legacy/LegacyLinkDB.php:246
194#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
195#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
196#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
197#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
198msgid ""
199"The personal, minimalist, super-fast, database free, bookmarking service"
200msgstr "Личный, минималистичный, сверхбыстрый сервис закладок без баз данных"
201
202#: application/bookmark/BookmarkInitializer.php:97
203msgid ""
204"Welcome to Shaarli!\n"
205"\n"
206"Shaarli allows you to bookmark your favorite pages, and share them with "
207"others or store them privately.\n"
208"You can add a description to your bookmarks, such as this one, and tag "
209"them.\n"
210"\n"
211"Create a new shaare by clicking the `+Shaare` button, or using any of the "
212"recommended tools (browser extension, mobile app, bookmarklet, REST API, "
213"etc.).\n"
214"\n"
215"You can easily retrieve your links, even with thousands of them, using the "
216"internal search engine, or search through tags (e.g. this Shaare is tagged "
217"with `shaarli` and `help`).\n"
218"Hashtags such as #shaarli #help are also supported.\n"
219"You can also filter the available [RSS feed](/feed/atom) and picture wall by "
220"tag or plaintext search.\n"
221"\n"
222"We hope that you will enjoy using Shaarli, maintained with ❤️ by the "
223"community!\n"
224"Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if "
225"you have a suggestion or encounter an issue.\n"
226msgstr ""
227"Добро пожаловать в Shaarli!\n"
228"\n"
229"Shaarli позволяет добавлять в закладки свои любимые страницы и делиться ими "
230"с другими или хранить их в частном порядке.\n"
231"Вы можете добавить описание к своим закладкам, например этой, и пометить "
232"их.\n"
233"\n"
234"Создайте новую закладку, нажав кнопку `+Поделиться`, или используя любой из "
235"рекомендуемых инструментов (расширение для браузера, мобильное приложение, "
236"букмарклет, REST API и т.д.).\n"
237"\n"
238"Вы можете легко получить свои ссылки, даже если их тысячи, с помощью "
239"внутренней поисковой системы или поиска по тегам (например, эта заметка "
240"помечена тегами `shaarli` and `help`).\n"
241"Также поддерживаются хэштеги, такие как #shaarli #help.\n"
242"Вы можете также фильтровать доступный [RSS канал](/feed/atom) и галерею по "
243"тегу или по поиску текста.\n"
244"\n"
245"Мы надеемся, что вам понравится использовать Shaarli, с ❤️ поддерживаемый "
246"сообществом!\n"
247"Не стесняйтесь открывать [запрос](https://github.com/shaarli/Shaarli/"
248"issues), если у вас есть предложение или возникла проблема.\n"
249
250#: application/bookmark/exception/BookmarkNotFoundException.php:14
251msgid "The link you are trying to reach does not exist or has been deleted."
252msgstr ""
253"Ссылка, по которой вы пытаетесь перейти, не существует или была удалена."
254
255#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:131
256msgid ""
257"Shaarli could not create the config file. Please make sure Shaarli has the "
258"right to write in the folder is it installed in."
259msgstr ""
260"Shaarli не удалось создать файл конфигурации. Убедитесь, что у Shaarli есть "
261"право на запись в папку, в которой он установлен."
262
263#: application/config/ConfigManager.php:137
264#: application/config/ConfigManager.php:164
265msgid "Invalid setting key parameter. String expected, got: "
266msgstr "Неверная настройка ключевого параметра. Ожидалась строка, получено: "
267
268#: application/config/exception/MissingFieldConfigException.php:20
269#, php-format
270msgid "Configuration value is required for %s"
271msgstr "Значение конфигурации требуется для %s"
272
273#: application/config/exception/PluginConfigOrderException.php:15
274msgid "An error occurred while trying to save plugins loading order."
275msgstr "Произошла ошибка при попытке сохранить порядок загрузки плагинов."
276
277#: application/config/exception/UnauthorizedConfigException.php:15
278msgid "You are not authorized to alter config."
279msgstr "Вы не авторизованы для изменения конфигурации."
280
281#: application/exceptions/IOException.php:23
282msgid "Error accessing"
283msgstr "Ошибка доступа"
284
285#: application/feed/FeedBuilder.php:180
286msgid "Direct link"
287msgstr "Прямая ссылка"
288
289#: application/feed/FeedBuilder.php:182
290#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
291#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
292#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
293msgid "Permalink"
294msgstr "Постоянная ссылка"
295
296#: application/front/controller/admin/ConfigureController.php:56
297#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
298msgid "Configure"
299msgstr "Настройка"
300
301#: application/front/controller/admin/ConfigureController.php:106
302#: application/legacy/LegacyUpdater.php:539
303msgid "You have enabled or changed thumbnails mode."
304msgstr "Вы включили или изменили режим миниатюр."
305
306#: application/front/controller/admin/ConfigureController.php:108
307#: application/front/controller/admin/ServerController.php:76
308#: application/legacy/LegacyUpdater.php:540
309msgid "Please synchronize them."
310msgstr "Пожалуйста, синхронизируйте их."
311
312#: application/front/controller/admin/ConfigureController.php:119
313#: application/front/controller/visitor/InstallController.php:149
314msgid "Error while writing config file after configuration update."
315msgstr "Ошибка при записи файла конфигурации после обновления конфигурации."
316
317#: application/front/controller/admin/ConfigureController.php:128
318msgid "Configuration was saved."
319msgstr "Конфигурация сохранена."
320
321#: application/front/controller/admin/ExportController.php:26
322#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
323msgid "Export"
324msgstr "Экспорт"
325
326#: application/front/controller/admin/ExportController.php:42
327msgid "Please select an export mode."
328msgstr "Выберите режим экспорта."
329
330#: application/front/controller/admin/ImportController.php:41
331#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
332msgid "Import"
333msgstr "Импорт"
334
335#: application/front/controller/admin/ImportController.php:55
336msgid "No import file provided."
337msgstr "Файл импорта не предоставлен."
338
339#: application/front/controller/admin/ImportController.php:66
340#, php-format
341msgid ""
342"The file you are trying to upload is probably bigger than what this "
343"webserver can accept (%s). Please upload in smaller chunks."
344msgstr ""
345"Файл, который вы пытаетесь загрузить, вероятно, больше, чем может принять "
346"этот сервер (%s). Пожалуйста, загружайте небольшими частями."
347
348#: application/front/controller/admin/ManageTagController.php:30
349msgid "whitespace"
350msgstr "пробел"
351
352#: application/front/controller/admin/ManageTagController.php:35
353#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
354#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
355msgid "Manage tags"
356msgstr "Управление тегами"
357
358#: application/front/controller/admin/ManageTagController.php:54
359msgid "Invalid tags provided."
360msgstr "Предоставлены недействительные теги."
361
362#: application/front/controller/admin/ManageTagController.php:78
363#, php-format
364msgid "The tag was removed from %d bookmark."
365msgid_plural "The tag was removed from %d bookmarks."
366msgstr[0] "Тег был удален из %d закладки."
367msgstr[1] "Тег был удален из %d закладок."
368msgstr[2] "Тег был удален из %d закладок."
369
370#: application/front/controller/admin/ManageTagController.php:83
371#, php-format
372msgid "The tag was renamed in %d bookmark."
373msgid_plural "The tag was renamed in %d bookmarks."
374msgstr[0] "Тег был переименован в %d закладке."
375msgstr[1] "Тег был переименован в %d закладках."
376msgstr[2] "Тег был переименован в %d закладках."
377
378#: application/front/controller/admin/ManageTagController.php:105
379msgid "Tags separator must be a single character."
380msgstr "Разделитель тегов должен состоять из одного символа."
381
382#: application/front/controller/admin/ManageTagController.php:111
383msgid "These characters are reserved and can't be used as tags separator: "
384msgstr ""
385"Эти символы зарезервированы и не могут использоваться в качестве разделителя "
386"тегов: "
387
388#: application/front/controller/admin/PasswordController.php:28
389#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
390#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
391msgid "Change password"
392msgstr "Изменить пароль"
393
394#: application/front/controller/admin/PasswordController.php:55
395msgid "You must provide the current and new password to change it."
396msgstr "Вы должны предоставить текущий и новый пароль, чтобы изменить его."
397
398#: application/front/controller/admin/PasswordController.php:71
399msgid "The old password is not correct."
400msgstr "Старый пароль неверен."
401
402#: application/front/controller/admin/PasswordController.php:97
403msgid "Your password has been changed"
404msgstr "Пароль изменен"
405
406#: application/front/controller/admin/PluginsController.php:45
407msgid "Plugin Administration"
408msgstr "Управление плагинами"
409
410#: application/front/controller/admin/PluginsController.php:76
411msgid "Setting successfully saved."
412msgstr "Настройка успешно сохранена."
413
414#: application/front/controller/admin/PluginsController.php:79
415msgid "Error while saving plugin configuration: "
416msgstr "Ошибка при сохранении конфигурации плагина: "
417
418#: application/front/controller/admin/ServerController.php:35
419msgid "Check disabled"
420msgstr "Проверка отключена"
421
422#: application/front/controller/admin/ServerController.php:57
423#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
424#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
425msgid "Server administration"
426msgstr "Администрирование сервера"
427
428#: application/front/controller/admin/ServerController.php:74
429msgid "Thumbnails cache has been cleared."
430msgstr "Кэш миниатюр очищен."
431
432#: application/front/controller/admin/ServerController.php:85
433msgid "Shaarli's cache folder has been cleared!"
434msgstr "Папка с кэшем Shaarli очищена!"
435
436#: application/front/controller/admin/ShaareAddController.php:26
437#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
438msgid "Shaare a new link"
439msgstr "Поделиться новой ссылкой"
440
441#: application/front/controller/admin/ShaareManageController.php:35
442#: application/front/controller/admin/ShaareManageController.php:93
443msgid "Invalid bookmark ID provided."
444msgstr "Указан неверный идентификатор закладки."
445
446#: application/front/controller/admin/ShaareManageController.php:47
447#: application/front/controller/admin/ShaareManageController.php:116
448#: application/front/controller/admin/ShaareManageController.php:156
449#: application/front/controller/admin/ShaarePublishController.php:82
450#, php-format
451msgid "Bookmark with identifier %s could not be found."
452msgstr "Закладка с идентификатором %s не найдена."
453
454#: application/front/controller/admin/ShaareManageController.php:101
455msgid "Invalid visibility provided."
456msgstr "Предоставлена недопустимая видимость."
457
458#: application/front/controller/admin/ShaarePublishController.php:173
459#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
460msgid "Edit"
461msgstr "Редактировать"
462
463#: application/front/controller/admin/ShaarePublishController.php:176
464#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
465#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
466msgid "Shaare"
467msgstr "Поделиться"
468
469#: application/front/controller/admin/ShaarePublishController.php:208
470msgid "Note: "
471msgstr "Заметка: "
472
473#: application/front/controller/admin/ThumbnailsController.php:37
474#: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
475msgid "Thumbnails update"
476msgstr "Обновление миниатюр"
477
478#: application/front/controller/admin/ToolsController.php:31
479#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
480#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:33
481msgid "Tools"
482msgstr "Инструменты"
483
484#: application/front/controller/visitor/BookmarkListController.php:121
485msgid "Search: "
486msgstr "Поиск: "
487
488#: application/front/controller/visitor/DailyController.php:200
489msgid "day"
490msgstr "день"
491
492#: application/front/controller/visitor/DailyController.php:200
493#: application/front/controller/visitor/DailyController.php:203
494#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
495#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
496#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48
497msgid "Daily"
498msgstr "За день"
499
500#: application/front/controller/visitor/DailyController.php:201
501msgid "week"
502msgstr "неделя"
503
504#: application/front/controller/visitor/DailyController.php:201
505#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
506msgid "Weekly"
507msgstr "За неделю"
508
509#: application/front/controller/visitor/DailyController.php:202
510msgid "month"
511msgstr "месяц"
512
513#: application/front/controller/visitor/DailyController.php:202
514#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
515msgid "Monthly"
516msgstr "За месяц"
517
518#: application/front/controller/visitor/ErrorController.php:30
519msgid "Error: "
520msgstr "Ошибка: "
521
522#: application/front/controller/visitor/ErrorController.php:34
523msgid "Please report it on Github."
524msgstr "Пожалуйста, сообщите об этом на Github."
525
526#: application/front/controller/visitor/ErrorController.php:39
527msgid "An unexpected error occurred."
528msgstr "Произошла непредвиденная ошибка."
529
530#: application/front/controller/visitor/ErrorNotFoundController.php:25
531msgid "Requested page could not be found."
532msgstr "Запрошенная страница не может быть найдена."
533
534#: application/front/controller/visitor/InstallController.php:65
535#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
536msgid "Install Shaarli"
537msgstr "Установить Shaarli"
538
539#: application/front/controller/visitor/InstallController.php:85
540#, php-format
541msgid ""
542"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
543"variable \"session.save_path\" is set correctly in your PHP config, and that "
544"you have write access to it.<br>It currently points to %s.<br>On some "
545"browsers, accessing your server via a hostname like 'localhost' or any "
546"custom hostname without a dot causes cookie storage to fail. We recommend "
547"accessing your server via it's IP address or Fully Qualified Domain Name.<br>"
548msgstr ""
549"<pre>Сессии на вашем сервере работают некорректно.<br>Убедитесь, что "
550"переменная \"session.save_path\" правильно установлена в вашей конфигурации "
551"PHP и что у вас есть доступ к ней на запись.<br>В настоящее время она "
552"указывает на %s.<br>В некоторых браузерах доступ к вашему серверу через имя "
553"хоста, например localhost или любое другое имя хоста без точки, приводит к "
554"сбою хранилища файлов cookie. Мы рекомендуем получить доступ к вашему "
555"серверу через его IP адрес или полное доменное имя.<br>"
556
557#: application/front/controller/visitor/InstallController.php:157
558msgid ""
559"Shaarli is now configured. Please login and start shaaring your bookmarks!"
560msgstr "Shaarli настроен. Войдите и начните делиться своими закладками!"
561
562#: application/front/controller/visitor/InstallController.php:171
563msgid "Insufficient permissions:"
564msgstr "Недостаточно разрешений:"
565
566#: application/front/controller/visitor/LoginController.php:46
567#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
568#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
569#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
570#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
571#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:77
572#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:101
573msgid "Login"
574msgstr "Вход"
575
576#: application/front/controller/visitor/LoginController.php:78
577msgid "Wrong login/password."
578msgstr "Неверный логин или пароль."
579
580#: application/front/controller/visitor/PictureWallController.php:29
581#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
582#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:43
583msgid "Picture wall"
584msgstr "Галерея"
585
586#: application/front/controller/visitor/TagCloudController.php:90
587msgid "Tag "
588msgstr "Тег "
589
590#: application/front/exceptions/AlreadyInstalledException.php:11
591msgid "Shaarli has already been installed. Login to edit the configuration."
592msgstr "Shaarli уже установлен. Войдите, чтобы изменить конфигурацию."
593
594#: application/front/exceptions/LoginBannedException.php:11
595msgid ""
596"You have been banned after too many failed login attempts. Try again later."
597msgstr ""
598"Вы были заблокированы из-за большого количества неудачных попыток входа в "
599"систему. Попробуйте позже."
600
601#: application/front/exceptions/OpenShaarliPasswordException.php:16
602msgid "You are not supposed to change a password on an Open Shaarli."
603msgstr "Вы не должны менять пароль на Open Shaarli."
604
605#: application/front/exceptions/ThumbnailsDisabledException.php:11
606msgid "Picture wall unavailable (thumbnails are disabled)."
607msgstr "Галерея недоступна (миниатюры отключены)."
608
609#: application/front/exceptions/WrongTokenException.php:16
610msgid "Wrong token."
611msgstr "Неправильный токен."
612
613#: application/helper/ApplicationUtils.php:163
614#, php-format
615msgid ""
616"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
617"cannot run. Your PHP version has known security vulnerabilities and should "
618"be updated as soon as possible."
619msgstr ""
620"Ваша версия PHP устарела! Shaarli требует как минимум PHP %s, и поэтому не "
621"может работать. В вашей версии PHP есть известные уязвимости в системе "
622"безопасности, и ее следует обновить как можно скорее."
623
624#: application/helper/ApplicationUtils.php:198
625#: application/helper/ApplicationUtils.php:218
626msgid "directory is not readable"
627msgstr "папка не доступна для чтения"
628
629#: application/helper/ApplicationUtils.php:221
630msgid "directory is not writable"
631msgstr "папка не доступна для записи"
632
633#: application/helper/ApplicationUtils.php:245
634msgid "file is not readable"
635msgstr "файл не доступен для чтения"
636
637#: application/helper/ApplicationUtils.php:248
638msgid "file is not writable"
639msgstr "файл не доступен для записи"
640
641#: application/helper/ApplicationUtils.php:282
642msgid "Configuration parsing"
643msgstr "Разбор конфигурации"
644
645#: application/helper/ApplicationUtils.php:283
646msgid "Slim Framework (routing, etc.)"
647msgstr "Slim Framework (маршрутизация и т. д.)"
648
649#: application/helper/ApplicationUtils.php:284
650msgid "Multibyte (Unicode) string support"
651msgstr "Поддержка многобайтовых (Unicode) строк"
652
653#: application/helper/ApplicationUtils.php:285
654msgid "Required to use thumbnails"
655msgstr "Обязательно использование миниатюр"
656
657#: application/helper/ApplicationUtils.php:286
658msgid "Localized text sorting (e.g. e->è->f)"
659msgstr "Локализованная сортировка текста (например, e->è->f)"
660
661#: application/helper/ApplicationUtils.php:287
662msgid "Better retrieval of bookmark metadata and thumbnail"
663msgstr "Лучшее получение метаданных закладок и миниатюр"
664
665#: application/helper/ApplicationUtils.php:288
666msgid "Use the translation system in gettext mode"
667msgstr "Используйте систему перевода в режиме gettext"
668
669#: application/helper/ApplicationUtils.php:289
670msgid "Login using LDAP server"
671msgstr "Вход через LDAP сервер"
672
673#: application/helper/DailyPageHelper.php:172
674msgid "Week"
675msgstr "Неделя"
676
677#: application/helper/DailyPageHelper.php:176
678msgid "Today"
679msgstr "Сегодня"
680
681#: application/helper/DailyPageHelper.php:178
682msgid "Yesterday"
683msgstr "Вчера"
684
685#: application/helper/FileUtils.php:100
686msgid "Provided path is not a directory."
687msgstr "Указанный путь не является папкой."
688
689#: application/helper/FileUtils.php:104
690msgid "Trying to delete a folder outside of Shaarli path."
691msgstr "Попытка удалить папку за пределами пути Shaarli."
692
693#: application/legacy/LegacyLinkDB.php:131
694msgid "You are not authorized to add a link."
695msgstr "Вы не авторизованы для изменения ссылки."
696
697#: application/legacy/LegacyLinkDB.php:134
698msgid "Internal Error: A link should always have an id and URL."
699msgstr "Внутренняя ошибка: ссылка всегда должна иметь идентификатор и URL."
700
701#: application/legacy/LegacyLinkDB.php:137
702msgid "You must specify an integer as a key."
703msgstr "В качестве ключа необходимо указать целое число."
704
705#: application/legacy/LegacyLinkDB.php:140
706msgid "Array offset and link ID must be equal."
707msgstr "Смещение массива и идентификатор ссылки должны быть одинаковыми."
708
709#: application/legacy/LegacyLinkDB.php:249
710msgid ""
711"Welcome to Shaarli! This is your first public bookmark. To edit or delete "
712"me, you must first login.\n"
713"\n"
714"To learn how to use Shaarli, consult the link \"Documentation\" at the "
715"bottom of this page.\n"
716"\n"
717"You use the community supported version of the original Shaarli project, by "
718"Sebastien Sauvage."
719msgstr ""
720"Добро пожаловать в Shaarli! Это ваша первая общедоступная закладка. Чтобы "
721"отредактировать или удалить меня, вы должны сначала авторизоваться.\n"
722"\n"
723"Чтобы узнать, как использовать Shaarli, перейдите по ссылке \"Документация\" "
724"внизу этой страницы.\n"
725"\n"
726"Вы используете поддерживаемую сообществом версию оригинального проекта "
727"Shaarli от Себастьяна Соваж."
728
729#: application/legacy/LegacyLinkDB.php:266
730msgid "My secret stuff... - Pastebin.com"
731msgstr "Мой секрет... - Pastebin.com"
732
733#: application/legacy/LegacyLinkDB.php:268
734msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
735msgstr ""
736"Тссс! Это личная ссылка, которую видите только ВЫ. Вы тоже можете удалить "
737"меня."
738
739#: application/legacy/LegacyUpdater.php:104
740msgid "Couldn't retrieve updater class methods."
741msgstr "Не удалось получить методы класса средства обновления."
742
743#: application/legacy/LegacyUpdater.php:540
744msgid "<a href=\"./admin/thumbnails\">"
745msgstr "<a href=\"./admin/thumbnails\">"
746
747#: application/netscape/NetscapeBookmarkUtils.php:63
748msgid "Invalid export selection:"
749msgstr "Неверный выбор экспорта:"
750
751#: application/netscape/NetscapeBookmarkUtils.php:215
752#, php-format
753msgid "File %s (%d bytes) "
754msgstr "Файл %s (%d байт) "
755
756#: application/netscape/NetscapeBookmarkUtils.php:217
757msgid "has an unknown file format. Nothing was imported."
758msgstr "имеет неизвестный формат файла. Ничего не импортировано."
759
760#: application/netscape/NetscapeBookmarkUtils.php:221
761#, php-format
762msgid ""
763"was successfully processed in %d seconds: %d bookmarks imported, %d "
764"bookmarks overwritten, %d bookmarks skipped."
765msgstr ""
766"успешно обработано за %d секунд: %d закладок импортировано, %d закладок "
767"перезаписаны, %d закладок пропущено."
768
769#: application/plugin/PluginManager.php:125
770msgid " [plugin incompatibility]: "
771msgstr " [несовместимость плагинов]: "
772
773#: application/plugin/exception/PluginFileNotFoundException.php:22
774#, php-format
775msgid "Plugin \"%s\" files not found."
776msgstr "Файл плагина \"%s\" не найден."
777
778#: application/render/PageCacheManager.php:32
779#, php-format
780msgid "Cannot purge %s: no directory"
781msgstr "Невозможно очистить%s: нет папки"
782
783#: application/updater/exception/UpdaterException.php:51
784msgid "An error occurred while running the update "
785msgstr "Произошла ошибка при запуске обновления "
786
787#: index.php:81
788msgid "Shared bookmarks on "
789msgstr "Общие закладки на "
790
791#: plugins/addlink_toolbar/addlink_toolbar.php:31
792msgid "URI"
793msgstr "URI"
794
795#: plugins/addlink_toolbar/addlink_toolbar.php:35
796#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
797msgid "Add link"
798msgstr "Добавить ссылку"
799
800#: plugins/addlink_toolbar/addlink_toolbar.php:52
801msgid "Adds the addlink input on the linklist page."
802msgstr ""
803"Добавляет на страницу списка ссылок поле для добавления новой закладки."
804
805#: plugins/archiveorg/archiveorg.php:29
806msgid "View on archive.org"
807msgstr "Посмотреть на archive.org"
808
809#: plugins/archiveorg/archiveorg.php:42
810msgid "For each link, add an Archive.org icon."
811msgstr "Для каждой ссылки добавить значок с Archive.org."
812
813#: plugins/default_colors/default_colors.php:38
814msgid ""
815"Default colors plugin error: This plugin is active and no custom color is "
816"configured."
817msgstr ""
818"Ошибка плагина цветов по умолчанию: этот плагин активен, и пользовательский "
819"цвет не настроен."
820
821#: plugins/default_colors/default_colors.php:113
822msgid "Override default theme colors. Use any CSS valid color."
823msgstr ""
824"Переопределить цвета темы по умолчанию. Используйте любой допустимый цвет "
825"CSS."
826
827#: plugins/default_colors/default_colors.php:114
828msgid "Main color (navbar green)"
829msgstr "Основной цвет (зеленый на панели навигации)"
830
831#: plugins/default_colors/default_colors.php:115
832msgid "Background color (light grey)"
833msgstr "Цвет фона (светло-серый)"
834
835#: plugins/default_colors/default_colors.php:116
836msgid "Dark main color (e.g. visited links)"
837msgstr "Темный основной цвет (например, посещенные ссылки)"
838
839#: plugins/demo_plugin/demo_plugin.php:478
840msgid ""
841"A demo plugin covering all use cases for template designers and plugin "
842"developers."
843msgstr ""
844"Демо плагин, охватывающий все варианты использования для дизайнеров шаблонов "
845"и разработчиков плагинов."
846
847#: plugins/demo_plugin/demo_plugin.php:479
848msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
849msgstr ""
850"Это параметр предназначен для демонстрационного плагина. Это будет суффикс."
851
852#: plugins/demo_plugin/demo_plugin.php:480
853msgid "Other demo parameter"
854msgstr "Другой демонстрационный параметр"
855
856#: plugins/isso/isso.php:22
857msgid ""
858"Isso plugin error: Please define the \"ISSO_SERVER\" setting in the plugin "
859"administration page."
860msgstr ""
861"Ошибка плагина Isso: определите параметр \"ISSO_SERVER\" на странице "
862"настройки плагина."
863
864#: plugins/isso/isso.php:92
865msgid "Let visitor comment your shaares on permalinks with Isso."
866msgstr ""
867"Позволить посетителю комментировать ваши закладки по постоянным ссылкам с "
868"Isso."
869
870#: plugins/isso/isso.php:93
871msgid "Isso server URL (without 'http://')"
872msgstr "URL сервера Isso (без 'http: //')"
873
874#: plugins/piwik/piwik.php:24
875msgid ""
876"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
877"administration page."
878msgstr ""
879"Ошибка плагина Piwik: укажите PIWIK_URL и PIWIK_SITEID на странице настройки "
880"плагина."
881
882#: plugins/piwik/piwik.php:73
883msgid "A plugin that adds Piwik tracking code to Shaarli pages."
884msgstr "Плагин, который добавляет код отслеживания Piwik на страницы Shaarli."
885
886#: plugins/piwik/piwik.php:74
887msgid "Piwik URL"
888msgstr "Piwik URL"
889
890#: plugins/piwik/piwik.php:75
891msgid "Piwik site ID"
892msgstr "Piwik site ID"
893
894#: plugins/playvideos/playvideos.php:26
895msgid "Video player"
896msgstr "Видео плеер"
897
898#: plugins/playvideos/playvideos.php:29
899msgid "Play Videos"
900msgstr "Воспроизвести видео"
901
902#: plugins/playvideos/playvideos.php:60
903msgid "Add a button in the toolbar allowing to watch all videos."
904msgstr ""
905"Добавьте кнопку на панель инструментов, позволяющую смотреть все видео."
906
907#: plugins/playvideos/youtube_playlist.js:214
908msgid "plugins/playvideos/jquery-1.11.2.min.js"
909msgstr "plugins/playvideos/jquery-1.11.2.min.js"
910
911#: plugins/pubsubhubbub/pubsubhubbub.php:72
912#, php-format
913msgid "Could not publish to PubSubHubbub: %s"
914msgstr "Не удалось опубликовать в PubSubHubbub: %s"
915
916#: plugins/pubsubhubbub/pubsubhubbub.php:99
917#, php-format
918msgid "Could not post to %s"
919msgstr "Не удалось отправить сообщение в %s"
920
921#: plugins/pubsubhubbub/pubsubhubbub.php:103
922#, php-format
923msgid "Bad response from the hub %s"
924msgstr "Плохой ответ от хаба %s"
925
926#: plugins/pubsubhubbub/pubsubhubbub.php:114
927msgid "Enable PubSubHubbub feed publishing."
928msgstr "Включить публикацию канала PubSubHubbub."
929
930#: plugins/qrcode/qrcode.php:74 plugins/wallabag/wallabag.php:72
931msgid "For each link, add a QRCode icon."
932msgstr "Для каждой ссылки добавить значок QR кода."
933
934#: plugins/wallabag/wallabag.php:22
935msgid ""
936"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the "
937"plugin administration page."
938msgstr ""
939"Ошибка плагина Wallabag: определите параметр \"WALLABAG_URL\" на странице "
940"настройки плагина."
941
942#: plugins/wallabag/wallabag.php:49
943msgid "Save to wallabag"
944msgstr "Сохранить в wallabag"
945
946#: plugins/wallabag/wallabag.php:73
947msgid "Wallabag API URL"
948msgstr "Wallabag API URL"
949
950#: plugins/wallabag/wallabag.php:74
951msgid "Wallabag API version (1 or 2)"
952msgstr "Wallabag версия API (1 или 2)"
953
954#: tmp/404.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
955msgid "Sorry, nothing to see here."
956msgstr "Извините, тут ничего нет."
957
958#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
959msgid "URL or leave empty to post a note"
960msgstr "URL или оставьте пустым, чтобы опубликовать заметку"
961
962#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
963msgid "BULK CREATION"
964msgstr "МАССОВОЕ СОЗДАНИЕ"
965
966#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
967msgid "Metadata asynchronous retrieval is disabled."
968msgstr "Асинхронное получение метаданных отключено."
969
970#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
971msgid ""
972"We recommend that you enable the setting <em>general > "
973"enable_async_metadata</em> in your configuration file to use bulk link "
974"creation."
975msgstr ""
976"Мы рекомендуем включить параметр <em>general > enable_async_metadata</em> в "
977"вашем файле конфигурации, чтобы использовать массовое создание ссылок."
978
979#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
980msgid "Shaare multiple new links"
981msgstr "Поделиться несколькими новыми ссылками"
982
983#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59
984msgid "Add one URL per line to create multiple bookmarks."
985msgstr "Добавьте по одному URL в строке, чтобы создать несколько закладок."
986
987#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
988#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
989msgid "Tags"
990msgstr "Теги"
991
992#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73
993#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
994#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
995#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
996msgid "Private"
997msgstr "Личный"
998
999#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
1000msgid "Add links"
1001msgstr "Добавить ссылки"
1002
1003#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1004msgid "Current password"
1005msgstr "Текущий пароль"
1006
1007#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
1008msgid "New password"
1009msgstr "Новый пароль"
1010
1011#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
1012msgid "Change"
1013msgstr "Изменить"
1014
1015#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1016#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
1017msgid "Tag"
1018msgstr "Тег"
1019
1020#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
1021msgid "New name"
1022msgstr "Новое имя"
1023
1024#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
1025msgid "Case sensitive"
1026msgstr "С учетом регистра"
1027
1028#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
1029#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
1030msgid "Rename tag"
1031msgstr "Переименовать тег"
1032
1033#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
1034msgid "Delete tag"
1035msgstr "Удалить тег"
1036
1037#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
1038msgid "You can also edit tags in the"
1039msgstr "Вы также можете редактировать теги в"
1040
1041#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
1042msgid "tag list"
1043msgstr "список тегов"
1044
1045#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
1046msgid "Change tags separator"
1047msgstr "Изменить разделитель тегов"
1048
1049#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
1050msgid "Your current tag separator is"
1051msgstr "Текущий разделитель тегов"
1052
1053#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
1054msgid "New separator"
1055msgstr "Новый разделитель"
1056
1057#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1058#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
1059#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
1060#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
1061#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
1062msgid "Save"
1063msgstr "Сохранить"
1064
1065#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61
1066msgid "Note that hashtags won't fully work with a non-whitespace separator."
1067msgstr ""
1068"Обратите внимание, что хэштеги не будут полностью работать с разделителем, "
1069"отличным от пробелов."
1070
1071#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
1072msgid "title"
1073msgstr "заголовок"
1074
1075#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
1076msgid "Home link"
1077msgstr "Домашняя ссылка"
1078
1079#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
1080msgid "Default value"
1081msgstr "Значение по умолчанию"
1082
1083#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1084msgid "Theme"
1085msgstr "Тема"
1086
1087#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:85
1088msgid "Description formatter"
1089msgstr "Средство форматирования описания"
1090
1091#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
1092#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
1093msgid "Language"
1094msgstr "Язык"
1095
1096#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143
1097#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
1098msgid "Timezone"
1099msgstr "Часовой пояс"
1100
1101#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
1102#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
1103msgid "Continent"
1104msgstr "Континент"
1105
1106#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
1107#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
1108msgid "City"
1109msgstr "Город"
1110
1111#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:191
1112msgid "Disable session cookie hijacking protection"
1113msgstr "Отключить защиту от перехвата файлов сеанса cookie"
1114
1115#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:193
1116msgid "Check this if you get disconnected or if your IP address changes often"
1117msgstr "Проверьте это, если вы отключаетесь или ваш IP адрес часто меняется"
1118
1119#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:210
1120msgid "Private links by default"
1121msgstr "Приватные ссылки по умолчанию"
1122
1123#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:211
1124msgid "All new links are private by default"
1125msgstr "Все новые ссылки по умолчанию являются приватными"
1126
1127#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:226
1128msgid "RSS direct links"
1129msgstr "RSS прямые ссылки"
1130
1131#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:227
1132msgid "Check this to use direct URL instead of permalink in feeds"
1133msgstr ""
1134"Установите этот флажок, чтобы использовать прямой URL вместо постоянной "
1135"ссылки в фидах"
1136
1137#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:242
1138msgid "Hide public links"
1139msgstr "Скрыть общедоступные ссылки"
1140
1141#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:243
1142msgid "Do not show any links if the user is not logged in"
1143msgstr "Не показывать ссылки, если пользователь не авторизован"
1144
1145#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:258
1146#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:149
1147msgid "Check updates"
1148msgstr "Проверить обновления"
1149
1150#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:259
1151#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
1152msgid "Notify me when a new release is ready"
1153msgstr "Оповестить, когда будет готов новый выпуск"
1154
1155#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274
1156msgid "Automatically retrieve description for new bookmarks"
1157msgstr "Автоматически получать описание для новых закладок"
1158
1159#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:275
1160msgid "Shaarli will try to retrieve the description from meta HTML headers"
1161msgstr "Shaarli попытается получить описание из мета заголовков HTML"
1162
1163#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:290
1164#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
1165msgid "Enable REST API"
1166msgstr "Включить REST API"
1167
1168#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:291
1169#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
1170msgid "Allow third party software to use Shaarli such as mobile application"
1171msgstr ""
1172"Разрешить стороннему программному обеспечению использовать Shaarli, например "
1173"мобильное приложение"
1174
1175#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:306
1176msgid "API secret"
1177msgstr "API ключ"
1178
1179#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:320
1180msgid "Enable thumbnails"
1181msgstr "Включить миниатюры"
1182
1183#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:324
1184msgid "You need to enable the extension <code>php-gd</code> to use thumbnails."
1185msgstr ""
1186"Вам необходимо включить расширение <code>php-gd</code> для использования "
1187"миниатюр."
1188
1189#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
1190#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
1191msgid "Synchronize thumbnails"
1192msgstr "Синхронизировать миниатюры"
1193
1194#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
1195#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
1196#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
1197msgid "All"
1198msgstr "Все"
1199
1200#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
1201#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
1202msgid "Only common media hosts"
1203msgstr "Только обычные медиа хосты"
1204
1205#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
1206#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
1207msgid "None"
1208msgstr "Ничего"
1209
1210#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
1211msgid "1 RSS entry per :type"
1212msgid_plural ""
1213msgstr[0] "1 RSS запись для каждого :type"
1214msgstr[1] "1 RSS запись для каждого :type"
1215msgstr[2] "1 RSS запись для каждого :type"
1216
1217#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
1218msgid "Previous :type"
1219msgid_plural ""
1220msgstr[0] "Предыдущий :type"
1221msgstr[1] "Предыдущих :type"
1222msgstr[2] "Предыдущих :type"
1223
1224#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
1225#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
1226msgid "All links of one :type in a single page."
1227msgid_plural ""
1228msgstr[0] "Все ссылки одного :type на одной странице."
1229msgstr[1] "Все ссылки одного :type на одной странице."
1230msgstr[2] "Все ссылки одного :type на одной странице."
1231
1232#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
1233msgid "Next :type"
1234msgid_plural ""
1235msgstr[0] "Следующий :type"
1236msgstr[1] "Следующие :type"
1237msgstr[2] "Следующие :type"
1238
1239#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
1240msgid "Edit Shaare"
1241msgstr "Изменить закладку"
1242
1243#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
1244msgid "New Shaare"
1245msgstr "Новая закладка"
1246
1247#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
1248msgid "Created:"
1249msgstr "Создано:"
1250
1251#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1252msgid "URL"
1253msgstr "URL"
1254
1255#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
1256msgid "Title"
1257msgstr "Заголовок"
1258
1259#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1260#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
1261#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
1262#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
1263#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124
1264msgid "Description"
1265msgstr "Описание"
1266
1267#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
1268msgid "Description will be rendered with"
1269msgstr "Описание будет отображаться с"
1270
1271#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
1272msgid "Markdown syntax documentation"
1273msgstr "Документация по синтаксису Markdown"
1274
1275#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
1276msgid "Markdown syntax"
1277msgstr "Синтаксис Markdown"
1278
1279#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:115
1280msgid "Cancel"
1281msgstr "Отменить"
1282
1283#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
1284msgid "Apply Changes"
1285msgstr "Применить изменения"
1286
1287#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:126
1288#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
1289#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
1290#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
1291#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
1292msgid "Delete"
1293msgstr "Удалить"
1294
1295#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
1296#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
1297msgid "Save all"
1298msgstr "Сохранить все"
1299
1300#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1301msgid "Export Database"
1302msgstr "Экспорт базы данных"
1303
1304#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
1305msgid "Selection"
1306msgstr "Выбор"
1307
1308#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
1309msgid "Public"
1310msgstr "Общедоступно"
1311
1312#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
1313msgid "Prepend note permalinks with this Shaarli instance's URL"
1314msgstr ""
1315"Добавить постоянные ссылки на заметку с URL адресом этого экземпляра Shaarli"
1316
1317#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
1318msgid "Useful to import bookmarks in a web browser"
1319msgstr "Useful to import bookmarks in a web browser"
1320
1321#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1322msgid "Import Database"
1323msgstr "Импорт базы данных"
1324
1325#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
1326msgid "Maximum size allowed:"
1327msgstr "Максимально допустимый размер:"
1328
1329#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
1330msgid "Visibility"
1331msgstr "Видимость"
1332
1333#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
1334msgid "Use values from the imported file, default to public"
1335msgstr ""
1336"Использовать значения из импортированного файла, по умолчанию общедоступные"
1337
1338#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1339msgid "Import all bookmarks as private"
1340msgstr "Импортировать все закладки как личные"
1341
1342#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
1343msgid "Import all bookmarks as public"
1344msgstr "Импортировать все закладки как общедоступные"
1345
1346#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57
1347msgid "Overwrite existing bookmarks"
1348msgstr "Заменить существующие закладки"
1349
1350#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1351msgid "Duplicates based on URL"
1352msgstr "Дубликаты на основе URL"
1353
1354#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
1355msgid "Add default tags"
1356msgstr "Добавить теги по умолчанию"
1357
1358#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
1359msgid "It looks like it's the first time you run Shaarli. Please configure it."
1360msgstr "Похоже, вы впервые запускаете Shaarli. Пожалуйста, настройте его."
1361
1362#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
1363#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1364#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:167
1365#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:167
1366msgid "Username"
1367msgstr "Имя пользователя"
1368
1369#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
1370#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
1371#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
1372#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:168
1373msgid "Password"
1374msgstr "Пароль"
1375
1376#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:62
1377msgid "Shaarli title"
1378msgstr "Заголовок Shaarli"
1379
1380#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
1381msgid "My links"
1382msgstr "Мои ссылки"
1383
1384#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
1385msgid "Install"
1386msgstr "Установка"
1387
1388#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190
1389msgid "Server requirements"
1390msgstr "Системные требования"
1391
1392#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
1393#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
1394msgid "shaare"
1395msgid_plural "shaares"
1396msgstr[0] "закладка"
1397msgstr[1] "закладки"
1398msgstr[2] "закладок"
1399
1400#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
1401#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
1402msgid "private link"
1403msgid_plural "private links"
1404msgstr[0] "личная ссылка"
1405msgstr[1] "личные ссылки"
1406msgstr[2] "личных ссылок"
1407
1408#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
1409#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
1410#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:123
1411msgid "Search text"
1412msgstr "Поиск текста"
1413
1414#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
1415#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130
1416#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:130
1417#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
1418#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
1419#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
1420#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
1421msgid "Filter by tag"
1422msgstr "Фильтровать по тегу"
1423
1424#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
1425#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
1426#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
1427#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:87
1428#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:139
1429#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
1430#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
1431msgid "Search"
1432msgstr "Поиск"
1433
1434#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
1435msgid "Nothing found."
1436msgstr "Ничего не найдено."
1437
1438#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118
1439#, php-format
1440msgid "%s result"
1441msgid_plural "%s results"
1442msgstr[0] "%s результат"
1443msgstr[1] "%s результатов"
1444msgstr[2] "%s результатов"
1445
1446#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
1447msgid "for"
1448msgstr "для"
1449
1450#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129
1451msgid "tagged"
1452msgstr "отмечено"
1453
1454#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:133
1455#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
1456msgid "Remove tag"
1457msgstr "Удалить тег"
1458
1459#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
1460msgid "with status"
1461msgstr "со статусом"
1462
1463#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
1464msgid "without any tag"
1465msgstr "без тега"
1466
1467#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
1468#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1469#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:41
1470msgid "Fold"
1471msgstr "Сложить"
1472
1473#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177
1474msgid "Edited: "
1475msgstr "Отредактировано: "
1476
1477#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
1478msgid "permalink"
1479msgstr "постоянная ссылка"
1480
1481#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
1482msgid "Add tag"
1483msgstr "Добавить тег"
1484
1485#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:185
1486msgid "Toggle sticky"
1487msgstr "Закрепить / Открепить"
1488
1489#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:187
1490msgid "Sticky"
1491msgstr "Закреплено"
1492
1493#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
1494msgid "Share a private link"
1495msgstr "Поделиться личной ссылкой"
1496
1497#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
1498#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5
1499msgid "Filters"
1500msgstr "Фильтры"
1501
1502#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:10
1503#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:10
1504msgid "Only display private links"
1505msgstr "Отображать только личные ссылки"
1506
1507#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
1508#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:13
1509msgid "Only display public links"
1510msgstr "Отображать только общедоступные ссылки"
1511
1512#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
1513#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18
1514msgid "Filter untagged links"
1515msgstr "Фильтровать неотмеченные ссылки"
1516
1517#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
1518#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24
1519msgid "Select all"
1520msgstr "Выбрать все"
1521
1522#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
1523#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
1524#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:29
1525#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:89
1526#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
1527#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
1528msgid "Fold all"
1529msgstr "Сложить все"
1530
1531#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
1532#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:76
1533msgid "Links per page"
1534msgstr "Ссылок на страницу"
1535
1536#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
1537#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
1538#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:171
1539msgid "Remember me"
1540msgstr "Запомнить меня"
1541
1542#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
1543#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
1544#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
1545#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
1546msgid "by the Shaarli community"
1547msgstr "сообществом Shaarli"
1548
1549#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1550#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:16
1551msgid "Documentation"
1552msgstr "Документация"
1553
1554#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
1555#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
1556msgid "Expand"
1557msgstr "Развернуть"
1558
1559#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
1560#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
1561msgid "Expand all"
1562msgstr "Развернуть все"
1563
1564#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
1565#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45
1566msgid "Are you sure you want to delete this link?"
1567msgstr "Вы уверены, что хотите удалить эту ссылку?"
1568
1569#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
1570#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46
1571msgid "Are you sure you want to delete this tag?"
1572msgstr "Вы уверены, что хотите удалить этот тег?"
1573
1574#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:11
1575#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:11
1576msgid "Menu"
1577msgstr "Меню"
1578
1579#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
1580#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:38
1581#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
1582msgid "Tag cloud"
1583msgstr "Облако тегов"
1584
1585#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
1586#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
1587#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:67
1588#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:92
1589msgid "RSS Feed"
1590msgstr "RSS канал"
1591
1592#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
1593#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108
1594#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:72
1595#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:108
1596msgid "Logout"
1597msgstr "Выйти"
1598
1599#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
1600#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:152
1601msgid "Set public"
1602msgstr "Сделать общедоступным"
1603
1604#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:157
1605#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:157
1606msgid "Set private"
1607msgstr "Сделать личным"
1608
1609#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
1610#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:189
1611msgid "is available"
1612msgstr "доступно"
1613
1614#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:196
1615#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:196
1616msgid "Error"
1617msgstr "Ошибка"
1618
1619#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
1620msgid "There is no cached thumbnail."
1621msgstr "Нет кэшированных миниатюр."
1622
1623#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
1624msgid "Try to synchronize them."
1625msgstr "Попробуйте синхронизировать их."
1626
1627#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
1628msgid "Picture Wall"
1629msgstr "Галерея"
1630
1631#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
1632msgid "pics"
1633msgstr "изображений"
1634
1635#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
1636msgid "You need to enable Javascript to change plugin loading order."
1637msgstr ""
1638"Вам необходимо включить Javascript, чтобы изменить порядок загрузки плагинов."
1639
1640#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
1641#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
1642msgid "Plugin administration"
1643msgstr "Управление плагинами"
1644
1645#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
1646msgid "Enabled Plugins"
1647msgstr "Включенные плагины"
1648
1649#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
1650#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
1651msgid "No plugin enabled."
1652msgstr "Нет включенных плагинов."
1653
1654#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
1655#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73
1656msgid "Disable"
1657msgstr "Отключить"
1658
1659#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1660#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
1661#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:98
1662#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
1663msgid "Name"
1664msgstr "Имя"
1665
1666#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
1667#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
1668msgid "Order"
1669msgstr "Порядок"
1670
1671#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
1672msgid "Disabled Plugins"
1673msgstr "Отключенные плагины"
1674
1675#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
1676msgid "No plugin disabled."
1677msgstr "Нет отключенных плагинов."
1678
1679#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:97
1680#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
1681msgid "Enable"
1682msgstr "Включить"
1683
1684#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
1685msgid "More plugins available"
1686msgstr "Доступны другие плагины"
1687
1688#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136
1689msgid "in the documentation"
1690msgstr "в документации"
1691
1692#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
1693msgid "Plugin configuration"
1694msgstr "Настройка плагинов"
1695
1696#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:195
1697msgid "No parameter available."
1698msgstr "Нет доступных параметров."
1699
1700#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1701msgid "General"
1702msgstr "Общее"
1703
1704#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
1705msgid "Index URL"
1706msgstr "Индексный URL"
1707
1708#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
1709msgid "Base path"
1710msgstr "Базовый путь"
1711
1712#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
1713msgid "Client IP"
1714msgstr "IP клиента"
1715
1716#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
1717msgid "Trusted reverse proxies"
1718msgstr "Надежные обратные прокси"
1719
1720#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
1721msgid "N/A"
1722msgstr "Нет данных"
1723
1724#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
1725msgid "Visit releases page on Github"
1726msgstr "Посетить страницу релизов на Github"
1727
1728#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
1729msgid "Synchronize all link thumbnails"
1730msgstr "Синхронизировать все миниатюры ссылок"
1731
1732#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:2
1733#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:2
1734msgid "Permissions"
1735msgstr "Разрешения"
1736
1737#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:8
1738#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:8
1739msgid "There are permissions that need to be fixed."
1740msgstr "Есть разрешения, которые нужно исправить."
1741
1742#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
1743#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:23
1744msgid "All read/write permissions are properly set."
1745msgstr "Все разрешения на чтение и запись установлены правильно."
1746
1747#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
1748#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:32
1749msgid "Running PHP"
1750msgstr "Запуск PHP"
1751
1752#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
1753#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:36
1754msgid "End of life: "
1755msgstr "Конец жизни: "
1756
1757#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
1758#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:48
1759msgid "Extension"
1760msgstr "Расширение"
1761
1762#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
1763#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:49
1764msgid "Usage"
1765msgstr "Применение"
1766
1767#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
1768#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:50
1769msgid "Status"
1770msgstr "Статус"
1771
1772#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
1773#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
1774#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:51
1775#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:66
1776msgid "Loaded"
1777msgstr "Загружено"
1778
1779#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
1780#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
1781msgid "Required"
1782msgstr "Обязательно"
1783
1784#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
1785#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
1786msgid "Optional"
1787msgstr "Необязательно"
1788
1789#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
1790#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:70
1791msgid "Not loaded"
1792msgstr "Не загружено"
1793
1794#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
1795#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
1796msgid "tags"
1797msgstr "теги"
1798
1799#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
1800#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
1801msgid "List all links with those tags"
1802msgstr "Список всех ссылок с этими тегами"
1803
1804#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
1805msgid "Tag list"
1806msgstr "Список тегов"
1807
1808#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3
1809#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3
1810msgid "Sort by:"
1811msgstr "Сортировать по:"
1812
1813#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
1814#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:5
1815msgid "Cloud"
1816msgstr "Облако"
1817
1818#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:6
1819#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:6
1820msgid "Most used"
1821msgstr "Наиболее используемое"
1822
1823#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
1824#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:7
1825msgid "Alphabetical"
1826msgstr "Алфавит"
1827
1828#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
1829msgid "Settings"
1830msgstr "Настройки"
1831
1832#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
1833msgid "Change Shaarli settings: title, timezone, etc."
1834msgstr "Измените настройки Shaarli: заголовок, часовой пояс и т.д."
1835
1836#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
1837msgid "Configure your Shaarli"
1838msgstr "Настройка Shaarli"
1839
1840#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
1841msgid "Enable, disable and configure plugins"
1842msgstr "Включить, отключить и настроить плагины"
1843
1844#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
1845msgid "Check instance's server configuration"
1846msgstr "Проверка конфигурации экземпляра сервера"
1847
1848#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
1849msgid "Change your password"
1850msgstr "Изменить пароль"
1851
1852#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
1853msgid "Rename or delete a tag in all links"
1854msgstr "Переименовать или удалить тег во всех ссылках"
1855
1856#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
1857msgid ""
1858"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
1859"delicious...)"
1860msgstr ""
1861"Импорт закладок Netscape HTML (экспортированные из Firefox, Chrome, Opera, "
1862"delicious...)"
1863
1864#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
1865msgid "Import links"
1866msgstr "Импорт ссылок"
1867
1868#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
1869msgid ""
1870"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
1871"Opera, delicious...)"
1872msgstr ""
1873"Экспорт закладок Netscape HTML (которые могут быть импортированы в Firefox, "
1874"Chrome, Opera, delicious...)"
1875
1876#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54
1877msgid "Export database"
1878msgstr "Экспорт базы данных"
1879
1880#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
1881msgid ""
1882"Drag one of these button to your bookmarks toolbar or right-click it and "
1883"\"Bookmark This Link\""
1884msgstr ""
1885"Перетащите одну из этих кнопок на панель закладок или щелкните по ней правой "
1886"кнопкой мыши и выберите \"Добавить ссылку в закладки\""
1887
1888#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
1889msgid "then click on the bookmarklet in any page you want to share."
1890msgstr ""
1891"затем щелкните букмарклет на любой странице, которой хотите поделиться."
1892
1893#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
1894#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
1895msgid ""
1896"Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
1897"Link"
1898msgstr ""
1899"Перетащите эту ссылку на панель закладок или щелкните по ней правой кнопкой "
1900"мыши и добавьте эту ссылку в закладки"
1901
1902#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
1903msgid "then click ✚Shaare link button in any page you want to share"
1904msgstr ""
1905"затем нажмите кнопку ✚Поделиться ссылкой на любой странице, которой хотите "
1906"поделиться"
1907
1908#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
1909#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
1910msgid "The selected text is too long, it will be truncated."
1911msgstr "Выделенный текст слишком длинный, он будет обрезан."
1912
1913#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
1914msgid "Shaare link"
1915msgstr "Поделиться ссылкой"
1916
1917#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:107
1918msgid ""
1919"Then click ✚Add Note button anytime to start composing a private Note (text "
1920"post) to your Shaarli"
1921msgstr ""
1922"Затем в любое время нажмите кнопку ✚Добавить заметку, чтобы начать создавать "
1923"личную заметку (текстовое сообщение) в своем Shaarli"
1924
1925#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
1926msgid "Add Note"
1927msgstr "Добавить заметку"
1928
1929#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:132
1930msgid "3rd party"
1931msgstr "Третья сторона"
1932
1933#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135
1934#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:140
1935msgid "plugin"
1936msgstr "плагин"
1937
1938#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
1939msgid ""
1940"Drag this link to your bookmarks toolbar, or right-click it and choose "
1941"Bookmark This Link"
1942msgstr ""
1943"Перетащите эту ссылку на панель закладок или щелкните по ней правой кнопкой "
1944"мыши и выберите \"Добавить ссылку в закладки\""
diff --git a/index.php b/index.php
index b10397dd..862c53ef 100644
--- a/index.php
+++ b/index.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Shaarli - The personal, minimalist, super-fast, database free, bookmarking service. 4 * Shaarli - The personal, minimalist, super-fast, database free, bookmarking service.
4 * 5 *
@@ -25,9 +26,13 @@ require_once 'application/Utils.php';
25 26
26require_once __DIR__ . '/init.php'; 27require_once __DIR__ . '/init.php';
27 28
29use Katzgrau\KLogger\Logger;
30use Psr\Log\LogLevel;
28use Shaarli\Config\ConfigManager; 31use Shaarli\Config\ConfigManager;
29use Shaarli\Container\ContainerBuilder; 32use Shaarli\Container\ContainerBuilder;
30use Shaarli\Languages; 33use Shaarli\Languages;
34use Shaarli\Plugin\PluginManager;
35use Shaarli\Security\BanManager;
31use Shaarli\Security\CookieManager; 36use Shaarli\Security\CookieManager;
32use Shaarli\Security\LoginManager; 37use Shaarli\Security\LoginManager;
33use Shaarli\Security\SessionManager; 38use Shaarli\Security\SessionManager;
@@ -48,10 +53,22 @@ if ($conf->get('dev.debug', false)) {
48 }); 53 });
49} 54}
50 55
56$logger = new Logger(
57 dirname($conf->get('resource.log')),
58 !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
59 ['filename' => basename($conf->get('resource.log'))]
60);
51$sessionManager = new SessionManager($_SESSION, $conf, session_save_path()); 61$sessionManager = new SessionManager($_SESSION, $conf, session_save_path());
52$sessionManager->initialize(); 62$sessionManager->initialize();
53$cookieManager = new CookieManager($_COOKIE); 63$cookieManager = new CookieManager($_COOKIE);
54$loginManager = new LoginManager($conf, $sessionManager, $cookieManager); 64$banManager = new BanManager(
65 $conf->get('security.trusted_proxies', []),
66 $conf->get('security.ban_after'),
67 $conf->get('security.ban_duration'),
68 $conf->get('resource.ban_file', 'data/ipbans.php'),
69 $logger
70);
71$loginManager = new LoginManager($conf, $sessionManager, $cookieManager, $banManager, $logger);
55$loginManager->generateStaySignedInToken($_SERVER['REMOTE_ADDR']); 72$loginManager->generateStaySignedInToken($_SERVER['REMOTE_ADDR']);
56 73
57// Sniff browser language and set date format accordingly. 74// Sniff browser language and set date format accordingly.
@@ -62,16 +79,26 @@ if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
62new Languages(setlocale(LC_MESSAGES, 0), $conf); 79new Languages(setlocale(LC_MESSAGES, 0), $conf);
63 80
64$conf->setEmpty('general.timezone', date_default_timezone_get()); 81$conf->setEmpty('general.timezone', date_default_timezone_get());
65$conf->setEmpty('general.title', t('Shared bookmarks on '). escape(index_url($_SERVER))); 82$conf->setEmpty('general.title', t('Shared bookmarks on ') . escape(index_url($_SERVER)));
66 83
67RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory 84RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl') . '/' . $conf->get('resource.theme') . '/'; // template directory
68RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory 85RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory
69 86
70date_default_timezone_set($conf->get('general.timezone', 'UTC')); 87date_default_timezone_set($conf->get('general.timezone', 'UTC'));
71 88
72$loginManager->checkLoginState(client_ip_id($_SERVER)); 89$loginManager->checkLoginState(client_ip_id($_SERVER));
73 90
74$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager); 91$pluginManager = new PluginManager($conf);
92$pluginManager->load($conf->get('general.enabled_plugins', []));
93
94$containerBuilder = new ContainerBuilder(
95 $conf,
96 $sessionManager,
97 $cookieManager,
98 $loginManager,
99 $pluginManager,
100 $logger
101);
75$container = $containerBuilder->build(); 102$container = $containerBuilder->build();
76$app = new App($container); 103$app = new App($container);
77 104
@@ -110,13 +137,16 @@ $app->group('/admin', function () {
110 $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save'); 137 $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save');
111 $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index'); 138 $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index');
112 $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save'); 139 $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save');
113 $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:addShaare'); 140 $this->post('/tags/change-separator', '\Shaarli\Front\Controller\Admin\ManageTagController:changeSeparator');
114 $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayCreateForm'); 141 $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare');
115 $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayEditForm'); 142 $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm');
116 $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:save'); 143 $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm');
117 $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ManageShaareController:deleteBookmark'); 144 $this->get('/shaare/private/{hash}', '\Shaarli\Front\Controller\Admin\ShaareManageController:sharePrivate');
118 $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ManageShaareController:changeVisibility'); 145 $this->post('/shaare-batch', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateBatchForms');
119 $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark'); 146 $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:save');
147 $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ShaareManageController:deleteBookmark');
148 $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ShaareManageController:changeVisibility');
149 $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ShaareManageController:pinBookmark');
120 $this->patch( 150 $this->patch(
121 '/shaare/{id:[0-9]+}/update-thumbnail', 151 '/shaare/{id:[0-9]+}/update-thumbnail',
122 '\Shaarli\Front\Controller\Admin\ThumbnailsController:ajaxUpdate' 152 '\Shaarli\Front\Controller\Admin\ThumbnailsController:ajaxUpdate'
@@ -128,11 +158,22 @@ $app->group('/admin', function () {
128 $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index'); 158 $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index');
129 $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save'); 159 $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
130 $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken'); 160 $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
161 $this->get('/server', '\Shaarli\Front\Controller\Admin\ServerController:index');
162 $this->get('/clear-cache', '\Shaarli\Front\Controller\Admin\ServerController:clearCache');
131 $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index'); 163 $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
132 164 $this->get('/metadata', '\Shaarli\Front\Controller\Admin\MetadataController:ajaxRetrieveTitle');
133 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility'); 165 $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
134})->add('\Shaarli\Front\ShaarliAdminMiddleware'); 166})->add('\Shaarli\Front\ShaarliAdminMiddleware');
135 167
168$app->group('/plugin', function () use ($pluginManager) {
169 foreach ($pluginManager->getRegisteredRoutes() as $pluginName => $routes) {
170 $this->group('/' . $pluginName, function () use ($routes) {
171 foreach ($routes as $route) {
172 $this->{strtolower($route['method'])}('/' . ltrim($route['route'], '/'), $route['callable']);
173 }
174 });
175 }
176})->add('\Shaarli\Front\ShaarliMiddleware');
136 177
137// REST API routes 178// REST API routes
138$app->group('/api/v1', function () { 179$app->group('/api/v1', function () {
@@ -151,6 +192,12 @@ $app->group('/api/v1', function () {
151 $this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory'); 192 $this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory');
152})->add('\Shaarli\Api\ApiMiddleware'); 193})->add('\Shaarli\Api\ApiMiddleware');
153 194
154$response = $app->run(true); 195try {
155 196 $response = $app->run(true);
156$app->respond($response); 197 $app->respond($response);
198} catch (Throwable $e) {
199 die(nl2br(
200 'An unexpected error happened, and the error template could not be displayed.' . PHP_EOL . PHP_EOL .
201 exception2text($e)
202 ));
203}
diff --git a/init.php b/init.php
index ab0e4ea7..d8462712 100644
--- a/init.php
+++ b/init.php
@@ -2,7 +2,7 @@
2 2
3require_once __DIR__ . '/vendor/autoload.php'; 3require_once __DIR__ . '/vendor/autoload.php';
4 4
5use Shaarli\ApplicationUtils; 5use Shaarli\Helper\ApplicationUtils;
6use Shaarli\Security\SessionManager; 6use Shaarli\Security\SessionManager;
7 7
8// Set 'UTC' as the default timezone if it is not defined in php.ini 8// Set 'UTC' as the default timezone if it is not defined in php.ini
diff --git a/package.json b/package.json
index 8a24512a..b879b223 100644
--- a/package.json
+++ b/package.json
@@ -7,6 +7,7 @@
7 "awesomplete": "^1.1.2", 7 "awesomplete": "^1.1.2",
8 "blazy": "^1.8.2", 8 "blazy": "^1.8.2",
9 "fork-awesome": "^1.1.7", 9 "fork-awesome": "^1.1.7",
10 "he": "^1.2.0",
10 "pure-extras": "^1.0.0", 11 "pure-extras": "^1.0.0",
11 "purecss": "^1.0.0" 12 "purecss": "^1.0.0"
12 }, 13 },
diff --git a/phpcs.xml b/phpcs.xml
index 29b95d56..9bdc8720 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -5,13 +5,19 @@
5 <file>index.php</file> 5 <file>index.php</file>
6 <file>application</file> 6 <file>application</file>
7 <file>plugins</file> 7 <file>plugins</file>
8 <file>tests</file> 8<!-- <file>tests</file>-->
9 9
10 <exclude-pattern>*/*.css</exclude-pattern> 10 <exclude-pattern>*/*.css</exclude-pattern>
11 <exclude-pattern>*/*.js</exclude-pattern> 11 <exclude-pattern>*/*.js</exclude-pattern>
12 12
13 <arg name="colors"/> 13 <arg name="colors"/>
14 14
15 <rule ref="PSR1"/> 15 <rule ref="PSR12"/>
16 <rule ref="PSR2"/> 16 <rule ref="Generic.Arrays.DisallowLongArraySyntax"/>
17
18 <rule ref="PSR1.Files.SideEffects.FoundWithSymbols">
19 <!-- index.php bootstraps everything, so yes mixed symbols with side effects -->
20 <exclude-pattern>index.php</exclude-pattern>
21 <exclude-pattern>plugins/*</exclude-pattern>
22 </rule>
17</ruleset> 23</ruleset>
diff --git a/plugins/addlink_toolbar/addlink_toolbar.php b/plugins/addlink_toolbar/addlink_toolbar.php
index ab6ed6de..80b1dd95 100644
--- a/plugins/addlink_toolbar/addlink_toolbar.php
+++ b/plugins/addlink_toolbar/addlink_toolbar.php
@@ -17,26 +17,26 @@ use Shaarli\Render\TemplatePage;
17function hook_addlink_toolbar_render_header($data) 17function hook_addlink_toolbar_render_header($data)
18{ 18{
19 if ($data['_PAGE_'] == TemplatePage::LINKLIST && $data['_LOGGEDIN_'] === true) { 19 if ($data['_PAGE_'] == TemplatePage::LINKLIST && $data['_LOGGEDIN_'] === true) {
20 $form = array( 20 $form = [
21 'attr' => array( 21 'attr' => [
22 'method' => 'GET', 22 'method' => 'GET',
23 'action' => $data['_BASE_PATH_'] . '/admin/shaare', 23 'action' => $data['_BASE_PATH_'] . '/admin/shaare',
24 'name' => 'addform', 24 'name' => 'addform',
25 'class' => 'addform', 25 'class' => 'addform',
26 ), 26 ],
27 'inputs' => array( 27 'inputs' => [
28 array( 28 [
29 'type' => 'text', 29 'type' => 'text',
30 'name' => 'post', 30 'name' => 'post',
31 'placeholder' => t('URI'), 31 'placeholder' => t('URI'),
32 ), 32 ],
33 array( 33 [
34 'type' => 'submit', 34 'type' => 'submit',
35 'value' => t('Add link'), 35 'value' => t('Add link'),
36 'class' => 'bigbutton', 36 'class' => 'bigbutton',
37 ), 37 ],
38 ), 38 ],
39 ); 39 ];
40 $data['fields_toolbar'][] = $form; 40 $data['fields_toolbar'][] = $form;
41 } 41 }
42 42
diff --git a/plugins/archiveorg/archiveorg.php b/plugins/archiveorg/archiveorg.php
index ed271532..88f2b653 100644
--- a/plugins/archiveorg/archiveorg.php
+++ b/plugins/archiveorg/archiveorg.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Plugin Archive.org. 4 * Plugin Archive.org.
4 * 5 *
diff --git a/plugins/default_colors/default_colors.php b/plugins/default_colors/default_colors.php
index e1fd5cfb..d3e1fa76 100644
--- a/plugins/default_colors/default_colors.php
+++ b/plugins/default_colors/default_colors.php
@@ -28,14 +28,14 @@ function default_colors_init($conf)
28{ 28{
29 $params = []; 29 $params = [];
30 foreach (DEFAULT_COLORS_PLACEHOLDERS as $placeholder) { 30 foreach (DEFAULT_COLORS_PLACEHOLDERS as $placeholder) {
31 $value = trim($conf->get('plugins.'. $placeholder, '')); 31 $value = trim($conf->get('plugins.' . $placeholder, ''));
32 if (strlen($value) > 0) { 32 if (strlen($value) > 0) {
33 $params[$placeholder] = $value; 33 $params[$placeholder] = $value;
34 } 34 }
35 } 35 }
36 36
37 if (empty($params)) { 37 if (empty($params)) {
38 $error = t('Default colors plugin error: '. 38 $error = t('Default colors plugin error: ' .
39 'This plugin is active and no custom color is configured.'); 39 'This plugin is active and no custom color is configured.');
40 return [$error]; 40 return [$error];
41 } 41 }
@@ -47,6 +47,20 @@ function default_colors_init($conf)
47} 47}
48 48
49/** 49/**
50 * When plugin parameters are saved, we regenerate the custom CSS file with provided settings.
51 *
52 * @param array $data $_POST array
53 *
54 * @return array Updated $_POST array
55 */
56function hook_default_colors_save_plugin_parameters($data)
57{
58 default_colors_generate_css_file($data);
59
60 return $data;
61}
62
63/**
50 * When linklist is displayed, include default_colors CSS file. 64 * When linklist is displayed, include default_colors CSS file.
51 * 65 *
52 * @param array $data - header data. 66 * @param array $data - header data.
@@ -56,7 +70,7 @@ function default_colors_init($conf)
56function hook_default_colors_render_includes($data) 70function hook_default_colors_render_includes($data)
57{ 71{
58 $file = PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css'; 72 $file = PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css';
59 if (file_exists($file )) { 73 if (file_exists($file)) {
60 $data['css_files'][] = $file ; 74 $data['css_files'][] = $file ;
61 } 75 }
62 76
@@ -75,7 +89,7 @@ function default_colors_generate_css_file($params): void
75 $content = ''; 89 $content = '';
76 foreach (DEFAULT_COLORS_PLACEHOLDERS as $rule) { 90 foreach (DEFAULT_COLORS_PLACEHOLDERS as $rule) {
77 $content .= !empty($params[$rule]) 91 $content .= !empty($params[$rule])
78 ? default_colors_format_css_rule($params, $rule) .';'. PHP_EOL 92 ? default_colors_format_css_rule($params, $rule) . ';' . PHP_EOL
79 : ''; 93 : '';
80 } 94 }
81 95
@@ -99,8 +113,8 @@ function default_colors_format_css_rule($data, $parameter)
99 } 113 }
100 114
101 $key = str_replace('DEFAULT_COLORS_', '', $parameter); 115 $key = str_replace('DEFAULT_COLORS_', '', $parameter);
102 $key = str_replace('_', '-', strtolower($key)) .'-color'; 116 $key = str_replace('_', '-', strtolower($key)) . '-color';
103 return ' --'. $key .': '. $data[$parameter]; 117 return ' --' . $key . ': ' . $data[$parameter];
104} 118}
105 119
106 120
diff --git a/plugins/demo_plugin/DemoPluginController.php b/plugins/demo_plugin/DemoPluginController.php
new file mode 100644
index 00000000..b8ace9c8
--- /dev/null
+++ b/plugins/demo_plugin/DemoPluginController.php
@@ -0,0 +1,24 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\DemoPlugin;
6
7use Shaarli\Front\Controller\Admin\ShaarliAdminController;
8use Slim\Http\Request;
9use Slim\Http\Response;
10
11class DemoPluginController extends ShaarliAdminController
12{
13 public function index(Request $request, Response $response): Response
14 {
15 $this->assignView(
16 'content',
17 '<div class="center">' .
18 'This is a demo page. I have access to Shaarli container, so I\'m free to do whatever I want here.' .
19 '</div>'
20 );
21
22 return $response->write($this->render('pluginscontent'));
23 }
24}
diff --git a/plugins/demo_plugin/demo_plugin.php b/plugins/demo_plugin/demo_plugin.php
index defb01f7..15cfc2c5 100644
--- a/plugins/demo_plugin/demo_plugin.php
+++ b/plugins/demo_plugin/demo_plugin.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Demo Plugin. 4 * Demo Plugin.
4 * 5 *
@@ -6,6 +7,8 @@
6 * Can be used by plugin developers to make their own plugin. 7 * Can be used by plugin developers to make their own plugin.
7 */ 8 */
8 9
10require_once __DIR__ . '/DemoPluginController.php';
11
9/* 12/*
10 * RENDER HEADER, INCLUDES, FOOTER 13 * RENDER HEADER, INCLUDES, FOOTER
11 * 14 *
@@ -59,6 +62,17 @@ function demo_plugin_init($conf)
59 return $errors; 62 return $errors;
60} 63}
61 64
65function demo_plugin_register_routes(): array
66{
67 return [
68 [
69 'method' => 'GET',
70 'route' => '/custom',
71 'callable' => 'Shaarli\DemoPlugin\DemoPluginController:index',
72 ],
73 ];
74}
75
62/** 76/**
63 * Hook render_header. 77 * Hook render_header.
64 * Executed on every page render. 78 * Executed on every page render.
@@ -82,14 +96,14 @@ function hook_demo_plugin_render_header($data)
82 * A link is an array of its attributes (key="value"), 96 * A link is an array of its attributes (key="value"),
83 * and a mandatory `html` key, which contains its value. 97 * and a mandatory `html` key, which contains its value.
84 */ 98 */
85 $button = array( 99 $button = [
86 'attr' => array ( 100 'attr' => [
87 'href' => '#', 101 'href' => '#',
88 'class' => 'mybutton', 102 'class' => 'mybutton',
89 'title' => 'hover me', 103 'title' => 'hover me',
90 ), 104 ],
91 'html' => 'DEMO buttons toolbar', 105 'html' => 'DEMO buttons toolbar',
92 ); 106 ];
93 $data['buttons_toolbar'][] = $button; 107 $data['buttons_toolbar'][] = $button;
94 } 108 }
95 109
@@ -115,29 +129,29 @@ function hook_demo_plugin_render_header($data)
115 * <input input-2-attribute-1="input 2 attribute 1 value"> 129 * <input input-2-attribute-1="input 2 attribute 1 value">
116 * </form> 130 * </form>
117 */ 131 */
118 $form = array( 132 $form = [
119 'attr' => array( 133 'attr' => [
120 'method' => 'GET', 134 'method' => 'GET',
121 'action' => $data['_BASE_PATH_'] . '/', 135 'action' => $data['_BASE_PATH_'] . '/',
122 'class' => 'addform', 136 'class' => 'addform',
123 ), 137 ],
124 'inputs' => array( 138 'inputs' => [
125 array( 139 [
126 'type' => 'text', 140 'type' => 'text',
127 'name' => 'demo', 141 'name' => 'demo',
128 'placeholder' => 'demo', 142 'placeholder' => 'demo',
129 ) 143 ]
130 ) 144 ]
131 ); 145 ];
132 $data['fields_toolbar'][] = $form; 146 $data['fields_toolbar'][] = $form;
133 } 147 }
134 // Another button always displayed 148 // Another button always displayed
135 $button = array( 149 $button = [
136 'attr' => array( 150 'attr' => [
137 'href' => '#', 151 'href' => '#',
138 ), 152 ],
139 'html' => 'Demo', 153 'html' => 'Demo',
140 ); 154 ];
141 $data['buttons_toolbar'][] = $button; 155 $data['buttons_toolbar'][] = $button;
142 156
143 return $data; 157 return $data;
@@ -187,7 +201,7 @@ function hook_demo_plugin_render_includes($data)
187function hook_demo_plugin_render_footer($data) 201function hook_demo_plugin_render_footer($data)
188{ 202{
189 // Footer text 203 // Footer text
190 $data['text'][] = '<br>'. demo_plugin_t('Shaarli is now enhanced by the awesome demo_plugin.'); 204 $data['text'][] = '<br>' . demo_plugin_t('Shaarli is now enhanced by the awesome demo_plugin.');
191 205
192 // Free elements at the end of the page. 206 // Free elements at the end of the page.
193 $data['endofpage'][] = '<marquee id="demo_marquee">' . 207 $data['endofpage'][] = '<marquee id="demo_marquee">' .
@@ -229,13 +243,13 @@ function hook_demo_plugin_render_linklist($data)
229 * and a mandatory `html` key, which contains its value. 243 * and a mandatory `html` key, which contains its value.
230 * It's also recommended to add key 'on' or 'off' for theme rendering. 244 * It's also recommended to add key 'on' or 'off' for theme rendering.
231 */ 245 */
232 $action = array( 246 $action = [
233 'attr' => array( 247 'attr' => [
234 'href' => '?up', 248 'href' => '?up',
235 'title' => 'Uppercase!', 249 'title' => 'Uppercase!',
236 ), 250 ],
237 'html' => '←', 251 'html' => '←',
238 ); 252 ];
239 253
240 if (isset($_GET['up'])) { 254 if (isset($_GET['up'])) {
241 // Manipulate link data 255 // Manipulate link data
@@ -275,7 +289,7 @@ function hook_demo_plugin_render_linklist($data)
275function hook_demo_plugin_render_editlink($data) 289function hook_demo_plugin_render_editlink($data)
276{ 290{
277 // Load HTML into a string 291 // Load HTML into a string
278 $html = file_get_contents(PluginManager::$PLUGINS_PATH .'/demo_plugin/field.html'); 292 $html = file_get_contents(PluginManager::$PLUGINS_PATH . '/demo_plugin/field.html');
279 293
280 // Replace value in HTML if it exists in $data 294 // Replace value in HTML if it exists in $data
281 if (!empty($data['link']['stuff'])) { 295 if (!empty($data['link']['stuff'])) {
@@ -303,7 +317,11 @@ function hook_demo_plugin_render_editlink($data)
303function hook_demo_plugin_render_tools($data) 317function hook_demo_plugin_render_tools($data)
304{ 318{
305 // field_plugin 319 // field_plugin
306 $data['tools_plugin'][] = 'tools_plugin'; 320 $data['tools_plugin'][] = '<div class="tools-item">
321 <a href="' . $data['_BASE_PATH_'] . '/plugin/demo_plugin/custom">
322 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Demo Plugin Custom Route</span>
323 </a>
324 </div>';
307 325
308 return $data; 326 return $data;
309} 327}
diff --git a/plugins/isso/isso.php b/plugins/isso/isso.php
index d4632163..a5450989 100644
--- a/plugins/isso/isso.php
+++ b/plugins/isso/isso.php
@@ -19,9 +19,9 @@ function isso_init($conf)
19{ 19{
20 $issoUrl = $conf->get('plugins.ISSO_SERVER'); 20 $issoUrl = $conf->get('plugins.ISSO_SERVER');
21 if (empty($issoUrl)) { 21 if (empty($issoUrl)) {
22 $error = t('Isso plugin error: '. 22 $error = t('Isso plugin error: ' .
23 'Please define the "ISSO_SERVER" setting in the plugin administration page.'); 23 'Please define the "ISSO_SERVER" setting in the plugin administration page.');
24 return array($error); 24 return [$error];
25 } 25 }
26} 26}
27 27
@@ -49,12 +49,12 @@ function hook_isso_render_linklist($data, $conf)
49 $isso = sprintf($issoHtml, $issoUrl, $issoUrl, $link['id'], $link['id']); 49 $isso = sprintf($issoHtml, $issoUrl, $issoUrl, $link['id'], $link['id']);
50 $data['plugin_end_zone'][] = $isso; 50 $data['plugin_end_zone'][] = $isso;
51 } else { 51 } else {
52 $button = '<span><a href="'. ($data['_BASE_PATH_'] ?? '') . '/shaare/%s#isso-thread">'; 52 $button = '<span><a href="' . ($data['_BASE_PATH_'] ?? '') . '/shaare/%s#isso-thread">';
53 // For the default theme we use a FontAwesome icon which is better than an image 53 // For the default theme we use a FontAwesome icon which is better than an image
54 if ($conf->get('resource.theme') === 'default') { 54 if ($conf->get('resource.theme') === 'default') {
55 $button .= '<i class="linklist-plugin-icon fa fa-comment"></i>'; 55 $button .= '<i class="linklist-plugin-icon fa fa-comment"></i>';
56 } else { 56 } else {
57 $button .= '<img class="linklist-plugin-icon" src="'. $data['_ROOT_PATH_'].'/plugins/isso/comment.png" '; 57 $button .= '<img class="linklist-plugin-icon" src="' . $data['_ROOT_PATH_'] . '/plugins/isso/comment.png" ';
58 $button .= 'title="Comment on this shaare" alt="Comments" />'; 58 $button .= 'title="Comment on this shaare" alt="Comments" />';
59 } 59 }
60 $button .= '</a></span>'; 60 $button .= '</a></span>';
diff --git a/plugins/piwik/piwik.php b/plugins/piwik/piwik.php
index 17b1aecc..efea8610 100644
--- a/plugins/piwik/piwik.php
+++ b/plugins/piwik/piwik.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Piwik plugin. 4 * Piwik plugin.
4 * Adds tracking code on each page. 5 * Adds tracking code on each page.
@@ -22,7 +23,7 @@ function piwik_init($conf)
22 if (empty($piwikUrl) || empty($piwikSiteid)) { 23 if (empty($piwikUrl) || empty($piwikSiteid)) {
23 $error = t('Piwik plugin error: ' . 24 $error = t('Piwik plugin error: ' .
24 'Please define PIWIK_URL and PIWIK_SITEID in the plugin administration page.'); 25 'Please define PIWIK_URL and PIWIK_SITEID in the plugin administration page.');
25 return array($error); 26 return [$error];
26 } 27 }
27} 28}
28 29
diff --git a/plugins/playvideos/playvideos.php b/plugins/playvideos/playvideos.php
index 91a9c1e5..4f874f92 100644
--- a/plugins/playvideos/playvideos.php
+++ b/plugins/playvideos/playvideos.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Plugin PlayVideos 4 * Plugin PlayVideos
4 * 5 *
@@ -19,14 +20,14 @@ use Shaarli\Render\TemplatePage;
19function hook_playvideos_render_header($data) 20function hook_playvideos_render_header($data)
20{ 21{
21 if ($data['_PAGE_'] == TemplatePage::LINKLIST) { 22 if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
22 $playvideo = array( 23 $playvideo = [
23 'attr' => array( 24 'attr' => [
24 'href' => '#', 25 'href' => '#',
25 'title' => t('Video player'), 26 'title' => t('Video player'),
26 'id' => 'playvideos', 27 'id' => 'playvideos',
27 ), 28 ],
28 'html' => '► '. t('Play Videos') 29 'html' => '► ' . t('Play Videos')
29 ); 30 ];
30 $data['buttons_toolbar'][] = $playvideo; 31 $data['buttons_toolbar'][] = $playvideo;
31 } 32 }
32 33
diff --git a/plugins/pubsubhubbub/pubsubhubbub.php b/plugins/pubsubhubbub/pubsubhubbub.php
index 8fe6799c..299b84fb 100644
--- a/plugins/pubsubhubbub/pubsubhubbub.php
+++ b/plugins/pubsubhubbub/pubsubhubbub.php
@@ -42,7 +42,7 @@ function pubsubhubbub_init($conf)
42function hook_pubsubhubbub_render_feed($data, $conf) 42function hook_pubsubhubbub_render_feed($data, $conf)
43{ 43{
44 $feedType = $data['_PAGE_'] == TemplatePage::FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM; 44 $feedType = $data['_PAGE_'] == TemplatePage::FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
45 $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml'); 45 $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.' . $feedType . '.xml');
46 $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL')); 46 $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL'));
47 47
48 return $data; 48 return $data;
@@ -59,10 +59,10 @@ function hook_pubsubhubbub_render_feed($data, $conf)
59 */ 59 */
60function hook_pubsubhubbub_save_link($data, $conf) 60function hook_pubsubhubbub_save_link($data, $conf)
61{ 61{
62 $feeds = array( 62 $feeds = [
63 index_url($_SERVER) .'feed/atom', 63 index_url($_SERVER) . 'feed/atom',
64 index_url($_SERVER) .'feed/rss', 64 index_url($_SERVER) . 'feed/rss',
65 ); 65 ];
66 66
67 $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post'; 67 $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post';
68 try { 68 try {
@@ -87,11 +87,11 @@ function hook_pubsubhubbub_save_link($data, $conf)
87 */ 87 */
88function nocurl_http_post($url, $postString) 88function nocurl_http_post($url, $postString)
89{ 89{
90 $params = array('http' => array( 90 $params = ['http' => [
91 'method' => 'POST', 91 'method' => 'POST',
92 'content' => $postString, 92 'content' => $postString,
93 'user_agent' => 'PubSubHubbub-Publisher-PHP/1.0', 93 'user_agent' => 'PubSubHubbub-Publisher-PHP/1.0',
94 )); 94 ]];
95 95
96 $context = stream_context_create($params); 96 $context = stream_context_create($params);
97 $fp = @fopen($url, 'rb', false, $context); 97 $fp = @fopen($url, 'rb', false, $context);
diff --git a/plugins/qrcode/qrcode.php b/plugins/qrcode/qrcode.php
index 24fd18ba..2ae10476 100644
--- a/plugins/qrcode/qrcode.php
+++ b/plugins/qrcode/qrcode.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Plugin qrcode 4 * Plugin qrcode
4 * Add QRCode containing URL for each links. 5 * Add QRCode containing URL for each links.
diff --git a/plugins/wallabag/WallabagInstance.php b/plugins/wallabag/WallabagInstance.php
index f4a0a92b..88f84ae3 100644
--- a/plugins/wallabag/WallabagInstance.php
+++ b/plugins/wallabag/WallabagInstance.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Plugin\Wallabag; 3namespace Shaarli\Plugin\Wallabag;
3 4
4/** 5/**
@@ -11,20 +12,20 @@ class WallabagInstance
11 * - key: version ID, must match plugin settings. 12 * - key: version ID, must match plugin settings.
12 * - value: version name. 13 * - value: version name.
13 */ 14 */
14 private static $wallabagVersions = array( 15 private static $wallabagVersions = [
15 1 => '1.x', 16 1 => '1.x',
16 2 => '2.x', 17 2 => '2.x',
17 ); 18 ];
18 19
19 /** 20 /**
20 * @var array Static reference to WB endpoint according to the API version. 21 * @var array Static reference to WB endpoint according to the API version.
21 * - key: version name. 22 * - key: version name.
22 * - value: endpoint. 23 * - value: endpoint.
23 */ 24 */
24 private static $wallabagEndpoints = array( 25 private static $wallabagEndpoints = [
25 '1.x' => '?plainurl=', 26 '1.x' => '?plainurl=',
26 '2.x' => 'bookmarklet?url=', 27 '2.x' => 'bookmarklet?url=',
27 ); 28 ];
28 29
29 /** 30 /**
30 * @var string Wallabag user instance URL. 31 * @var string Wallabag user instance URL.
diff --git a/plugins/wallabag/wallabag.php b/plugins/wallabag/wallabag.php
index d0df3501..f2003cb9 100644
--- a/plugins/wallabag/wallabag.php
+++ b/plugins/wallabag/wallabag.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2/** 3/**
3 * Wallabag plugin 4 * Wallabag plugin
4 */ 5 */
@@ -18,10 +19,11 @@ function wallabag_init($conf)
18{ 19{
19 $wallabagUrl = $conf->get('plugins.WALLABAG_URL'); 20 $wallabagUrl = $conf->get('plugins.WALLABAG_URL');
20 if (empty($wallabagUrl)) { 21 if (empty($wallabagUrl)) {
21 $error = t('Wallabag plugin error: '. 22 $error = t('Wallabag plugin error: ' .
22 'Please define the "WALLABAG_URL" setting in the plugin administration page.'); 23 'Please define the "WALLABAG_URL" setting in the plugin administration page.');
23 return array($error); 24 return [$error];
24 } 25 }
26 $conf->setEmpty('plugins.WALLABAG_URL', '2');
25} 27}
26 28
27/** 29/**
@@ -35,7 +37,7 @@ function wallabag_init($conf)
35function hook_wallabag_render_linklist($data, $conf) 37function hook_wallabag_render_linklist($data, $conf)
36{ 38{
37 $wallabagUrl = $conf->get('plugins.WALLABAG_URL'); 39 $wallabagUrl = $conf->get('plugins.WALLABAG_URL');
38 if (empty($wallabagUrl)) { 40 if (empty($wallabagUrl) || !$data['_LOGGEDIN_']) {
39 return $data; 41 return $data;
40 } 42 }
41 43
@@ -51,7 +53,7 @@ function hook_wallabag_render_linklist($data, $conf)
51 $wallabag = sprintf( 53 $wallabag = sprintf(
52 $wallabagHtml, 54 $wallabagHtml,
53 $wallabagInstance->getWallabagUrl(), 55 $wallabagInstance->getWallabagUrl(),
54 urlencode($value['url']), 56 urlencode(unescape($value['url'])),
55 $path, 57 $path,
56 $linkTitle 58 $linkTitle
57 ); 59 );
diff --git a/tests/PluginManagerTest.php b/tests/PluginManagerTest.php
index efef5e87..8947f679 100644
--- a/tests/PluginManagerTest.php
+++ b/tests/PluginManagerTest.php
@@ -120,4 +120,43 @@ class PluginManagerTest extends \Shaarli\TestCase
120 $this->assertEquals('test plugin', $meta[self::$pluginName]['description']); 120 $this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
121 $this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']); 121 $this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
122 } 122 }
123
124 /**
125 * Test plugin custom routes - note that there is no check on callable functions
126 */
127 public function testRegisteredRoutes(): void
128 {
129 PluginManager::$PLUGINS_PATH = self::$pluginPath;
130 $this->pluginManager->load([self::$pluginName]);
131
132 $expectedParameters = [
133 [
134 'method' => 'GET',
135 'route' => '/test',
136 'callable' => 'getFunction',
137 ],
138 [
139 'method' => 'POST',
140 'route' => '/custom',
141 'callable' => 'postFunction',
142 ],
143 ];
144 $meta = $this->pluginManager->getRegisteredRoutes();
145 static::assertSame($expectedParameters, $meta[self::$pluginName]);
146 }
147
148 /**
149 * Test plugin custom routes with invalid route
150 */
151 public function testRegisteredRoutesInvalid(): void
152 {
153 $plugin = 'test_route_invalid';
154 $this->pluginManager->load([$plugin]);
155
156 $meta = $this->pluginManager->getRegisteredRoutes();
157 static::assertSame([], $meta);
158
159 $errors = $this->pluginManager->getErrors();
160 static::assertSame(['test_route_invalid [plugin incompatibility]: trying to register invalid route.'], $errors);
161 }
123} 162}
diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php
index 6e787d7f..59dca75f 100644
--- a/tests/UtilsTest.php
+++ b/tests/UtilsTest.php
@@ -63,41 +63,25 @@ class UtilsTest extends \Shaarli\TestCase
63 } 63 }
64 64
65 /** 65 /**
66 * Log a message to a file - IPv4 client address 66 * Format a log a message - IPv4 client address
67 */ 67 */
68 public function testLogmIp4() 68 public function testFormatLogIp4()
69 { 69 {
70 $logMessage = 'IPv4 client connected'; 70 $message = 'IPv4 client connected';
71 logm(self::$testLogFile, '127.0.0.1', $logMessage); 71 $log = format_log($message, '127.0.0.1');
72 list($date, $ip, $message) = $this->getLastLogEntry();
73 72
74 $this->assertInstanceOf( 73 static::assertSame('- 127.0.0.1 - IPv4 client connected', $log);
75 'DateTime',
76 DateTime::createFromFormat(self::$dateFormat, $date)
77 );
78 $this->assertTrue(
79 filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false
80 );
81 $this->assertEquals($logMessage, $message);
82 } 74 }
83 75
84 /** 76 /**
85 * Log a message to a file - IPv6 client address 77 * Format a log a message - IPv6 client address
86 */ 78 */
87 public function testLogmIp6() 79 public function testFormatLogIp6()
88 { 80 {
89 $logMessage = 'IPv6 client connected'; 81 $message = 'IPv6 client connected';
90 logm(self::$testLogFile, '2001:db8::ff00:42:8329', $logMessage); 82 $log = format_log($message, '2001:db8::ff00:42:8329');
91 list($date, $ip, $message) = $this->getLastLogEntry();
92 83
93 $this->assertInstanceOf( 84 static::assertSame('- 2001:db8::ff00:42:8329 - IPv6 client connected', $log);
94 'DateTime',
95 DateTime::createFromFormat(self::$dateFormat, $date)
96 );
97 $this->assertTrue(
98 filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
99 );
100 $this->assertEquals($logMessage, $message);
101 } 85 }
102 86
103 /** 87 /**
diff --git a/tests/api/controllers/links/PostLinkTest.php b/tests/api/controllers/links/PostLinkTest.php
index 7ff92f5c..f755e2d2 100644
--- a/tests/api/controllers/links/PostLinkTest.php
+++ b/tests/api/controllers/links/PostLinkTest.php
@@ -92,8 +92,8 @@ class PostLinkTest extends TestCase
92 92
93 $mock = $this->createMock(Router::class); 93 $mock = $this->createMock(Router::class);
94 $mock->expects($this->any()) 94 $mock->expects($this->any())
95 ->method('relativePathFor') 95 ->method('pathFor')
96 ->willReturn('api/v1/bookmarks/1'); 96 ->willReturn('/api/v1/bookmarks/1');
97 97
98 // affect @property-read... seems to work 98 // affect @property-read... seems to work
99 $this->controller->getCi()->router = $mock; 99 $this->controller->getCi()->router = $mock;
@@ -128,7 +128,7 @@ class PostLinkTest extends TestCase
128 128
129 $response = $this->controller->postLink($request, new Response()); 129 $response = $this->controller->postLink($request, new Response());
130 $this->assertEquals(201, $response->getStatusCode()); 130 $this->assertEquals(201, $response->getStatusCode());
131 $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]); 131 $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
132 $data = json_decode((string) $response->getBody(), true); 132 $data = json_decode((string) $response->getBody(), true);
133 $this->assertEquals(self::NB_FIELDS_LINK, count($data)); 133 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
134 $this->assertEquals(43, $data['id']); 134 $this->assertEquals(43, $data['id']);
@@ -175,7 +175,7 @@ class PostLinkTest extends TestCase
175 $response = $this->controller->postLink($request, new Response()); 175 $response = $this->controller->postLink($request, new Response());
176 176
177 $this->assertEquals(201, $response->getStatusCode()); 177 $this->assertEquals(201, $response->getStatusCode());
178 $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]); 178 $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
179 $data = json_decode((string) $response->getBody(), true); 179 $data = json_decode((string) $response->getBody(), true);
180 $this->assertEquals(self::NB_FIELDS_LINK, count($data)); 180 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
181 $this->assertEquals(43, $data['id']); 181 $this->assertEquals(43, $data['id']);
@@ -229,4 +229,52 @@ class PostLinkTest extends TestCase
229 \DateTime::createFromFormat(\DateTime::ATOM, $data['updated']) 229 \DateTime::createFromFormat(\DateTime::ATOM, $data['updated'])
230 ); 230 );
231 } 231 }
232
233 /**
234 * Test link creation with a tag string provided
235 */
236 public function testPostLinkWithTagString(): void
237 {
238 $link = [
239 'tags' => 'one two',
240 ];
241 $env = Environment::mock([
242 'REQUEST_METHOD' => 'POST',
243 'CONTENT_TYPE' => 'application/json'
244 ]);
245
246 $request = Request::createFromEnvironment($env);
247 $request = $request->withParsedBody($link);
248 $response = $this->controller->postLink($request, new Response());
249
250 $this->assertEquals(201, $response->getStatusCode());
251 $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
252 $data = json_decode((string) $response->getBody(), true);
253 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
254 $this->assertEquals(['one', 'two'], $data['tags']);
255 }
256
257 /**
258 * Test link creation with a tag string provided
259 */
260 public function testPostLinkWithTagString2(): void
261 {
262 $link = [
263 'tags' => ['one two'],
264 ];
265 $env = Environment::mock([
266 'REQUEST_METHOD' => 'POST',
267 'CONTENT_TYPE' => 'application/json'
268 ]);
269
270 $request = Request::createFromEnvironment($env);
271 $request = $request->withParsedBody($link);
272 $response = $this->controller->postLink($request, new Response());
273
274 $this->assertEquals(201, $response->getStatusCode());
275 $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
276 $data = json_decode((string) $response->getBody(), true);
277 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
278 $this->assertEquals(['one', 'two'], $data['tags']);
279 }
232} 280}
diff --git a/tests/api/controllers/links/PutLinkTest.php b/tests/api/controllers/links/PutLinkTest.php
index 240ee323..fe24f2eb 100644
--- a/tests/api/controllers/links/PutLinkTest.php
+++ b/tests/api/controllers/links/PutLinkTest.php
@@ -233,4 +233,52 @@ class PutLinkTest extends \Shaarli\TestCase
233 233
234 $this->controller->putLink($request, new Response(), ['id' => -1]); 234 $this->controller->putLink($request, new Response(), ['id' => -1]);
235 } 235 }
236
237 /**
238 * Test link creation with a tag string provided
239 */
240 public function testPutLinkWithTagString(): void
241 {
242 $link = [
243 'tags' => 'one two',
244 ];
245 $id = '41';
246 $env = Environment::mock([
247 'REQUEST_METHOD' => 'PUT',
248 'CONTENT_TYPE' => 'application/json'
249 ]);
250
251 $request = Request::createFromEnvironment($env);
252 $request = $request->withParsedBody($link);
253 $response = $this->controller->putLink($request, new Response(), ['id' => $id]);
254
255 $this->assertEquals(200, $response->getStatusCode());
256 $data = json_decode((string) $response->getBody(), true);
257 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
258 $this->assertEquals(['one', 'two'], $data['tags']);
259 }
260
261 /**
262 * Test link creation with a tag string provided
263 */
264 public function testPutLinkWithTagString2(): void
265 {
266 $link = [
267 'tags' => ['one two'],
268 ];
269 $id = '41';
270 $env = Environment::mock([
271 'REQUEST_METHOD' => 'PUT',
272 'CONTENT_TYPE' => 'application/json'
273 ]);
274
275 $request = Request::createFromEnvironment($env);
276 $request = $request->withParsedBody($link);
277 $response = $this->controller->putLink($request, new Response(), ['id' => $id]);
278
279 $this->assertEquals(200, $response->getStatusCode());
280 $data = json_decode((string) $response->getBody(), true);
281 $this->assertEquals(self::NB_FIELDS_LINK, count($data));
282 $this->assertEquals(['one', 'two'], $data['tags']);
283 }
236} 284}
diff --git a/tests/bookmark/BookmarkFileServiceTest.php b/tests/bookmark/BookmarkFileServiceTest.php
index daafd250..f619aff3 100644
--- a/tests/bookmark/BookmarkFileServiceTest.php
+++ b/tests/bookmark/BookmarkFileServiceTest.php
@@ -686,22 +686,6 @@ class BookmarkFileServiceTest extends TestCase
686 } 686 }
687 687
688 /** 688 /**
689 * List the days for which bookmarks have been posted
690 */
691 public function testDays()
692 {
693 $this->assertSame(
694 ['20100309', '20100310', '20121206', '20121207', '20130614', '20150310'],
695 $this->publicLinkDB->days()
696 );
697
698 $this->assertSame(
699 ['20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'],
700 $this->privateLinkDB->days()
701 );
702 }
703
704 /**
705 * The URL corresponds to an existing entry in the DB 689 * The URL corresponds to an existing entry in the DB
706 */ 690 */
707 public function testGetKnownLinkFromURL() 691 public function testGetKnownLinkFromURL()
@@ -898,6 +882,37 @@ class BookmarkFileServiceTest extends TestCase
898 } 882 }
899 883
900 /** 884 /**
885 * Test filterHash() on a private bookmark while logged out.
886 */
887 public function testFilterHashPrivateWhileLoggedOut()
888 {
889 $this->expectException(BookmarkNotFoundException::class);
890 $this->expectExceptionMessage('The link you are trying to reach does not exist or has been deleted');
891
892 $hash = smallHash('20141125_084734' . 6);
893
894 $this->publicLinkDB->findByHash($hash);
895 }
896
897 /**
898 * Test filterHash() with private key.
899 */
900 public function testFilterHashWithPrivateKey()
901 {
902 $hash = smallHash('20141125_084734' . 6);
903 $privateKey = 'this is usually auto generated';
904
905 $bookmark = $this->privateLinkDB->findByHash($hash);
906 $bookmark->addAdditionalContentEntry('private_key', $privateKey);
907 $this->privateLinkDB->save();
908
909 $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
910 $bookmark = $this->privateLinkDB->findByHash($hash, $privateKey);
911
912 static::assertSame(6, $bookmark->getId());
913 }
914
915 /**
901 * Test linksCountPerTag all tags without filter. 916 * Test linksCountPerTag all tags without filter.
902 * Equal occurrences should be sorted alphabetically. 917 * Equal occurrences should be sorted alphabetically.
903 */ 918 */
@@ -1043,33 +1058,105 @@ class BookmarkFileServiceTest extends TestCase
1043 } 1058 }
1044 1059
1045 /** 1060 /**
1046 * Test filterDay while logged in 1061 * Test find by dates in the middle of the datastore (sorted by dates) with a single bookmark as a result.
1047 */ 1062 */
1048 public function testFilterDayLoggedIn(): void 1063 public function testFilterByDateMidTimePeriodSingleBookmark(): void
1049 { 1064 {
1050 $bookmarks = $this->privateLinkDB->filterDay('20121206'); 1065 $bookmarks = $this->privateLinkDB->findByDate(
1051 $expectedIds = [4, 9, 1, 0]; 1066 DateTime::createFromFormat('Ymd_His', '20121206_150000'),
1067 DateTime::createFromFormat('Ymd_His', '20121206_160000'),
1068 $before,
1069 $after
1070 );
1052 1071
1053 static::assertCount(4, $bookmarks); 1072 static::assertCount(1, $bookmarks);
1054 foreach ($bookmarks as $bookmark) { 1073
1055 $i = ($i ?? -1) + 1; 1074 static::assertSame(9, $bookmarks[0]->getId());
1056 static::assertSame($expectedIds[$i], $bookmark->getId()); 1075 static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
1057 } 1076 static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_172539'), $after);
1058 } 1077 }
1059 1078
1060 /** 1079 /**
1061 * Test filterDay while logged out 1080 * Test find by dates in the middle of the datastore (sorted by dates) with a multiple bookmarks as a result.
1062 */ 1081 */
1063 public function testFilterDayLoggedOut(): void 1082 public function testFilterByDateMidTimePeriodMultipleBookmarks(): void
1064 { 1083 {
1065 $bookmarks = $this->publicLinkDB->filterDay('20121206'); 1084 $bookmarks = $this->privateLinkDB->findByDate(
1066 $expectedIds = [4, 9, 1]; 1085 DateTime::createFromFormat('Ymd_His', '20121206_150000'),
1086 DateTime::createFromFormat('Ymd_His', '20121206_180000'),
1087 $before,
1088 $after
1089 );
1067 1090
1068 static::assertCount(3, $bookmarks); 1091 static::assertCount(2, $bookmarks);
1069 foreach ($bookmarks as $bookmark) { 1092
1070 $i = ($i ?? -1) + 1; 1093 static::assertSame(1, $bookmarks[0]->getId());
1071 static::assertSame($expectedIds[$i], $bookmark->getId()); 1094 static::assertSame(9, $bookmarks[1]->getId());
1072 } 1095 static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
1096 static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_182539'), $after);
1097 }
1098
1099 /**
1100 * Test find by dates at the end of the datastore (sorted by dates).
1101 */
1102 public function testFilterByDateLastTimePeriod(): void
1103 {
1104 $after = new DateTime();
1105 $bookmarks = $this->privateLinkDB->findByDate(
1106 DateTime::createFromFormat('Ymd_His', '20150310_114640'),
1107 DateTime::createFromFormat('Ymd_His', '20450101_010101'),
1108 $before,
1109 $after
1110 );
1111
1112 static::assertCount(1, $bookmarks);
1113
1114 static::assertSame(41, $bookmarks[0]->getId());
1115 static::assertEquals(DateTime::createFromFormat('Ymd_His', '20150310_114633'), $before);
1116 static::assertNull($after);
1117 }
1118
1119 /**
1120 * Test find by dates at the beginning of the datastore (sorted by dates).
1121 */
1122 public function testFilterByDateFirstTimePeriod(): void
1123 {
1124 $before = new DateTime();
1125 $bookmarks = $this->privateLinkDB->findByDate(
1126 DateTime::createFromFormat('Ymd_His', '20000101_101010'),
1127 DateTime::createFromFormat('Ymd_His', '20100309_110000'),
1128 $before,
1129 $after
1130 );
1131
1132 static::assertCount(1, $bookmarks);
1133
1134 static::assertSame(11, $bookmarks[0]->getId());
1135 static::assertNull($before);
1136 static::assertEquals(DateTime::createFromFormat('Ymd_His', '20100310_101010'), $after);
1137 }
1138
1139 /**
1140 * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
1141 */
1142 public function testGetLatestWithSticky(): void
1143 {
1144 $bookmark = $this->publicLinkDB->getLatest();
1145
1146 static::assertSame(41, $bookmark->getId());
1147 }
1148
1149 /**
1150 * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
1151 */
1152 public function testGetLatestEmptyDatastore(): void
1153 {
1154 unlink($this->conf->get('resource.datastore'));
1155 $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
1156
1157 $bookmark = $this->publicLinkDB->getLatest();
1158
1159 static::assertNull($bookmark);
1073 } 1160 }
1074 1161
1075 /** 1162 /**
diff --git a/tests/bookmark/BookmarkFilterTest.php b/tests/bookmark/BookmarkFilterTest.php
index 574d8e3f..835674f2 100644
--- a/tests/bookmark/BookmarkFilterTest.php
+++ b/tests/bookmark/BookmarkFilterTest.php
@@ -44,7 +44,7 @@ class BookmarkFilterTest extends TestCase
44 self::$refDB->write(self::$testDatastore); 44 self::$refDB->write(self::$testDatastore);
45 $history = new History('sandbox/history.php'); 45 $history = new History('sandbox/history.php');
46 self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true); 46 self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true);
47 self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks()); 47 self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks(), $conf);
48 } 48 }
49 49
50 /** 50 /**
diff --git a/tests/bookmark/BookmarkTest.php b/tests/bookmark/BookmarkTest.php
index 4c7ae4c0..cb91b26b 100644
--- a/tests/bookmark/BookmarkTest.php
+++ b/tests/bookmark/BookmarkTest.php
@@ -79,6 +79,23 @@ class BookmarkTest extends TestCase
79 } 79 }
80 80
81 /** 81 /**
82 * Test fromArray() with a link with a custom tags separator
83 */
84 public function testFromArrayCustomTagsSeparator()
85 {
86 $data = [
87 'id' => 1,
88 'tags' => ['tag1', 'tag2', 'chair'],
89 ];
90
91 $bookmark = (new Bookmark())->fromArray($data, '@');
92 $this->assertEquals($data['id'], $bookmark->getId());
93 $this->assertEquals($data['tags'], $bookmark->getTags());
94 $this->assertEquals('tag1@tag2@chair', $bookmark->getTagsString('@'));
95 }
96
97
98 /**
82 * Test validate() with a valid minimal bookmark 99 * Test validate() with a valid minimal bookmark
83 */ 100 */
84 public function testValidateValidFullBookmark() 101 public function testValidateValidFullBookmark()
@@ -252,7 +269,7 @@ class BookmarkTest extends TestCase
252 { 269 {
253 $bookmark = new Bookmark(); 270 $bookmark = new Bookmark();
254 271
255 $str = 'tag1 tag2 tag3.tag3-2, tag4 , -tag5 '; 272 $str = 'tag1 tag2 tag3.tag3-2 tag4 -tag5 ';
256 $bookmark->setTagsString($str); 273 $bookmark->setTagsString($str);
257 $this->assertEquals( 274 $this->assertEquals(
258 [ 275 [
@@ -276,9 +293,9 @@ class BookmarkTest extends TestCase
276 $array = [ 293 $array = [
277 'tag1 ', 294 'tag1 ',
278 ' tag2', 295 ' tag2',
279 'tag3.tag3-2,', 296 'tag3.tag3-2',
280 ', tag4', 297 ' tag4',
281 ', ', 298 ' ',
282 '-tag5 ', 299 '-tag5 ',
283 ]; 300 ];
284 $bookmark->setTags($array); 301 $bookmark->setTags($array);
@@ -347,4 +364,48 @@ class BookmarkTest extends TestCase
347 $bookmark->deleteTag('nope'); 364 $bookmark->deleteTag('nope');
348 $this->assertEquals(['tag1', 'tag2', 'chair'], $bookmark->getTags()); 365 $this->assertEquals(['tag1', 'tag2', 'chair'], $bookmark->getTags());
349 } 366 }
367
368 /**
369 * Test shouldUpdateThumbnail() with bookmarks needing an update.
370 */
371 public function testShouldUpdateThumbnail(): void
372 {
373 $bookmark = (new Bookmark())->setUrl('http://domain.tld/with-image');
374
375 static::assertTrue($bookmark->shouldUpdateThumbnail());
376
377 $bookmark = (new Bookmark())
378 ->setUrl('http://domain.tld/with-image')
379 ->setThumbnail('unknown file')
380 ;
381
382 static::assertTrue($bookmark->shouldUpdateThumbnail());
383 }
384
385 /**
386 * Test shouldUpdateThumbnail() with bookmarks that should not update.
387 */
388 public function testShouldNotUpdateThumbnail(): void
389 {
390 $bookmark = (new Bookmark());
391
392 static::assertFalse($bookmark->shouldUpdateThumbnail());
393
394 $bookmark = (new Bookmark())
395 ->setUrl('ftp://domain.tld/other-protocol', ['ftp'])
396 ;
397
398 static::assertFalse($bookmark->shouldUpdateThumbnail());
399
400 $bookmark = (new Bookmark())
401 ->setUrl('http://domain.tld/with-image')
402 ->setThumbnail(__FILE__)
403 ;
404
405 static::assertFalse($bookmark->shouldUpdateThumbnail());
406
407 $bookmark = (new Bookmark())->setUrl('/shaare/abcdef');
408
409 static::assertFalse($bookmark->shouldUpdateThumbnail());
410 }
350} 411}
diff --git a/tests/bookmark/LinkUtilsTest.php b/tests/bookmark/LinkUtilsTest.php
index 29941c8c..46a7f1fe 100644
--- a/tests/bookmark/LinkUtilsTest.php
+++ b/tests/bookmark/LinkUtilsTest.php
@@ -169,6 +169,36 @@ class LinkUtilsTest extends TestCase
169 } 169 }
170 170
171 /** 171 /**
172 * Test html_extract_tag() with double quoted content containing single quote, and the opposite.
173 */
174 public function testHtmlExtractExistentNameTagWithMixedQuotes(): void
175 {
176 $description = 'Bob and Alice share M&M\'s.';
177
178 $html = '<meta property="og:description" content="' . $description . '">';
179 $this->assertEquals($description, html_extract_tag('description', $html));
180
181 $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
182 'tag2="content2" content="' . $description . '" tag3="content3">';
183 $this->assertEquals($description, html_extract_tag('description', $html));
184
185 $html = '<meta property="og:description" name="description" content="' . $description . '">';
186 $this->assertEquals($description, html_extract_tag('description', $html));
187
188 $description = 'Bob and Alice share "cookies".';
189
190 $html = '<meta property="og:description" content=\'' . $description . '\'>';
191 $this->assertEquals($description, html_extract_tag('description', $html));
192
193 $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
194 'tag2="content2" content=\'' . $description . '\' tag3="content3">';
195 $this->assertEquals($description, html_extract_tag('description', $html));
196
197 $html = '<meta property="og:description" name="description" content=\'' . $description . '\'>';
198 $this->assertEquals($description, html_extract_tag('description', $html));
199 }
200
201 /**
172 * Test html_extract_tag() when the tag <meta name= is not found. 202 * Test html_extract_tag() when the tag <meta name= is not found.
173 */ 203 */
174 public function testHtmlExtractNonExistentNameTag() 204 public function testHtmlExtractNonExistentNameTag()
@@ -215,61 +245,104 @@ class LinkUtilsTest extends TestCase
215 $this->assertFalse(html_extract_tag('description', $html)); 245 $this->assertFalse(html_extract_tag('description', $html));
216 } 246 }
217 247
248 public function testHtmlExtractDescriptionFromGoogleRealCase(): void
249 {
250 $html = 'id="gsr"><meta content="Fêtes de fin d\'année" property="twitter:title"><meta '.
251 'content="Bonnes fêtes de fin d\'année ! #GoogleDoodle" property="twitter:description">'.
252 '<meta content="Bonnes fêtes de fin d\'année ! #GoogleDoodle" property="og:description">'.
253 '<meta content="summary_large_image" property="twitter:card"><meta co'
254 ;
255 $this->assertSame('Bonnes fêtes de fin d\'année ! #GoogleDoodle', html_extract_tag('description', $html));
256 }
257
258 /**
259 * Test the header callback with valid value
260 */
261 public function testCurlHeaderCallbackOk(): void
262 {
263 $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_ok');
264 $data = [
265 'HTTP/1.1 200 OK',
266 'Server: GitHub.com',
267 'Date: Sat, 28 Oct 2017 12:01:33 GMT',
268 'Content-Type: text/html; charset=utf-8',
269 'Status: 200 OK',
270 ];
271
272 foreach ($data as $chunk) {
273 static::assertIsInt($callback(null, $chunk));
274 }
275
276 static::assertSame('utf-8', $charset);
277 }
278
218 /** 279 /**
219 * Test the download callback with valid value 280 * Test the download callback with valid value
220 */ 281 */
221 public function testCurlDownloadCallbackOk() 282 public function testCurlDownloadCallbackOk(): void
222 { 283 {
284 $charset = 'utf-8';
223 $callback = get_curl_download_callback( 285 $callback = get_curl_download_callback(
224 $charset, 286 $charset,
225 $title, 287 $title,
226 $desc, 288 $desc,
227 $keywords, 289 $keywords,
228 false, 290 false,
229 'ut_curl_getinfo_ok' 291 ' '
230 ); 292 );
293
231 $data = [ 294 $data = [
232 'HTTP/1.1 200 OK', 295 'th=device-width">'
233 'Server: GitHub.com',
234 'Date: Sat, 28 Oct 2017 12:01:33 GMT',
235 'Content-Type: text/html; charset=utf-8',
236 'Status: 200 OK',
237 'end' => 'th=device-width">'
238 . '<title>Refactoring · GitHub</title>' 296 . '<title>Refactoring · GitHub</title>'
239 . '<link rel="search" type="application/opensea', 297 . '<link rel="search" type="application/opensea',
240 '<title>ignored</title>' 298 '<title>ignored</title>'
241 . '<meta name="description" content="desc" />' 299 . '<meta name="description" content="desc" />'
242 . '<meta name="keywords" content="key1,key2" />', 300 . '<meta name="keywords" content="key1,key2" />',
243 ]; 301 ];
244 foreach ($data as $key => $line) { 302
245 $ignore = null; 303 foreach ($data as $chunk) {
246 $expected = $key !== 'end' ? strlen($line) : false; 304 static::assertSame(strlen($chunk), $callback(null, $chunk));
247 $this->assertEquals($expected, $callback($ignore, $line));
248 if ($expected === false) {
249 break;
250 }
251 } 305 }
252 $this->assertEquals('utf-8', $charset); 306
253 $this->assertEquals('Refactoring · GitHub', $title); 307 static::assertSame('utf-8', $charset);
254 $this->assertEmpty($desc); 308 static::assertSame('Refactoring · GitHub', $title);
255 $this->assertEmpty($keywords); 309 static::assertEmpty($desc);
310 static::assertEmpty($keywords);
311 }
312
313 /**
314 * Test the header callback with valid value
315 */
316 public function testCurlHeaderCallbackNoCharset(): void
317 {
318 $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_no_charset');
319 $data = [
320 'HTTP/1.1 200 OK',
321 ];
322
323 foreach ($data as $chunk) {
324 static::assertSame(strlen($chunk), $callback(null, $chunk));
325 }
326
327 static::assertFalse($charset);
256 } 328 }
257 329
258 /** 330 /**
259 * Test the download callback with valid values and no charset 331 * Test the download callback with valid values and no charset
260 */ 332 */
261 public function testCurlDownloadCallbackOkNoCharset() 333 public function testCurlDownloadCallbackOkNoCharset(): void
262 { 334 {
335 $charset = null;
263 $callback = get_curl_download_callback( 336 $callback = get_curl_download_callback(
264 $charset, 337 $charset,
265 $title, 338 $title,
266 $desc, 339 $desc,
267 $keywords, 340 $keywords,
268 false, 341 false,
269 'ut_curl_getinfo_no_charset' 342 ' '
270 ); 343 );
344
271 $data = [ 345 $data = [
272 'HTTP/1.1 200 OK',
273 'end' => 'th=device-width">' 346 'end' => 'th=device-width">'
274 . '<title>Refactoring · GitHub</title>' 347 . '<title>Refactoring · GitHub</title>'
275 . '<link rel="search" type="application/opensea', 348 . '<link rel="search" type="application/opensea',
@@ -277,10 +350,11 @@ class LinkUtilsTest extends TestCase
277 . '<meta name="description" content="desc" />' 350 . '<meta name="description" content="desc" />'
278 . '<meta name="keywords" content="key1,key2" />', 351 . '<meta name="keywords" content="key1,key2" />',
279 ]; 352 ];
280 foreach ($data as $key => $line) { 353
281 $ignore = null; 354 foreach ($data as $chunk) {
282 $this->assertEquals(strlen($line), $callback($ignore, $line)); 355 static::assertSame(strlen($chunk), $callback(null, $chunk));
283 } 356 }
357
284 $this->assertEmpty($charset); 358 $this->assertEmpty($charset);
285 $this->assertEquals('Refactoring · GitHub', $title); 359 $this->assertEquals('Refactoring · GitHub', $title);
286 $this->assertEmpty($desc); 360 $this->assertEmpty($desc);
@@ -290,18 +364,19 @@ class LinkUtilsTest extends TestCase
290 /** 364 /**
291 * Test the download callback with valid values and no charset 365 * Test the download callback with valid values and no charset
292 */ 366 */
293 public function testCurlDownloadCallbackOkHtmlCharset() 367 public function testCurlDownloadCallbackOkHtmlCharset(): void
294 { 368 {
369 $charset = null;
295 $callback = get_curl_download_callback( 370 $callback = get_curl_download_callback(
296 $charset, 371 $charset,
297 $title, 372 $title,
298 $desc, 373 $desc,
299 $keywords, 374 $keywords,
300 false, 375 false,
301 'ut_curl_getinfo_no_charset' 376 ' '
302 ); 377 );
378
303 $data = [ 379 $data = [
304 'HTTP/1.1 200 OK',
305 '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />', 380 '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />',
306 'end' => 'th=device-width">' 381 'end' => 'th=device-width">'
307 . '<title>Refactoring · GitHub</title>' 382 . '<title>Refactoring · GitHub</title>'
@@ -310,14 +385,10 @@ class LinkUtilsTest extends TestCase
310 . '<meta name="description" content="desc" />' 385 . '<meta name="description" content="desc" />'
311 . '<meta name="keywords" content="key1,key2" />', 386 . '<meta name="keywords" content="key1,key2" />',
312 ]; 387 ];
313 foreach ($data as $key => $line) { 388 foreach ($data as $chunk) {
314 $ignore = null; 389 static::assertSame(strlen($chunk), $callback(null, $chunk));
315 $expected = $key !== 'end' ? strlen($line) : false;
316 $this->assertEquals($expected, $callback($ignore, $line));
317 if ($expected === false) {
318 break;
319 }
320 } 390 }
391
321 $this->assertEquals('utf-8', $charset); 392 $this->assertEquals('utf-8', $charset);
322 $this->assertEquals('Refactoring · GitHub', $title); 393 $this->assertEquals('Refactoring · GitHub', $title);
323 $this->assertEmpty($desc); 394 $this->assertEmpty($desc);
@@ -327,25 +398,27 @@ class LinkUtilsTest extends TestCase
327 /** 398 /**
328 * Test the download callback with valid values and no title 399 * Test the download callback with valid values and no title
329 */ 400 */
330 public function testCurlDownloadCallbackOkNoTitle() 401 public function testCurlDownloadCallbackOkNoTitle(): void
331 { 402 {
403 $charset = 'utf-8';
332 $callback = get_curl_download_callback( 404 $callback = get_curl_download_callback(
333 $charset, 405 $charset,
334 $title, 406 $title,
335 $desc, 407 $desc,
336 $keywords, 408 $keywords,
337 false, 409 false,
338 'ut_curl_getinfo_ok' 410 ' '
339 ); 411 );
412
340 $data = [ 413 $data = [
341 'HTTP/1.1 200 OK',
342 'end' => 'th=device-width">Refactoring · GitHub<link rel="search" type="application/opensea', 414 'end' => 'th=device-width">Refactoring · GitHub<link rel="search" type="application/opensea',
343 'ignored', 415 'ignored',
344 ]; 416 ];
345 foreach ($data as $key => $line) { 417
346 $ignore = null; 418 foreach ($data as $chunk) {
347 $this->assertEquals(strlen($line), $callback($ignore, $line)); 419 static::assertSame(strlen($chunk), $callback(null, $chunk));
348 } 420 }
421
349 $this->assertEquals('utf-8', $charset); 422 $this->assertEquals('utf-8', $charset);
350 $this->assertEmpty($title); 423 $this->assertEmpty($title);
351 $this->assertEmpty($desc); 424 $this->assertEmpty($desc);
@@ -353,81 +426,56 @@ class LinkUtilsTest extends TestCase
353 } 426 }
354 427
355 /** 428 /**
356 * Test the download callback with an invalid content type. 429 * Test the header callback with an invalid content type.
357 */ 430 */
358 public function testCurlDownloadCallbackInvalidContentType() 431 public function testCurlHeaderCallbackInvalidContentType(): void
359 { 432 {
360 $callback = get_curl_download_callback( 433 $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_ct_ko');
361 $charset, 434 $data = [
362 $title, 435 'HTTP/1.1 200 OK',
363 $desc, 436 ];
364 $keywords, 437
365 false, 438 static::assertFalse($callback(null, $data[0]));
366 'ut_curl_getinfo_ct_ko' 439 static::assertNull($charset);
367 );
368 $ignore = null;
369 $this->assertFalse($callback($ignore, ''));
370 $this->assertEmpty($charset);
371 $this->assertEmpty($title);
372 } 440 }
373 441
374 /** 442 /**
375 * Test the download callback with an invalid response code. 443 * Test the header callback with an invalid response code.
376 */ 444 */
377 public function testCurlDownloadCallbackInvalidResponseCode() 445 public function testCurlHeaderCallbackInvalidResponseCode(): void
378 { 446 {
379 $callback = $callback = get_curl_download_callback( 447 $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_rc_ko');
380 $charset, 448
381 $title, 449 static::assertFalse($callback(null, ''));
382 $desc, 450 static::assertNull($charset);
383 $keywords,
384 false,
385 'ut_curl_getinfo_rc_ko'
386 );
387 $ignore = null;
388 $this->assertFalse($callback($ignore, ''));
389 $this->assertEmpty($charset);
390 $this->assertEmpty($title);
391 } 451 }
392 452
393 /** 453 /**
394 * Test the download callback with an invalid content type and response code. 454 * Test the header callback with an invalid content type and response code.
395 */ 455 */
396 public function testCurlDownloadCallbackInvalidContentTypeAndResponseCode() 456 public function testCurlHeaderCallbackInvalidContentTypeAndResponseCode(): void
397 { 457 {
398 $callback = $callback = get_curl_download_callback( 458 $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_rs_ct_ko');
399 $charset, 459
400 $title, 460 static::assertFalse($callback(null, ''));
401 $desc, 461 static::assertNull($charset);
402 $keywords,
403 false,
404 'ut_curl_getinfo_rs_ct_ko'
405 );
406 $ignore = null;
407 $this->assertFalse($callback($ignore, ''));
408 $this->assertEmpty($charset);
409 $this->assertEmpty($title);
410 } 462 }
411 463
412 /** 464 /**
413 * Test the download callback with valid value, and retrieve_description option enabled. 465 * Test the download callback with valid value, and retrieve_description option enabled.
414 */ 466 */
415 public function testCurlDownloadCallbackOkWithDesc() 467 public function testCurlDownloadCallbackOkWithDesc(): void
416 { 468 {
469 $charset = 'utf-8';
417 $callback = get_curl_download_callback( 470 $callback = get_curl_download_callback(
418 $charset, 471 $charset,
419 $title, 472 $title,
420 $desc, 473 $desc,
421 $keywords, 474 $keywords,
422 true, 475 true,
423 'ut_curl_getinfo_ok' 476 ' '
424 ); 477 );
425 $data = [ 478 $data = [
426 'HTTP/1.1 200 OK',
427 'Server: GitHub.com',
428 'Date: Sat, 28 Oct 2017 12:01:33 GMT',
429 'Content-Type: text/html; charset=utf-8',
430 'Status: 200 OK',
431 'th=device-width">' 479 'th=device-width">'
432 . '<title>Refactoring · GitHub</title>' 480 . '<title>Refactoring · GitHub</title>'
433 . '<link rel="search" type="application/opensea', 481 . '<link rel="search" type="application/opensea',
@@ -435,14 +483,11 @@ class LinkUtilsTest extends TestCase
435 . '<meta name="description" content="link desc" />' 483 . '<meta name="description" content="link desc" />'
436 . '<meta name="keywords" content="key1,key2" />', 484 . '<meta name="keywords" content="key1,key2" />',
437 ]; 485 ];
438 foreach ($data as $key => $line) { 486
439 $ignore = null; 487 foreach ($data as $chunk) {
440 $expected = $key !== 'end' ? strlen($line) : false; 488 static::assertSame(strlen($chunk), $callback(null, $chunk));
441 $this->assertEquals($expected, $callback($ignore, $line));
442 if ($expected === false) {
443 break;
444 }
445 } 489 }
490
446 $this->assertEquals('utf-8', $charset); 491 $this->assertEquals('utf-8', $charset);
447 $this->assertEquals('Refactoring · GitHub', $title); 492 $this->assertEquals('Refactoring · GitHub', $title);
448 $this->assertEquals('link desc', $desc); 493 $this->assertEquals('link desc', $desc);
@@ -453,8 +498,9 @@ class LinkUtilsTest extends TestCase
453 * Test the download callback with valid value, and retrieve_description option enabled, 498 * Test the download callback with valid value, and retrieve_description option enabled,
454 * but no desc or keyword defined in the page. 499 * but no desc or keyword defined in the page.
455 */ 500 */
456 public function testCurlDownloadCallbackOkWithDescNotFound() 501 public function testCurlDownloadCallbackOkWithDescNotFound(): void
457 { 502 {
503 $charset = 'utf-8';
458 $callback = get_curl_download_callback( 504 $callback = get_curl_download_callback(
459 $charset, 505 $charset,
460 $title, 506 $title,
@@ -464,24 +510,16 @@ class LinkUtilsTest extends TestCase
464 'ut_curl_getinfo_ok' 510 'ut_curl_getinfo_ok'
465 ); 511 );
466 $data = [ 512 $data = [
467 'HTTP/1.1 200 OK',
468 'Server: GitHub.com',
469 'Date: Sat, 28 Oct 2017 12:01:33 GMT',
470 'Content-Type: text/html; charset=utf-8',
471 'Status: 200 OK',
472 'th=device-width">' 513 'th=device-width">'
473 . '<title>Refactoring · GitHub</title>' 514 . '<title>Refactoring · GitHub</title>'
474 . '<link rel="search" type="application/opensea', 515 . '<link rel="search" type="application/opensea',
475 'end' => '<title>ignored</title>', 516 'end' => '<title>ignored</title>',
476 ]; 517 ];
477 foreach ($data as $key => $line) { 518
478 $ignore = null; 519 foreach ($data as $chunk) {
479 $expected = $key !== 'end' ? strlen($line) : false; 520 static::assertSame(strlen($chunk), $callback(null, $chunk));
480 $this->assertEquals($expected, $callback($ignore, $line));
481 if ($expected === false) {
482 break;
483 }
484 } 521 }
522
485 $this->assertEquals('utf-8', $charset); 523 $this->assertEquals('utf-8', $charset);
486 $this->assertEquals('Refactoring · GitHub', $title); 524 $this->assertEquals('Refactoring · GitHub', $title);
487 $this->assertEmpty($desc); 525 $this->assertEmpty($desc);
@@ -582,6 +620,115 @@ class LinkUtilsTest extends TestCase
582 } 620 }
583 621
584 /** 622 /**
623 * Test tags_str2array with whitespace separator.
624 */
625 public function testTagsStr2ArrayWithSpaceSeparator(): void
626 {
627 $separator = ' ';
628
629 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1 tag2 tag3', $separator));
630 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1 tag2 tag3', $separator));
631 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array(' tag1 tag2 tag3 ', $separator));
632 static::assertSame(['tag1@', 'tag2,', '.tag3'], tags_str2array(' tag1@ tag2, .tag3 ', $separator));
633 static::assertSame([], tags_str2array('', $separator));
634 static::assertSame([], tags_str2array(' ', $separator));
635 static::assertSame([], tags_str2array(null, $separator));
636 }
637
638 /**
639 * Test tags_str2array with @ separator.
640 */
641 public function testTagsStr2ArrayWithCharSeparator(): void
642 {
643 $separator = '@';
644
645 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@tag2@tag3', $separator));
646 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@@@@tag2@@@@tag3', $separator));
647 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('@@@tag1@@@tag2@@@@tag3@@', $separator));
648 static::assertSame(
649 ['tag1#', 'tag2, and other', '.tag3'],
650 tags_str2array('@@@ tag1# @@@ tag2, and other @@@@.tag3@@', $separator)
651 );
652 static::assertSame([], tags_str2array('', $separator));
653 static::assertSame([], tags_str2array(' ', $separator));
654 static::assertSame([], tags_str2array(null, $separator));
655 }
656
657 /**
658 * Test tags_array2str with ' ' separator.
659 */
660 public function testTagsArray2StrWithSpaceSeparator(): void
661 {
662 $separator = ' ';
663
664 static::assertSame('tag1 tag2 tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
665 static::assertSame('tag1, tag2@ tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
666 static::assertSame('tag1 tag2 tag3', tags_array2str([' tag1 ', 'tag2', 'tag3 '], $separator));
667 static::assertSame('tag1 tag2 tag3', tags_array2str([' tag1 ', ' ', 'tag2', ' ', 'tag3 '], $separator));
668 static::assertSame('tag1', tags_array2str([' tag1 '], $separator));
669 static::assertSame('', tags_array2str([' '], $separator));
670 static::assertSame('', tags_array2str([], $separator));
671 static::assertSame('', tags_array2str(null, $separator));
672 }
673
674 /**
675 * Test tags_array2str with @ separator.
676 */
677 public function testTagsArray2StrWithCharSeparator(): void
678 {
679 $separator = '@';
680
681 static::assertSame('tag1@tag2@tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
682 static::assertSame('tag1,@tag2@tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
683 static::assertSame(
684 'tag1@tag2, and other@tag3',
685 tags_array2str(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
686 );
687 static::assertSame('tag1@tag2@tag3', tags_array2str(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
688 static::assertSame('tag1', tags_array2str(['@@@@tag1@@@@'], $separator));
689 static::assertSame('', tags_array2str(['@@@'], $separator));
690 static::assertSame('', tags_array2str([], $separator));
691 static::assertSame('', tags_array2str(null, $separator));
692 }
693
694 /**
695 * Test tags_array2str with @ separator.
696 */
697 public function testTagsFilterWithSpaceSeparator(): void
698 {
699 $separator = ' ';
700
701 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
702 static::assertSame(['tag1,', 'tag2@', 'tag3'], tags_filter(['tag1,', 'tag2@', 'tag3'], $separator));
703 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter([' tag1 ', 'tag2', 'tag3 '], $separator));
704 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter([' tag1 ', ' ', 'tag2', ' ', 'tag3 '], $separator));
705 static::assertSame(['tag1'], tags_filter([' tag1 '], $separator));
706 static::assertSame([], tags_filter([' '], $separator));
707 static::assertSame([], tags_filter([], $separator));
708 static::assertSame([], tags_filter(null, $separator));
709 }
710
711 /**
712 * Test tags_array2str with @ separator.
713 */
714 public function testTagsArrayFilterWithSpaceSeparator(): void
715 {
716 $separator = '@';
717
718 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
719 static::assertSame(['tag1,', 'tag2#', 'tag3'], tags_filter(['tag1,', 'tag2#', 'tag3'], $separator));
720 static::assertSame(
721 ['tag1', 'tag2, and other', 'tag3'],
722 tags_filter(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
723 );
724 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
725 static::assertSame(['tag1'], tags_filter(['@@@@tag1@@@@'], $separator));
726 static::assertSame([], tags_filter(['@@@'], $separator));
727 static::assertSame([], tags_filter([], $separator));
728 static::assertSame([], tags_filter(null, $separator));
729 }
730
731 /**
585 * Util function to build an hashtag link. 732 * Util function to build an hashtag link.
586 * 733 *
587 * @param string $hashtag Hashtag name. 734 * @param string $hashtag Hashtag name.
diff --git a/tests/container/ContainerBuilderTest.php b/tests/container/ContainerBuilderTest.php
index 5d52daef..04d4ef01 100644
--- a/tests/container/ContainerBuilderTest.php
+++ b/tests/container/ContainerBuilderTest.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Container; 5namespace Shaarli\Container;
6 6
7use Psr\Log\LoggerInterface;
7use Shaarli\Bookmark\BookmarkServiceInterface; 8use Shaarli\Bookmark\BookmarkServiceInterface;
8use Shaarli\Config\ConfigManager; 9use Shaarli\Config\ConfigManager;
9use Shaarli\Feed\FeedBuilder; 10use Shaarli\Feed\FeedBuilder;
@@ -12,6 +13,7 @@ use Shaarli\Front\Controller\Visitor\ErrorController;
12use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; 13use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
13use Shaarli\History; 14use Shaarli\History;
14use Shaarli\Http\HttpAccess; 15use Shaarli\Http\HttpAccess;
16use Shaarli\Http\MetadataRetriever;
15use Shaarli\Netscape\NetscapeBookmarkUtils; 17use Shaarli\Netscape\NetscapeBookmarkUtils;
16use Shaarli\Plugin\PluginManager; 18use Shaarli\Plugin\PluginManager;
17use Shaarli\Render\PageBuilder; 19use Shaarli\Render\PageBuilder;
@@ -41,11 +43,15 @@ class ContainerBuilderTest extends TestCase
41 /** @var CookieManager */ 43 /** @var CookieManager */
42 protected $cookieManager; 44 protected $cookieManager;
43 45
46 /** @var PluginManager */
47 protected $pluginManager;
48
44 public function setUp(): void 49 public function setUp(): void
45 { 50 {
46 $this->conf = new ConfigManager('tests/utils/config/configJson'); 51 $this->conf = new ConfigManager('tests/utils/config/configJson');
47 $this->sessionManager = $this->createMock(SessionManager::class); 52 $this->sessionManager = $this->createMock(SessionManager::class);
48 $this->cookieManager = $this->createMock(CookieManager::class); 53 $this->cookieManager = $this->createMock(CookieManager::class);
54 $this->pluginManager = $this->createMock(PluginManager::class);
49 55
50 $this->loginManager = $this->createMock(LoginManager::class); 56 $this->loginManager = $this->createMock(LoginManager::class);
51 $this->loginManager->method('isLoggedIn')->willReturn(true); 57 $this->loginManager->method('isLoggedIn')->willReturn(true);
@@ -54,7 +60,9 @@ class ContainerBuilderTest extends TestCase
54 $this->conf, 60 $this->conf,
55 $this->sessionManager, 61 $this->sessionManager,
56 $this->cookieManager, 62 $this->cookieManager,
57 $this->loginManager 63 $this->loginManager,
64 $this->pluginManager,
65 $this->createMock(LoggerInterface::class)
58 ); 66 );
59 } 67 }
60 68
@@ -72,6 +80,8 @@ class ContainerBuilderTest extends TestCase
72 static::assertInstanceOf(History::class, $container->history); 80 static::assertInstanceOf(History::class, $container->history);
73 static::assertInstanceOf(HttpAccess::class, $container->httpAccess); 81 static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
74 static::assertInstanceOf(LoginManager::class, $container->loginManager); 82 static::assertInstanceOf(LoginManager::class, $container->loginManager);
83 static::assertInstanceOf(LoggerInterface::class, $container->logger);
84 static::assertInstanceOf(MetadataRetriever::class, $container->metadataRetriever);
75 static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils); 85 static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
76 static::assertInstanceOf(PageBuilder::class, $container->pageBuilder); 86 static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
77 static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager); 87 static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
diff --git a/tests/feed/CachedPageTest.php b/tests/feed/CachedPageTest.php
index 904db9dc..1decfaf3 100644
--- a/tests/feed/CachedPageTest.php
+++ b/tests/feed/CachedPageTest.php
@@ -40,10 +40,10 @@ class CachedPageTest extends \Shaarli\TestCase
40 */ 40 */
41 public function testConstruct() 41 public function testConstruct()
42 { 42 {
43 new CachedPage(self::$testCacheDir, '', true); 43 new CachedPage(self::$testCacheDir, '', true, null);
44 new CachedPage(self::$testCacheDir, '', false); 44 new CachedPage(self::$testCacheDir, '', false, null);
45 new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/rss', true); 45 new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/rss', true, null);
46 new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/atom', false); 46 new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/atom', false, null);
47 $this->addToAssertionCount(1); 47 $this->addToAssertionCount(1);
48 } 48 }
49 49
@@ -52,7 +52,7 @@ class CachedPageTest extends \Shaarli\TestCase
52 */ 52 */
53 public function testCache() 53 public function testCache()
54 { 54 {
55 $page = new CachedPage(self::$testCacheDir, self::$url, true); 55 $page = new CachedPage(self::$testCacheDir, self::$url, true, null);
56 56
57 $this->assertFileNotExists(self::$filename); 57 $this->assertFileNotExists(self::$filename);
58 $page->cache('<p>Some content</p>'); 58 $page->cache('<p>Some content</p>');
@@ -68,7 +68,7 @@ class CachedPageTest extends \Shaarli\TestCase
68 */ 68 */
69 public function testShouldNotCache() 69 public function testShouldNotCache()
70 { 70 {
71 $page = new CachedPage(self::$testCacheDir, self::$url, false); 71 $page = new CachedPage(self::$testCacheDir, self::$url, false, null);
72 72
73 $this->assertFileNotExists(self::$filename); 73 $this->assertFileNotExists(self::$filename);
74 $page->cache('<p>Some content</p>'); 74 $page->cache('<p>Some content</p>');
@@ -80,7 +80,7 @@ class CachedPageTest extends \Shaarli\TestCase
80 */ 80 */
81 public function testCachedVersion() 81 public function testCachedVersion()
82 { 82 {
83 $page = new CachedPage(self::$testCacheDir, self::$url, true); 83 $page = new CachedPage(self::$testCacheDir, self::$url, true, null);
84 84
85 $this->assertFileNotExists(self::$filename); 85 $this->assertFileNotExists(self::$filename);
86 $page->cache('<p>Some content</p>'); 86 $page->cache('<p>Some content</p>');
@@ -96,7 +96,7 @@ class CachedPageTest extends \Shaarli\TestCase
96 */ 96 */
97 public function testCachedVersionNoFile() 97 public function testCachedVersionNoFile()
98 { 98 {
99 $page = new CachedPage(self::$testCacheDir, self::$url, true); 99 $page = new CachedPage(self::$testCacheDir, self::$url, true, null);
100 100
101 $this->assertFileNotExists(self::$filename); 101 $this->assertFileNotExists(self::$filename);
102 $this->assertEquals( 102 $this->assertEquals(
@@ -110,7 +110,7 @@ class CachedPageTest extends \Shaarli\TestCase
110 */ 110 */
111 public function testNoCachedVersion() 111 public function testNoCachedVersion()
112 { 112 {
113 $page = new CachedPage(self::$testCacheDir, self::$url, false); 113 $page = new CachedPage(self::$testCacheDir, self::$url, false, null);
114 114
115 $this->assertFileNotExists(self::$filename); 115 $this->assertFileNotExists(self::$filename);
116 $this->assertEquals( 116 $this->assertEquals(
@@ -118,4 +118,43 @@ class CachedPageTest extends \Shaarli\TestCase
118 $page->cachedVersion() 118 $page->cachedVersion()
119 ); 119 );
120 } 120 }
121
122 /**
123 * Return a page's cached content within date period
124 */
125 public function testCachedVersionInDatePeriod()
126 {
127 $period = new \DatePeriod(
128 new \DateTime('yesterday'),
129 new \DateInterval('P1D'),
130 new \DateTime('tomorrow')
131 );
132 $page = new CachedPage(self::$testCacheDir, self::$url, true, $period);
133
134 $this->assertFileNotExists(self::$filename);
135 $page->cache('<p>Some content</p>');
136 $this->assertFileExists(self::$filename);
137 $this->assertEquals(
138 '<p>Some content</p>',
139 $page->cachedVersion()
140 );
141 }
142
143 /**
144 * Return a page's cached content outside of date period
145 */
146 public function testCachedVersionNotInDatePeriod()
147 {
148 $period = new \DatePeriod(
149 new \DateTime('yesterday noon'),
150 new \DateInterval('P1D'),
151 new \DateTime('yesterday midnight')
152 );
153 $page = new CachedPage(self::$testCacheDir, self::$url, true, $period);
154
155 $this->assertFileNotExists(self::$filename);
156 $page->cache('<p>Some content</p>');
157 $this->assertFileExists(self::$filename);
158 $this->assertNull($page->cachedVersion());
159 }
121} 160}
diff --git a/tests/formatter/BookmarkDefaultFormatterTest.php b/tests/formatter/BookmarkDefaultFormatterTest.php
index 3fc6f8dc..4fcc5dd1 100644
--- a/tests/formatter/BookmarkDefaultFormatterTest.php
+++ b/tests/formatter/BookmarkDefaultFormatterTest.php
@@ -289,4 +289,24 @@ class BookmarkDefaultFormatterTest extends TestCase
289 $link['taglist_html'] 289 $link['taglist_html']
290 ); 290 );
291 } 291 }
292
293 /**
294 * Test default formatting with formatter_settings.autolink set to false:
295 * URLs and hashtags should not be transformed
296 */
297 public function testFormatDescriptionWithoutLinkification(): void
298 {
299 $this->conf->set('formatter_settings.autolink', false);
300 $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
301
302 $bookmark = new Bookmark();
303 $bookmark->setDescription('Hi!' . PHP_EOL . 'https://thisisaurl.tld #hashtag');
304
305 $link = $this->formatter->format($bookmark);
306
307 static::assertSame(
308 'Hi!<br />' . PHP_EOL . 'https://thisisaurl.tld &nbsp;#hashtag',
309 $link['description']
310 );
311 }
292} 312}
diff --git a/tests/front/controller/admin/ConfigureControllerTest.php b/tests/front/controller/admin/ConfigureControllerTest.php
index d82db0a7..13644df9 100644
--- a/tests/front/controller/admin/ConfigureControllerTest.php
+++ b/tests/front/controller/admin/ConfigureControllerTest.php
@@ -62,7 +62,7 @@ class ConfigureControllerTest extends TestCase
62 static::assertSame('privacy.hide_public_links', $assignedVariables['hide_public_links']); 62 static::assertSame('privacy.hide_public_links', $assignedVariables['hide_public_links']);
63 static::assertSame('api.enabled', $assignedVariables['api_enabled']); 63 static::assertSame('api.enabled', $assignedVariables['api_enabled']);
64 static::assertSame('api.secret', $assignedVariables['api_secret']); 64 static::assertSame('api.secret', $assignedVariables['api_secret']);
65 static::assertCount(5, $assignedVariables['languages']); 65 static::assertCount(6, $assignedVariables['languages']);
66 static::assertArrayHasKey('gd_enabled', $assignedVariables); 66 static::assertArrayHasKey('gd_enabled', $assignedVariables);
67 static::assertSame('thumbnails.mode', $assignedVariables['thumbnails_mode']); 67 static::assertSame('thumbnails.mode', $assignedVariables['thumbnails_mode']);
68 } 68 }
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php b/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php
deleted file mode 100644
index 0f27ec2f..00000000
--- a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
6
7use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
8use Shaarli\Front\Controller\Admin\ManageShaareController;
9use Shaarli\Http\HttpAccess;
10use Shaarli\TestCase;
11use Slim\Http\Request;
12use Slim\Http\Response;
13
14class AddShaareTest extends TestCase
15{
16 use FrontAdminControllerMockHelper;
17
18 /** @var ManageShaareController */
19 protected $controller;
20
21 public function setUp(): void
22 {
23 $this->createContainer();
24
25 $this->container->httpAccess = $this->createMock(HttpAccess::class);
26 $this->controller = new ManageShaareController($this->container);
27 }
28
29 /**
30 * Test displaying add link page
31 */
32 public function testAddShaare(): void
33 {
34 $assignedVariables = [];
35 $this->assignTemplateVars($assignedVariables);
36
37 $request = $this->createMock(Request::class);
38 $response = new Response();
39
40 $result = $this->controller->addShaare($request, $response);
41
42 static::assertSame(200, $result->getStatusCode());
43 static::assertSame('addlink', (string) $result->getBody());
44
45 static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']);
46 }
47}
diff --git a/tests/front/controller/admin/ManageTagControllerTest.php b/tests/front/controller/admin/ManageTagControllerTest.php
index 8a0ff7a9..af6f273f 100644
--- a/tests/front/controller/admin/ManageTagControllerTest.php
+++ b/tests/front/controller/admin/ManageTagControllerTest.php
@@ -6,6 +6,7 @@ namespace Shaarli\Front\Controller\Admin;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\BookmarkFilter; 8use Shaarli\Bookmark\BookmarkFilter;
9use Shaarli\Config\ConfigManager;
9use Shaarli\Front\Exception\WrongTokenException; 10use Shaarli\Front\Exception\WrongTokenException;
10use Shaarli\Security\SessionManager; 11use Shaarli\Security\SessionManager;
11use Shaarli\TestCase; 12use Shaarli\TestCase;
@@ -44,10 +45,33 @@ class ManageTagControllerTest extends TestCase
44 static::assertSame('changetag', (string) $result->getBody()); 45 static::assertSame('changetag', (string) $result->getBody());
45 46
46 static::assertSame('fromtag', $assignedVariables['fromtag']); 47 static::assertSame('fromtag', $assignedVariables['fromtag']);
48 static::assertSame('@', $assignedVariables['tags_separator']);
47 static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']); 49 static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']);
48 } 50 }
49 51
50 /** 52 /**
53 * Test displaying manage tag page
54 */
55 public function testIndexWhitespaceSeparator(): void
56 {
57 $assignedVariables = [];
58 $this->assignTemplateVars($assignedVariables);
59
60 $this->container->conf = $this->createMock(ConfigManager::class);
61 $this->container->conf->method('get')->willReturnCallback(function (string $key) {
62 return $key === 'general.tags_separator' ? ' ' : $key;
63 });
64
65 $request = $this->createMock(Request::class);
66 $response = new Response();
67
68 $this->controller->index($request, $response);
69
70 static::assertSame('&nbsp;', $assignedVariables['tags_separator']);
71 static::assertSame('whitespace', $assignedVariables['tags_separator_desc']);
72 }
73
74 /**
51 * Test posting a tag update - rename tag - valid info provided. 75 * Test posting a tag update - rename tag - valid info provided.
52 */ 76 */
53 public function testSaveRenameTagValid(): void 77 public function testSaveRenameTagValid(): void
@@ -269,4 +293,116 @@ class ManageTagControllerTest extends TestCase
269 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session); 293 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
270 static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]); 294 static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
271 } 295 }
296
297 /**
298 * Test changeSeparator to '#': redirection + success message.
299 */
300 public function testChangeSeparatorValid(): void
301 {
302 $toSeparator = '#';
303
304 $session = [];
305 $this->assignSessionVars($session);
306
307 $request = $this->createMock(Request::class);
308 $request
309 ->expects(static::atLeastOnce())
310 ->method('getParam')
311 ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
312 return $key === 'separator' ? $toSeparator : $key;
313 })
314 ;
315 $response = new Response();
316
317 $this->container->conf
318 ->expects(static::once())
319 ->method('set')
320 ->with('general.tags_separator', $toSeparator, true, true)
321 ;
322
323 $result = $this->controller->changeSeparator($request, $response);
324
325 static::assertSame(302, $result->getStatusCode());
326 static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
327
328 static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
329 static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
330 static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
331 static::assertSame(
332 ['Your tags separator setting has been updated!'],
333 $session[SessionManager::KEY_SUCCESS_MESSAGES]
334 );
335 }
336
337 /**
338 * Test changeSeparator to '#@' (too long): redirection + error message.
339 */
340 public function testChangeSeparatorInvalidTooLong(): void
341 {
342 $toSeparator = '#@';
343
344 $session = [];
345 $this->assignSessionVars($session);
346
347 $request = $this->createMock(Request::class);
348 $request
349 ->expects(static::atLeastOnce())
350 ->method('getParam')
351 ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
352 return $key === 'separator' ? $toSeparator : $key;
353 })
354 ;
355 $response = new Response();
356
357 $this->container->conf->expects(static::never())->method('set');
358
359 $result = $this->controller->changeSeparator($request, $response);
360
361 static::assertSame(302, $result->getStatusCode());
362 static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
363
364 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
365 static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
366 static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
367 static::assertSame(
368 ['Tags separator must be a single character.'],
369 $session[SessionManager::KEY_ERROR_MESSAGES]
370 );
371 }
372
373 /**
374 * Test changeSeparator to '#@' (too long): redirection + error message.
375 */
376 public function testChangeSeparatorInvalidReservedCharacter(): void
377 {
378 $toSeparator = '*';
379
380 $session = [];
381 $this->assignSessionVars($session);
382
383 $request = $this->createMock(Request::class);
384 $request
385 ->expects(static::atLeastOnce())
386 ->method('getParam')
387 ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
388 return $key === 'separator' ? $toSeparator : $key;
389 })
390 ;
391 $response = new Response();
392
393 $this->container->conf->expects(static::never())->method('set');
394
395 $result = $this->controller->changeSeparator($request, $response);
396
397 static::assertSame(302, $result->getStatusCode());
398 static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
399
400 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
401 static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
402 static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
403 static::assertStringStartsWith(
404 'These characters are reserved and can\'t be used as tags separator',
405 $session[SessionManager::KEY_ERROR_MESSAGES][0]
406 );
407 }
272} 408}
diff --git a/tests/front/controller/admin/ServerControllerTest.php b/tests/front/controller/admin/ServerControllerTest.php
new file mode 100644
index 00000000..355cce7d
--- /dev/null
+++ b/tests/front/controller/admin/ServerControllerTest.php
@@ -0,0 +1,184 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Config\ConfigManager;
8use Shaarli\Security\SessionManager;
9use Shaarli\TestCase;
10use Slim\Http\Request;
11use Slim\Http\Response;
12
13/**
14 * Test Server administration controller.
15 */
16class ServerControllerTest extends TestCase
17{
18 use FrontAdminControllerMockHelper;
19
20 /** @var ServerController */
21 protected $controller;
22
23 public function setUp(): void
24 {
25 $this->createContainer();
26
27 $this->controller = new ServerController($this->container);
28
29 // initialize dummy cache
30 @mkdir('sandbox/');
31 foreach (['pagecache', 'tmp', 'cache'] as $folder) {
32 @mkdir('sandbox/' . $folder);
33 @touch('sandbox/' . $folder . '/.htaccess');
34 @touch('sandbox/' . $folder . '/1');
35 @touch('sandbox/' . $folder . '/2');
36 }
37 }
38
39 public function tearDown(): void
40 {
41 foreach (['pagecache', 'tmp', 'cache'] as $folder) {
42 @unlink('sandbox/' . $folder . '/.htaccess');
43 @unlink('sandbox/' . $folder . '/1');
44 @unlink('sandbox/' . $folder . '/2');
45 @rmdir('sandbox/' . $folder);
46 }
47 }
48
49 /**
50 * Test default display of server administration page.
51 */
52 public function testIndex(): void
53 {
54 $request = $this->createMock(Request::class);
55 $response = new Response();
56
57 // Save RainTPL assigned variables
58 $assignedVariables = [];
59 $this->assignTemplateVars($assignedVariables);
60
61 $result = $this->controller->index($request, $response);
62
63 static::assertSame(200, $result->getStatusCode());
64 static::assertSame('server', (string) $result->getBody());
65
66 static::assertSame(PHP_VERSION, $assignedVariables['php_version']);
67 static::assertArrayHasKey('php_has_reached_eol', $assignedVariables);
68 static::assertArrayHasKey('php_eol', $assignedVariables);
69 static::assertArrayHasKey('php_extensions', $assignedVariables);
70 static::assertArrayHasKey('permissions', $assignedVariables);
71 static::assertEmpty($assignedVariables['permissions']);
72
73 static::assertRegExp(
74 '#https://github\.com/shaarli/Shaarli/releases/tag/v\d+\.\d+\.\d+#',
75 $assignedVariables['release_url']
76 );
77 static::assertRegExp('#v\d+\.\d+\.\d+#', $assignedVariables['latest_version']);
78 static::assertRegExp('#(v\d+\.\d+\.\d+|dev)#', $assignedVariables['current_version']);
79 static::assertArrayHasKey('index_url', $assignedVariables);
80 static::assertArrayHasKey('client_ip', $assignedVariables);
81 static::assertArrayHasKey('trusted_proxies', $assignedVariables);
82
83 static::assertSame('Server administration - Shaarli', $assignedVariables['pagetitle']);
84 }
85
86 /**
87 * Test clearing the main cache
88 */
89 public function testClearMainCache(): void
90 {
91 $this->container->conf = $this->createMock(ConfigManager::class);
92 $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
93 if ($key === 'resource.page_cache') {
94 return 'sandbox/pagecache';
95 } elseif ($key === 'resource.raintpl_tmp') {
96 return 'sandbox/tmp';
97 } elseif ($key === 'resource.thumbnails_cache') {
98 return 'sandbox/cache';
99 } else {
100 return $default;
101 }
102 });
103
104 $this->container->sessionManager
105 ->expects(static::once())
106 ->method('setSessionParameter')
107 ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['Shaarli\'s cache folder has been cleared!'])
108 ;
109
110 $request = $this->createMock(Request::class);
111 $request->method('getQueryParam')->with('type')->willReturn('main');
112 $response = new Response();
113
114 $result = $this->controller->clearCache($request, $response);
115
116 static::assertSame(302, $result->getStatusCode());
117 static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location'));
118
119 static::assertFileNotExists('sandbox/pagecache/1');
120 static::assertFileNotExists('sandbox/pagecache/2');
121 static::assertFileNotExists('sandbox/tmp/1');
122 static::assertFileNotExists('sandbox/tmp/2');
123
124 static::assertFileExists('sandbox/pagecache/.htaccess');
125 static::assertFileExists('sandbox/tmp/.htaccess');
126 static::assertFileExists('sandbox/cache');
127 static::assertFileExists('sandbox/cache/.htaccess');
128 static::assertFileExists('sandbox/cache/1');
129 static::assertFileExists('sandbox/cache/2');
130 }
131
132 /**
133 * Test clearing thumbnails cache
134 */
135 public function testClearThumbnailsCache(): void
136 {
137 $this->container->conf = $this->createMock(ConfigManager::class);
138 $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
139 if ($key === 'resource.page_cache') {
140 return 'sandbox/pagecache';
141 } elseif ($key === 'resource.raintpl_tmp') {
142 return 'sandbox/tmp';
143 } elseif ($key === 'resource.thumbnails_cache') {
144 return 'sandbox/cache';
145 } else {
146 return $default;
147 }
148 });
149
150 $this->container->sessionManager
151 ->expects(static::once())
152 ->method('setSessionParameter')
153 ->willReturnCallback(function (string $key, array $value): SessionManager {
154 static::assertSame(SessionManager::KEY_WARNING_MESSAGES, $key);
155 static::assertCount(1, $value);
156 static::assertStringStartsWith('Thumbnails cache has been cleared.', $value[0]);
157
158 return $this->container->sessionManager;
159 });
160 ;
161
162 $request = $this->createMock(Request::class);
163 $request->method('getQueryParam')->with('type')->willReturn('thumbnails');
164 $response = new Response();
165
166 $result = $this->controller->clearCache($request, $response);
167
168 static::assertSame(302, $result->getStatusCode());
169 static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location'));
170
171 static::assertFileNotExists('sandbox/cache/1');
172 static::assertFileNotExists('sandbox/cache/2');
173
174 static::assertFileExists('sandbox/cache/.htaccess');
175 static::assertFileExists('sandbox/pagecache');
176 static::assertFileExists('sandbox/pagecache/.htaccess');
177 static::assertFileExists('sandbox/pagecache/1');
178 static::assertFileExists('sandbox/pagecache/2');
179 static::assertFileExists('sandbox/tmp');
180 static::assertFileExists('sandbox/tmp/.htaccess');
181 static::assertFileExists('sandbox/tmp/1');
182 static::assertFileExists('sandbox/tmp/2');
183 }
184}
diff --git a/tests/front/controller/admin/ShaareAddControllerTest.php b/tests/front/controller/admin/ShaareAddControllerTest.php
new file mode 100644
index 00000000..a27ebe64
--- /dev/null
+++ b/tests/front/controller/admin/ShaareAddControllerTest.php
@@ -0,0 +1,97 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin;
6
7use Shaarli\Config\ConfigManager;
8use Shaarli\Formatter\BookmarkMarkdownFormatter;
9use Shaarli\Http\HttpAccess;
10use Shaarli\TestCase;
11use Slim\Http\Request;
12use Slim\Http\Response;
13
14class ShaareAddControllerTest extends TestCase
15{
16 use FrontAdminControllerMockHelper;
17
18 /** @var ShaareAddController */
19 protected $controller;
20
21 public function setUp(): void
22 {
23 $this->createContainer();
24
25 $this->container->httpAccess = $this->createMock(HttpAccess::class);
26 $this->controller = new ShaareAddController($this->container);
27 }
28
29 /**
30 * Test displaying add link page
31 */
32 public function testAddShaare(): void
33 {
34 $assignedVariables = [];
35 $this->assignTemplateVars($assignedVariables);
36
37 $request = $this->createMock(Request::class);
38 $response = new Response();
39
40 $expectedTags = [
41 'tag1' => 32,
42 'tag2' => 24,
43 'tag3' => 1,
44 ];
45 $this->container->bookmarkService
46 ->expects(static::once())
47 ->method('bookmarksCountPerTag')
48 ->willReturn($expectedTags)
49 ;
50 $expectedTags = array_merge($expectedTags, [BookmarkMarkdownFormatter::NO_MD_TAG => 1]);
51
52 $this->container->conf = $this->createMock(ConfigManager::class);
53 $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
54 return $key === 'formatter' ? 'markdown' : $default;
55 });
56
57 $result = $this->controller->addShaare($request, $response);
58
59 static::assertSame(200, $result->getStatusCode());
60 static::assertSame('addlink', (string) $result->getBody());
61
62 static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']);
63 static::assertFalse($assignedVariables['default_private_links']);
64 static::assertTrue($assignedVariables['async_metadata']);
65 static::assertSame($expectedTags, $assignedVariables['tags']);
66 }
67
68 /**
69 * Test displaying add link page
70 */
71 public function testAddShaareWithoutMd(): void
72 {
73 $assignedVariables = [];
74 $this->assignTemplateVars($assignedVariables);
75
76 $request = $this->createMock(Request::class);
77 $response = new Response();
78
79 $expectedTags = [
80 'tag1' => 32,
81 'tag2' => 24,
82 'tag3' => 1,
83 ];
84 $this->container->bookmarkService
85 ->expects(static::once())
86 ->method('bookmarksCountPerTag')
87 ->willReturn($expectedTags)
88 ;
89
90 $result = $this->controller->addShaare($request, $response);
91
92 static::assertSame(200, $result->getStatusCode());
93 static::assertSame('addlink', (string) $result->getBody());
94
95 static::assertSame($expectedTags, $assignedVariables['tags']);
96 }
97}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php
index 096d0774..28b1c023 100644
--- a/tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php
+++ b/tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php
@@ -2,7 +2,7 @@
2 2
3declare(strict_types=1); 3declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; 5namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
@@ -10,7 +10,7 @@ use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\BookmarkRawFormatter; 10use Shaarli\Formatter\BookmarkRawFormatter;
11use Shaarli\Formatter\FormatterFactory; 11use Shaarli\Formatter\FormatterFactory;
12use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; 12use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
13use Shaarli\Front\Controller\Admin\ManageShaareController; 13use Shaarli\Front\Controller\Admin\ShaareManageController;
14use Shaarli\Http\HttpAccess; 14use Shaarli\Http\HttpAccess;
15use Shaarli\Security\SessionManager; 15use Shaarli\Security\SessionManager;
16use Shaarli\TestCase; 16use Shaarli\TestCase;
@@ -21,7 +21,7 @@ class ChangeVisibilityBookmarkTest extends TestCase
21{ 21{
22 use FrontAdminControllerMockHelper; 22 use FrontAdminControllerMockHelper;
23 23
24 /** @var ManageShaareController */ 24 /** @var ShaareManageController */
25 protected $controller; 25 protected $controller;
26 26
27 public function setUp(): void 27 public function setUp(): void
@@ -29,7 +29,7 @@ class ChangeVisibilityBookmarkTest extends TestCase
29 $this->createContainer(); 29 $this->createContainer();
30 30
31 $this->container->httpAccess = $this->createMock(HttpAccess::class); 31 $this->container->httpAccess = $this->createMock(HttpAccess::class);
32 $this->controller = new ManageShaareController($this->container); 32 $this->controller = new ShaareManageController($this->container);
33 } 33 }
34 34
35 /** 35 /**
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php
index 83bbee7c..a276d988 100644
--- a/tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php
+++ b/tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php
@@ -2,14 +2,14 @@
2 2
3declare(strict_types=1); 3declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; 5namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Formatter\BookmarkFormatter; 9use Shaarli\Formatter\BookmarkFormatter;
10use Shaarli\Formatter\FormatterFactory; 10use Shaarli\Formatter\FormatterFactory;
11use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; 11use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
12use Shaarli\Front\Controller\Admin\ManageShaareController; 12use Shaarli\Front\Controller\Admin\ShaareManageController;
13use Shaarli\Http\HttpAccess; 13use Shaarli\Http\HttpAccess;
14use Shaarli\Security\SessionManager; 14use Shaarli\Security\SessionManager;
15use Shaarli\TestCase; 15use Shaarli\TestCase;
@@ -20,7 +20,7 @@ class DeleteBookmarkTest extends TestCase
20{ 20{
21 use FrontAdminControllerMockHelper; 21 use FrontAdminControllerMockHelper;
22 22
23 /** @var ManageShaareController */ 23 /** @var ShaareManageController */
24 protected $controller; 24 protected $controller;
25 25
26 public function setUp(): void 26 public function setUp(): void
@@ -28,7 +28,7 @@ class DeleteBookmarkTest extends TestCase
28 $this->createContainer(); 28 $this->createContainer();
29 29
30 $this->container->httpAccess = $this->createMock(HttpAccess::class); 30 $this->container->httpAccess = $this->createMock(HttpAccess::class);
31 $this->controller = new ManageShaareController($this->container); 31 $this->controller = new ShaareManageController($this->container);
32 } 32 }
33 33
34 /** 34 /**
@@ -38,6 +38,8 @@ class DeleteBookmarkTest extends TestCase
38 { 38 {
39 $parameters = ['id' => '123']; 39 $parameters = ['id' => '123'];
40 40
41 $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/shaare/abcdef';
42
41 $request = $this->createMock(Request::class); 43 $request = $this->createMock(Request::class);
42 $request 44 $request
43 ->method('getParam') 45 ->method('getParam')
@@ -90,6 +92,8 @@ class DeleteBookmarkTest extends TestCase
90 { 92 {
91 $parameters = ['id' => '123 456 789']; 93 $parameters = ['id' => '123 456 789'];
92 94
95 $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/?searchtags=abcdef';
96
93 $request = $this->createMock(Request::class); 97 $request = $this->createMock(Request::class);
94 $request 98 $request
95 ->method('getParam') 99 ->method('getParam')
@@ -152,7 +156,7 @@ class DeleteBookmarkTest extends TestCase
152 $result = $this->controller->deleteBookmark($request, $response); 156 $result = $this->controller->deleteBookmark($request, $response);
153 157
154 static::assertSame(302, $result->getStatusCode()); 158 static::assertSame(302, $result->getStatusCode());
155 static::assertSame(['/subfolder/'], $result->getHeader('location')); 159 static::assertSame(['/subfolder/?searchtags=abcdef'], $result->getHeader('location'));
156 } 160 }
157 161
158 /** 162 /**
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php
index 50ce7df1..b89206ce 100644
--- a/tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php
+++ b/tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php
@@ -2,12 +2,12 @@
2 2
3declare(strict_types=1); 3declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; 5namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; 9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
10use Shaarli\Front\Controller\Admin\ManageShaareController; 10use Shaarli\Front\Controller\Admin\ShaareManageController;
11use Shaarli\Http\HttpAccess; 11use Shaarli\Http\HttpAccess;
12use Shaarli\Security\SessionManager; 12use Shaarli\Security\SessionManager;
13use Shaarli\TestCase; 13use Shaarli\TestCase;
@@ -18,7 +18,7 @@ class PinBookmarkTest extends TestCase
18{ 18{
19 use FrontAdminControllerMockHelper; 19 use FrontAdminControllerMockHelper;
20 20
21 /** @var ManageShaareController */ 21 /** @var ShaareManageController */
22 protected $controller; 22 protected $controller;
23 23
24 public function setUp(): void 24 public function setUp(): void
@@ -26,7 +26,7 @@ class PinBookmarkTest extends TestCase
26 $this->createContainer(); 26 $this->createContainer();
27 27
28 $this->container->httpAccess = $this->createMock(HttpAccess::class); 28 $this->container->httpAccess = $this->createMock(HttpAccess::class);
29 $this->controller = new ManageShaareController($this->container); 29 $this->controller = new ShaareManageController($this->container);
30 } 30 }
31 31
32 /** 32 /**
diff --git a/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php b/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php
new file mode 100644
index 00000000..ae61dfb7
--- /dev/null
+++ b/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php
@@ -0,0 +1,139 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
6
7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
9use Shaarli\Front\Controller\Admin\ShaareManageController;
10use Shaarli\Http\HttpAccess;
11use Shaarli\TestCase;
12use Slim\Http\Request;
13use Slim\Http\Response;
14
15/**
16 * Test GET /admin/shaare/private/{hash}
17 */
18class SharePrivateTest extends TestCase
19{
20 use FrontAdminControllerMockHelper;
21
22 /** @var ShaareManageController */
23 protected $controller;
24
25 public function setUp(): void
26 {
27 $this->createContainer();
28
29 $this->container->httpAccess = $this->createMock(HttpAccess::class);
30 $this->controller = new ShaareManageController($this->container);
31 }
32
33 /**
34 * Test shaare private with a private bookmark which does not have a key yet.
35 */
36 public function testSharePrivateWithNewPrivateBookmark(): void
37 {
38 $hash = 'abcdcef';
39 $request = $this->createMock(Request::class);
40 $response = new Response();
41
42 $bookmark = (new Bookmark())
43 ->setId(123)
44 ->setUrl('http://domain.tld')
45 ->setTitle('Title 123')
46 ->setPrivate(true)
47 ;
48
49 $this->container->bookmarkService
50 ->expects(static::once())
51 ->method('findByHash')
52 ->with($hash)
53 ->willReturn($bookmark)
54 ;
55 $this->container->bookmarkService
56 ->expects(static::once())
57 ->method('set')
58 ->with($bookmark, true)
59 ->willReturnCallback(function (Bookmark $bookmark): Bookmark {
60 static::assertSame(32, strlen($bookmark->getAdditionalContentEntry('private_key')));
61
62 return $bookmark;
63 })
64 ;
65
66 $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
67
68 static::assertSame(302, $result->getStatusCode());
69 static::assertRegExp('#/subfolder/shaare/' . $hash . '\?key=\w{32}#', $result->getHeaderLine('Location'));
70 }
71
72 /**
73 * Test shaare private with a private bookmark which does already have a key.
74 */
75 public function testSharePrivateWithExistingPrivateBookmark(): void
76 {
77 $hash = 'abcdcef';
78 $existingKey = 'this is a private key';
79 $request = $this->createMock(Request::class);
80 $response = new Response();
81
82 $bookmark = (new Bookmark())
83 ->setId(123)
84 ->setUrl('http://domain.tld')
85 ->setTitle('Title 123')
86 ->setPrivate(true)
87 ->addAdditionalContentEntry('private_key', $existingKey)
88 ;
89
90 $this->container->bookmarkService
91 ->expects(static::once())
92 ->method('findByHash')
93 ->with($hash)
94 ->willReturn($bookmark)
95 ;
96 $this->container->bookmarkService
97 ->expects(static::never())
98 ->method('set')
99 ;
100
101 $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
102
103 static::assertSame(302, $result->getStatusCode());
104 static::assertSame('/subfolder/shaare/' . $hash . '?key=' . $existingKey, $result->getHeaderLine('Location'));
105 }
106
107 /**
108 * Test shaare private with a public bookmark.
109 */
110 public function testSharePrivateWithPublicBookmark(): void
111 {
112 $hash = 'abcdcef';
113 $request = $this->createMock(Request::class);
114 $response = new Response();
115
116 $bookmark = (new Bookmark())
117 ->setId(123)
118 ->setUrl('http://domain.tld')
119 ->setTitle('Title 123')
120 ->setPrivate(false)
121 ;
122
123 $this->container->bookmarkService
124 ->expects(static::once())
125 ->method('findByHash')
126 ->with($hash)
127 ->willReturn($bookmark)
128 ;
129 $this->container->bookmarkService
130 ->expects(static::never())
131 ->method('set')
132 ;
133
134 $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
135
136 static::assertSame(302, $result->getStatusCode());
137 static::assertSame('/subfolder/shaare/' . $hash, $result->getHeaderLine('Location'));
138 }
139}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php
new file mode 100644
index 00000000..ce8e112b
--- /dev/null
+++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php
@@ -0,0 +1,63 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
6
7use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
8use Shaarli\Front\Controller\Admin\ShaarePublishController;
9use Shaarli\Http\HttpAccess;
10use Shaarli\Http\MetadataRetriever;
11use Shaarli\TestCase;
12use Slim\Http\Request;
13use Slim\Http\Response;
14
15class DisplayCreateBatchFormTest extends TestCase
16{
17 use FrontAdminControllerMockHelper;
18
19 /** @var ShaarePublishController */
20 protected $controller;
21
22 public function setUp(): void
23 {
24 $this->createContainer();
25
26 $this->container->httpAccess = $this->createMock(HttpAccess::class);
27 $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
28 $this->controller = new ShaarePublishController($this->container);
29 }
30
31 /**
32 * TODO
33 */
34 public function testDisplayCreateFormBatch(): void
35 {
36 $urls = [
37 'https://domain1.tld/url1',
38 'https://domain2.tld/url2',
39 ' ',
40 'https://domain3.tld/url3',
41 ];
42
43 $request = $this->createMock(Request::class);
44 $request->method('getParam')->willReturnCallback(function (string $key) use ($urls): ?string {
45 return $key === 'urls' ? implode(PHP_EOL, $urls) : null;
46 });
47 $response = new Response();
48
49 $assignedVariables = [];
50 $this->assignTemplateVars($assignedVariables);
51
52 $result = $this->controller->displayCreateBatchForms($request, $response);
53
54 static::assertSame(200, $result->getStatusCode());
55 static::assertSame('editlink.batch', (string) $result->getBody());
56
57 static::assertTrue($assignedVariables['batch_mode']);
58 static::assertCount(3, $assignedVariables['links']);
59 static::assertSame($urls[0], $assignedVariables['links'][0]['link']['url']);
60 static::assertSame($urls[1], $assignedVariables['links'][1]['link']['url']);
61 static::assertSame($urls[3], $assignedVariables['links'][2]['link']['url']);
62 }
63}
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
index 2eb95251..964773da 100644
--- a/tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
+++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
@@ -2,13 +2,14 @@
2 2
3declare(strict_types=1); 3declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; 5namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Config\ConfigManager; 8use Shaarli\Config\ConfigManager;
9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; 9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
10use Shaarli\Front\Controller\Admin\ManageShaareController; 10use Shaarli\Front\Controller\Admin\ShaarePublishController;
11use Shaarli\Http\HttpAccess; 11use Shaarli\Http\HttpAccess;
12use Shaarli\Http\MetadataRetriever;
12use Shaarli\TestCase; 13use Shaarli\TestCase;
13use Slim\Http\Request; 14use Slim\Http\Request;
14use Slim\Http\Response; 15use Slim\Http\Response;
@@ -17,7 +18,7 @@ class DisplayCreateFormTest extends TestCase
17{ 18{
18 use FrontAdminControllerMockHelper; 19 use FrontAdminControllerMockHelper;
19 20
20 /** @var ManageShaareController */ 21 /** @var ShaarePublishController */
21 protected $controller; 22 protected $controller;
22 23
23 public function setUp(): void 24 public function setUp(): void
@@ -25,14 +26,15 @@ class DisplayCreateFormTest extends TestCase
25 $this->createContainer(); 26 $this->createContainer();
26 27
27 $this->container->httpAccess = $this->createMock(HttpAccess::class); 28 $this->container->httpAccess = $this->createMock(HttpAccess::class);
28 $this->controller = new ManageShaareController($this->container); 29 $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
30 $this->controller = new ShaarePublishController($this->container);
29 } 31 }
30 32
31 /** 33 /**
32 * Test displaying bookmark create form 34 * Test displaying bookmark create form
33 * Ensure that every step of the standard workflow works properly. 35 * Ensure that every step of the standard workflow works properly.
34 */ 36 */
35 public function testDisplayCreateFormWithUrl(): void 37 public function testDisplayCreateFormWithUrlAndWithMetadataRetrieval(): void
36 { 38 {
37 $this->container->environment = [ 39 $this->container->environment = [
38 'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc' 40 'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
@@ -53,40 +55,20 @@ class DisplayCreateFormTest extends TestCase
53 }); 55 });
54 $response = new Response(); 56 $response = new Response();
55 57
56 $this->container->httpAccess 58 $this->container->conf = $this->createMock(ConfigManager::class);
57 ->expects(static::once()) 59 $this->container->conf->method('get')->willReturnCallback(function (string $param, $default) {
58 ->method('getCurlDownloadCallback') 60 if ($param === 'general.enable_async_metadata') {
59 ->willReturnCallback( 61 return false;
60 function (&$charset, &$title, &$description, &$tags) use ( 62 }
61 $remoteTitle, 63
62 $remoteDesc, 64 return $default;
63 $remoteTags 65 });
64 ): callable { 66
65 return function () use ( 67 $this->container->metadataRetriever->expects(static::once())->method('retrieve')->willReturn([
66 &$charset, 68 'title' => $remoteTitle,
67 &$title, 69 'description' => $remoteDesc,
68 &$description, 70 'tags' => $remoteTags,
69 &$tags, 71 ]);
70 $remoteTitle,
71 $remoteDesc,
72 $remoteTags
73 ): void {
74 $charset = 'ISO-8859-1';
75 $title = $remoteTitle;
76 $description = $remoteDesc;
77 $tags = $remoteTags;
78 };
79 }
80 )
81 ;
82 $this->container->httpAccess
83 ->expects(static::once())
84 ->method('getHttpResponse')
85 ->with($expectedUrl, 30, 4194304)
86 ->willReturnCallback(function($url, $timeout, $maxBytes, $callback): void {
87 $callback();
88 })
89 ;
90 72
91 $this->container->bookmarkService 73 $this->container->bookmarkService
92 ->expects(static::once()) 74 ->expects(static::once())
@@ -119,7 +101,73 @@ class DisplayCreateFormTest extends TestCase
119 static::assertSame($expectedUrl, $assignedVariables['link']['url']); 101 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
120 static::assertSame($remoteTitle, $assignedVariables['link']['title']); 102 static::assertSame($remoteTitle, $assignedVariables['link']['title']);
121 static::assertSame($remoteDesc, $assignedVariables['link']['description']); 103 static::assertSame($remoteDesc, $assignedVariables['link']['description']);
122 static::assertSame($remoteTags, $assignedVariables['link']['tags']); 104 static::assertSame($remoteTags . ' ', $assignedVariables['link']['tags']);
105 static::assertFalse($assignedVariables['link']['private']);
106
107 static::assertTrue($assignedVariables['link_is_new']);
108 static::assertSame($referer, $assignedVariables['http_referer']);
109 static::assertSame($tags, $assignedVariables['tags']);
110 static::assertArrayHasKey('source', $assignedVariables);
111 static::assertArrayHasKey('default_private_links', $assignedVariables);
112 static::assertArrayHasKey('async_metadata', $assignedVariables);
113 static::assertArrayHasKey('retrieve_description', $assignedVariables);
114 }
115
116 /**
117 * Test displaying bookmark create form without any external metadata retrieval attempt
118 */
119 public function testDisplayCreateFormWithUrlAndWithoutMetadata(): void
120 {
121 $this->container->environment = [
122 'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
123 ];
124
125 $assignedVariables = [];
126 $this->assignTemplateVars($assignedVariables);
127
128 $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
129 $expectedUrl = str_replace('&utm_ad=pay', '', $url);
130
131 $request = $this->createMock(Request::class);
132 $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
133 return $key === 'post' ? $url : null;
134 });
135 $response = new Response();
136
137 $this->container->metadataRetriever->expects(static::never())->method('retrieve');
138
139 $this->container->bookmarkService
140 ->expects(static::once())
141 ->method('bookmarksCountPerTag')
142 ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
143 ;
144
145 // Make sure that PluginManager hook is triggered
146 $this->container->pluginManager
147 ->expects(static::atLeastOnce())
148 ->method('executeHooks')
149 ->withConsecutive(['render_editlink'], ['render_includes'])
150 ->willReturnCallback(function (string $hook, array $data): array {
151 if ('render_editlink' === $hook) {
152 static::assertSame('', $data['link']['title']);
153 static::assertSame('', $data['link']['description']);
154 }
155
156 return $data;
157 })
158 ;
159
160 $result = $this->controller->displayCreateForm($request, $response);
161
162 static::assertSame(200, $result->getStatusCode());
163 static::assertSame('editlink', (string) $result->getBody());
164
165 static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
166
167 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
168 static::assertSame('', $assignedVariables['link']['title']);
169 static::assertSame('', $assignedVariables['link']['description']);
170 static::assertSame('', $assignedVariables['link']['tags']);
123 static::assertFalse($assignedVariables['link']['private']); 171 static::assertFalse($assignedVariables['link']['private']);
124 172
125 static::assertTrue($assignedVariables['link_is_new']); 173 static::assertTrue($assignedVariables['link_is_new']);
@@ -127,6 +175,8 @@ class DisplayCreateFormTest extends TestCase
127 static::assertSame($tags, $assignedVariables['tags']); 175 static::assertSame($tags, $assignedVariables['tags']);
128 static::assertArrayHasKey('source', $assignedVariables); 176 static::assertArrayHasKey('source', $assignedVariables);
129 static::assertArrayHasKey('default_private_links', $assignedVariables); 177 static::assertArrayHasKey('default_private_links', $assignedVariables);
178 static::assertArrayHasKey('async_metadata', $assignedVariables);
179 static::assertArrayHasKey('retrieve_description', $assignedVariables);
130 } 180 }
131 181
132 /** 182 /**
@@ -142,7 +192,7 @@ class DisplayCreateFormTest extends TestCase
142 'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash', 192 'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash',
143 'title' => 'Provided Title', 193 'title' => 'Provided Title',
144 'description' => 'Provided description.', 194 'description' => 'Provided description.',
145 'tags' => 'abc def', 195 'tags' => 'abc@def',
146 'private' => '1', 196 'private' => '1',
147 'source' => 'apps', 197 'source' => 'apps',
148 ]; 198 ];
@@ -166,7 +216,7 @@ class DisplayCreateFormTest extends TestCase
166 static::assertSame($expectedUrl, $assignedVariables['link']['url']); 216 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
167 static::assertSame($parameters['title'], $assignedVariables['link']['title']); 217 static::assertSame($parameters['title'], $assignedVariables['link']['title']);
168 static::assertSame($parameters['description'], $assignedVariables['link']['description']); 218 static::assertSame($parameters['description'], $assignedVariables['link']['description']);
169 static::assertSame($parameters['tags'], $assignedVariables['link']['tags']); 219 static::assertSame($parameters['tags'] . '@', $assignedVariables['link']['tags']);
170 static::assertTrue($assignedVariables['link']['private']); 220 static::assertTrue($assignedVariables['link']['private']);
171 static::assertTrue($assignedVariables['link_is_new']); 221 static::assertTrue($assignedVariables['link_is_new']);
172 static::assertSame($parameters['source'], $assignedVariables['source']); 222 static::assertSame($parameters['source'], $assignedVariables['source']);
@@ -310,7 +360,7 @@ class DisplayCreateFormTest extends TestCase
310 static::assertSame($expectedUrl, $assignedVariables['link']['url']); 360 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
311 static::assertSame($title, $assignedVariables['link']['title']); 361 static::assertSame($title, $assignedVariables['link']['title']);
312 static::assertSame($description, $assignedVariables['link']['description']); 362 static::assertSame($description, $assignedVariables['link']['description']);
313 static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']); 363 static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
314 static::assertTrue($assignedVariables['link']['private']); 364 static::assertTrue($assignedVariables['link']['private']);
315 static::assertSame($createdAt, $assignedVariables['link']['created']); 365 static::assertSame($createdAt, $assignedVariables['link']['created']);
316 } 366 }
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
index 2dc3f41c..738cea12 100644
--- a/tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php
+++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
@@ -2,12 +2,12 @@
2 2
3declare(strict_types=1); 3declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; 5namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; 9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
10use Shaarli\Front\Controller\Admin\ManageShaareController; 10use Shaarli\Front\Controller\Admin\ShaarePublishController;
11use Shaarli\Http\HttpAccess; 11use Shaarli\Http\HttpAccess;
12use Shaarli\Security\SessionManager; 12use Shaarli\Security\SessionManager;
13use Shaarli\TestCase; 13use Shaarli\TestCase;
@@ -18,7 +18,7 @@ class DisplayEditFormTest extends TestCase
18{ 18{
19 use FrontAdminControllerMockHelper; 19 use FrontAdminControllerMockHelper;
20 20
21 /** @var ManageShaareController */ 21 /** @var ShaarePublishController */
22 protected $controller; 22 protected $controller;
23 23
24 public function setUp(): void 24 public function setUp(): void
@@ -26,7 +26,7 @@ class DisplayEditFormTest extends TestCase
26 $this->createContainer(); 26 $this->createContainer();
27 27
28 $this->container->httpAccess = $this->createMock(HttpAccess::class); 28 $this->container->httpAccess = $this->createMock(HttpAccess::class);
29 $this->controller = new ManageShaareController($this->container); 29 $this->controller = new ShaarePublishController($this->container);
30 } 30 }
31 31
32 /** 32 /**
@@ -74,7 +74,7 @@ class DisplayEditFormTest extends TestCase
74 static::assertSame($url, $assignedVariables['link']['url']); 74 static::assertSame($url, $assignedVariables['link']['url']);
75 static::assertSame($title, $assignedVariables['link']['title']); 75 static::assertSame($title, $assignedVariables['link']['title']);
76 static::assertSame($description, $assignedVariables['link']['description']); 76 static::assertSame($description, $assignedVariables['link']['description']);
77 static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']); 77 static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
78 static::assertTrue($assignedVariables['link']['private']); 78 static::assertTrue($assignedVariables['link']['private']);
79 static::assertSame($createdAt, $assignedVariables['link']['created']); 79 static::assertSame($createdAt, $assignedVariables['link']['created']);
80 } 80 }
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php
index 37542c26..b6a861bc 100644
--- a/tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php
+++ b/tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php
@@ -2,12 +2,12 @@
2 2
3declare(strict_types=1); 3declare(strict_types=1);
4 4
5namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest; 5namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Config\ConfigManager; 8use Shaarli\Config\ConfigManager;
9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper; 9use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
10use Shaarli\Front\Controller\Admin\ManageShaareController; 10use Shaarli\Front\Controller\Admin\ShaarePublishController;
11use Shaarli\Front\Exception\WrongTokenException; 11use Shaarli\Front\Exception\WrongTokenException;
12use Shaarli\Http\HttpAccess; 12use Shaarli\Http\HttpAccess;
13use Shaarli\Security\SessionManager; 13use Shaarli\Security\SessionManager;
@@ -20,7 +20,7 @@ class SaveBookmarkTest extends TestCase
20{ 20{
21 use FrontAdminControllerMockHelper; 21 use FrontAdminControllerMockHelper;
22 22
23 /** @var ManageShaareController */ 23 /** @var ShaarePublishController */
24 protected $controller; 24 protected $controller;
25 25
26 public function setUp(): void 26 public function setUp(): void
@@ -28,7 +28,7 @@ class SaveBookmarkTest extends TestCase
28 $this->createContainer(); 28 $this->createContainer();
29 29
30 $this->container->httpAccess = $this->createMock(HttpAccess::class); 30 $this->container->httpAccess = $this->createMock(HttpAccess::class);
31 $this->controller = new ManageShaareController($this->container); 31 $this->controller = new ShaarePublishController($this->container);
32 } 32 }
33 33
34 /** 34 /**
@@ -209,7 +209,7 @@ class SaveBookmarkTest extends TestCase
209 /** 209 /**
210 * Test save a bookmark - try to retrieve the thumbnail 210 * Test save a bookmark - try to retrieve the thumbnail
211 */ 211 */
212 public function testSaveBookmarkWithThumbnail(): void 212 public function testSaveBookmarkWithThumbnailSync(): void
213 { 213 {
214 $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash']; 214 $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
215 215
@@ -224,7 +224,13 @@ class SaveBookmarkTest extends TestCase
224 224
225 $this->container->conf = $this->createMock(ConfigManager::class); 225 $this->container->conf = $this->createMock(ConfigManager::class);
226 $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) { 226 $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
227 return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default; 227 if ($key === 'thumbnails.mode') {
228 return Thumbnailer::MODE_ALL;
229 } elseif ($key === 'general.enable_async_metadata') {
230 return false;
231 }
232
233 return $default;
228 }); 234 });
229 235
230 $this->container->thumbnailer = $this->createMock(Thumbnailer::class); 236 $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
@@ -275,6 +281,51 @@ class SaveBookmarkTest extends TestCase
275 } 281 }
276 282
277 /** 283 /**
284 * Test save a bookmark - do not attempt to retrieve thumbnails if async mode is enabled.
285 */
286 public function testSaveBookmarkWithThumbnailAsync(): void
287 {
288 $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
289
290 $request = $this->createMock(Request::class);
291 $request
292 ->method('getParam')
293 ->willReturnCallback(function (string $key) use ($parameters): ?string {
294 return $parameters[$key] ?? null;
295 })
296 ;
297 $response = new Response();
298
299 $this->container->conf = $this->createMock(ConfigManager::class);
300 $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
301 if ($key === 'thumbnails.mode') {
302 return Thumbnailer::MODE_ALL;
303 } elseif ($key === 'general.enable_async_metadata') {
304 return true;
305 }
306
307 return $default;
308 });
309
310 $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
311 $this->container->thumbnailer->expects(static::never())->method('get');
312
313 $this->container->bookmarkService
314 ->expects(static::once())
315 ->method('addOrSet')
316 ->willReturnCallback(function (Bookmark $bookmark): Bookmark {
317 static::assertNull($bookmark->getThumbnail());
318
319 return $bookmark;
320 })
321 ;
322
323 $result = $this->controller->save($request, $response);
324
325 static::assertSame(302, $result->getStatusCode());
326 }
327
328 /**
278 * Change the password with a wrong existing password 329 * Change the password with a wrong existing password
279 */ 330 */
280 public function testSaveBookmarkFromBookmarklet(): void 331 public function testSaveBookmarkFromBookmarklet(): void
diff --git a/tests/front/controller/visitor/BookmarkListControllerTest.php b/tests/front/controller/visitor/BookmarkListControllerTest.php
index 0c95df97..dec938f2 100644
--- a/tests/front/controller/visitor/BookmarkListControllerTest.php
+++ b/tests/front/controller/visitor/BookmarkListControllerTest.php
@@ -173,7 +173,7 @@ class BookmarkListControllerTest extends TestCase
173 $request = $this->createMock(Request::class); 173 $request = $this->createMock(Request::class);
174 $request->method('getParam')->willReturnCallback(function (string $key) { 174 $request->method('getParam')->willReturnCallback(function (string $key) {
175 if ('searchtags' === $key) { 175 if ('searchtags' === $key) {
176 return 'abc def'; 176 return 'abc@def';
177 } 177 }
178 if ('searchterm' === $key) { 178 if ('searchterm' === $key) {
179 return 'ghi jkl'; 179 return 'ghi jkl';
@@ -204,7 +204,7 @@ class BookmarkListControllerTest extends TestCase
204 ->expects(static::once()) 204 ->expects(static::once())
205 ->method('search') 205 ->method('search')
206 ->with( 206 ->with(
207 ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'], 207 ['searchtags' => 'abc@def', 'searchterm' => 'ghi jkl'],
208 'private', 208 'private',
209 false, 209 false,
210 true 210 true
@@ -222,7 +222,7 @@ class BookmarkListControllerTest extends TestCase
222 static::assertSame('linklist', (string) $result->getBody()); 222 static::assertSame('linklist', (string) $result->getBody());
223 223
224 static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']); 224 static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']);
225 static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc+def', $assignedVariables['previous_page_url']); 225 static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc%40def', $assignedVariables['previous_page_url']);
226 } 226 }
227 227
228 /** 228 /**
@@ -292,6 +292,37 @@ class BookmarkListControllerTest extends TestCase
292 } 292 }
293 293
294 /** 294 /**
295 * Test GET /shaare/{hash}?key={key} - Find a link by hash using a private link.
296 */
297 public function testPermalinkWithPrivateKey(): void
298 {
299 $hash = 'abcdef';
300 $privateKey = 'this is a private key';
301
302 $assignedVariables = [];
303 $this->assignTemplateVars($assignedVariables);
304
305 $request = $this->createMock(Request::class);
306 $request->method('getParam')->willReturnCallback(function (string $key, $default = null) use ($privateKey) {
307 return $key === 'key' ? $privateKey : $default;
308 });
309 $response = new Response();
310
311 $this->container->bookmarkService
312 ->expects(static::once())
313 ->method('findByHash')
314 ->with($hash, $privateKey)
315 ->willReturn((new Bookmark())->setId(123)->setTitle('Title 1')->setUrl('http://url1.tld'))
316 ;
317
318 $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
319
320 static::assertSame(200, $result->getStatusCode());
321 static::assertSame('linklist', (string) $result->getBody());
322 static::assertCount(1, $assignedVariables['links']);
323 }
324
325 /**
295 * Test getting link list with thumbnail updates. 326 * Test getting link list with thumbnail updates.
296 * -> 2 thumbnails update, only 1 datastore write 327 * -> 2 thumbnails update, only 1 datastore write
297 */ 328 */
@@ -307,7 +338,13 @@ class BookmarkListControllerTest extends TestCase
307 $this->container->conf 338 $this->container->conf
308 ->method('get') 339 ->method('get')
309 ->willReturnCallback(function (string $key, $default) { 340 ->willReturnCallback(function (string $key, $default) {
310 return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default; 341 if ($key === 'thumbnails.mode') {
342 return Thumbnailer::MODE_ALL;
343 } elseif ($key === 'general.enable_async_metadata') {
344 return false;
345 }
346
347 return $default;
311 }) 348 })
312 ; 349 ;
313 350
@@ -357,7 +394,13 @@ class BookmarkListControllerTest extends TestCase
357 $this->container->conf 394 $this->container->conf
358 ->method('get') 395 ->method('get')
359 ->willReturnCallback(function (string $key, $default) { 396 ->willReturnCallback(function (string $key, $default) {
360 return $key === 'thumbnails.mode' ? Thumbnailer::MODE_ALL : $default; 397 if ($key === 'thumbnails.mode') {
398 return Thumbnailer::MODE_ALL;
399 } elseif ($key === 'general.enable_async_metadata') {
400 return false;
401 }
402
403 return $default;
361 }) 404 })
362 ; 405 ;
363 406
@@ -379,6 +422,47 @@ class BookmarkListControllerTest extends TestCase
379 } 422 }
380 423
381 /** 424 /**
425 * Test getting a permalink with thumbnail update with async setting: no update should run.
426 */
427 public function testThumbnailUpdateFromPermalinkAsync(): void
428 {
429 $request = $this->createMock(Request::class);
430 $response = new Response();
431
432 $this->container->loginManager = $this->createMock(LoginManager::class);
433 $this->container->loginManager->method('isLoggedIn')->willReturn(true);
434
435 $this->container->conf = $this->createMock(ConfigManager::class);
436 $this->container->conf
437 ->method('get')
438 ->willReturnCallback(function (string $key, $default) {
439 if ($key === 'thumbnails.mode') {
440 return Thumbnailer::MODE_ALL;
441 } elseif ($key === 'general.enable_async_metadata') {
442 return true;
443 }
444
445 return $default;
446 })
447 ;
448
449 $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
450 $this->container->thumbnailer->expects(static::never())->method('get');
451
452 $this->container->bookmarkService
453 ->expects(static::once())
454 ->method('findByHash')
455 ->willReturn((new Bookmark())->setId(2)->setUrl('https://url.tld')->setTitle('Title 1'))
456 ;
457 $this->container->bookmarkService->expects(static::never())->method('set');
458 $this->container->bookmarkService->expects(static::never())->method('save');
459
460 $result = $this->controller->permalink($request, $response, ['hash' => 'abc']);
461
462 static::assertSame(200, $result->getStatusCode());
463 }
464
465 /**
382 * Trigger legacy controller in link list controller: permalink 466 * Trigger legacy controller in link list controller: permalink
383 */ 467 */
384 public function testLegacyControllerPermalink(): void 468 public function testLegacyControllerPermalink(): void
diff --git a/tests/front/controller/visitor/DailyControllerTest.php b/tests/front/controller/visitor/DailyControllerTest.php
index fc78bc13..70fbce54 100644
--- a/tests/front/controller/visitor/DailyControllerTest.php
+++ b/tests/front/controller/visitor/DailyControllerTest.php
@@ -28,52 +28,49 @@ class DailyControllerTest extends TestCase
28 public function testValidIndexControllerInvokeDefault(): void 28 public function testValidIndexControllerInvokeDefault(): void
29 { 29 {
30 $currentDay = new \DateTimeImmutable('2020-05-13'); 30 $currentDay = new \DateTimeImmutable('2020-05-13');
31 $previousDate = new \DateTime('2 days ago 00:00:00');
32 $nextDate = new \DateTime('today 00:00:00');
31 33
32 $request = $this->createMock(Request::class); 34 $request = $this->createMock(Request::class);
33 $request->method('getQueryParam')->willReturn($currentDay->format('Ymd')); 35 $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
36 return $key === 'day' ? $currentDay->format('Ymd') : null;
37 });
34 $response = new Response(); 38 $response = new Response();
35 39
36 // Save RainTPL assigned variables 40 // Save RainTPL assigned variables
37 $assignedVariables = []; 41 $assignedVariables = [];
38 $this->assignTemplateVars($assignedVariables); 42 $this->assignTemplateVars($assignedVariables);
39 43
40 // Links dataset: 2 links with thumbnails
41 $this->container->bookmarkService
42 ->expects(static::once())
43 ->method('days')
44 ->willReturnCallback(function () use ($currentDay): array {
45 return [
46 '20200510',
47 $currentDay->format('Ymd'),
48 '20200516',
49 ];
50 })
51 ;
52 $this->container->bookmarkService 44 $this->container->bookmarkService
53 ->expects(static::once()) 45 ->expects(static::once())
54 ->method('filterDay') 46 ->method('findByDate')
55 ->willReturnCallback(function (): array { 47 ->willReturnCallback(
56 return [ 48 function ($from, $to, &$previous, &$next) use ($currentDay, $previousDate, $nextDate): array {
57 (new Bookmark()) 49 $previous = $previousDate;
58 ->setId(1) 50 $next = $nextDate;
59 ->setUrl('http://url.tld') 51
60 ->setTitle(static::generateString(50)) 52 return [
61 ->setDescription(static::generateString(500)) 53 (new Bookmark())
62 , 54 ->setId(1)
63 (new Bookmark()) 55 ->setUrl('http://url.tld')
64 ->setId(2) 56 ->setTitle(static::generateString(50))
65 ->setUrl('http://url2.tld') 57 ->setDescription(static::generateString(500))
66 ->setTitle(static::generateString(50)) 58 ,
67 ->setDescription(static::generateString(500)) 59 (new Bookmark())
68 , 60 ->setId(2)
69 (new Bookmark()) 61 ->setUrl('http://url2.tld')
70 ->setId(3) 62 ->setTitle(static::generateString(50))
71 ->setUrl('http://url3.tld') 63 ->setDescription(static::generateString(500))
72 ->setTitle(static::generateString(50)) 64 ,
73 ->setDescription(static::generateString(500)) 65 (new Bookmark())
74 , 66 ->setId(3)
75 ]; 67 ->setUrl('http://url3.tld')
76 }) 68 ->setTitle(static::generateString(50))
69 ->setDescription(static::generateString(500))
70 ,
71 ];
72 }
73 )
77 ; 74 ;
78 75
79 // Make sure that PluginManager hook is triggered 76 // Make sure that PluginManager hook is triggered
@@ -81,20 +78,22 @@ class DailyControllerTest extends TestCase
81 ->expects(static::atLeastOnce()) 78 ->expects(static::atLeastOnce())
82 ->method('executeHooks') 79 ->method('executeHooks')
83 ->withConsecutive(['render_daily']) 80 ->withConsecutive(['render_daily'])
84 ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array { 81 ->willReturnCallback(
85 if ('render_daily' === $hook) { 82 function (string $hook, array $data, array $param) use ($currentDay, $previousDate, $nextDate): array {
86 static::assertArrayHasKey('linksToDisplay', $data); 83 if ('render_daily' === $hook) {
87 static::assertCount(3, $data['linksToDisplay']); 84 static::assertArrayHasKey('linksToDisplay', $data);
88 static::assertSame(1, $data['linksToDisplay'][0]['id']); 85 static::assertCount(3, $data['linksToDisplay']);
89 static::assertSame($currentDay->getTimestamp(), $data['day']); 86 static::assertSame(1, $data['linksToDisplay'][0]['id']);
90 static::assertSame('20200510', $data['previousday']); 87 static::assertSame($currentDay->getTimestamp(), $data['day']);
91 static::assertSame('20200516', $data['nextday']); 88 static::assertSame($previousDate->format('Ymd'), $data['previousday']);
92 89 static::assertSame($nextDate->format('Ymd'), $data['nextday']);
93 static::assertArrayHasKey('loggedin', $param); 90
91 static::assertArrayHasKey('loggedin', $param);
92 }
93
94 return $data;
94 } 95 }
95 96 )
96 return $data;
97 })
98 ; 97 ;
99 98
100 $result = $this->controller->index($request, $response); 99 $result = $this->controller->index($request, $response);
@@ -107,6 +106,11 @@ class DailyControllerTest extends TestCase
107 ); 106 );
108 static::assertEquals($currentDay, $assignedVariables['dayDate']); 107 static::assertEquals($currentDay, $assignedVariables['dayDate']);
109 static::assertEquals($currentDay->getTimestamp(), $assignedVariables['day']); 108 static::assertEquals($currentDay->getTimestamp(), $assignedVariables['day']);
109 static::assertSame($previousDate->format('Ymd'), $assignedVariables['previousday']);
110 static::assertSame($nextDate->format('Ymd'), $assignedVariables['nextday']);
111 static::assertSame('day', $assignedVariables['type']);
112 static::assertSame('May 13, 2020', $assignedVariables['dayDesc']);
113 static::assertSame('Daily', $assignedVariables['localizedType']);
110 static::assertCount(3, $assignedVariables['linksToDisplay']); 114 static::assertCount(3, $assignedVariables['linksToDisplay']);
111 115
112 $link = $assignedVariables['linksToDisplay'][0]; 116 $link = $assignedVariables['linksToDisplay'][0];
@@ -171,27 +175,20 @@ class DailyControllerTest extends TestCase
171 $currentDay = new \DateTimeImmutable('2020-05-13'); 175 $currentDay = new \DateTimeImmutable('2020-05-13');
172 176
173 $request = $this->createMock(Request::class); 177 $request = $this->createMock(Request::class);
178 $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
179 return $key === 'day' ? $currentDay->format('Ymd') : null;
180 });
174 $response = new Response(); 181 $response = new Response();
175 182
176 // Save RainTPL assigned variables 183 // Save RainTPL assigned variables
177 $assignedVariables = []; 184 $assignedVariables = [];
178 $this->assignTemplateVars($assignedVariables); 185 $this->assignTemplateVars($assignedVariables);
179 186
180 // Links dataset: 2 links with thumbnails
181 $this->container->bookmarkService 187 $this->container->bookmarkService
182 ->expects(static::once()) 188 ->expects(static::once())
183 ->method('days') 189 ->method('findByDate')
184 ->willReturnCallback(function () use ($currentDay): array { 190 ->willReturnCallback(function () use ($currentDay): array {
185 return [ 191 return [
186 $currentDay->format($currentDay->format('Ymd')),
187 ];
188 })
189 ;
190 $this->container->bookmarkService
191 ->expects(static::once())
192 ->method('filterDay')
193 ->willReturnCallback(function (): array {
194 return [
195 (new Bookmark()) 192 (new Bookmark())
196 ->setId(1) 193 ->setId(1)
197 ->setUrl('http://url.tld') 194 ->setUrl('http://url.tld')
@@ -250,21 +247,11 @@ class DailyControllerTest extends TestCase
250 $assignedVariables = []; 247 $assignedVariables = [];
251 $this->assignTemplateVars($assignedVariables); 248 $this->assignTemplateVars($assignedVariables);
252 249
253 // Links dataset: 2 links with thumbnails
254 $this->container->bookmarkService 250 $this->container->bookmarkService
255 ->expects(static::once()) 251 ->expects(static::once())
256 ->method('days') 252 ->method('findByDate')
257 ->willReturnCallback(function () use ($currentDay): array { 253 ->willReturnCallback(function () use ($currentDay): array {
258 return [ 254 return [
259 $currentDay->format($currentDay->format('Ymd')),
260 ];
261 })
262 ;
263 $this->container->bookmarkService
264 ->expects(static::once())
265 ->method('filterDay')
266 ->willReturnCallback(function (): array {
267 return [
268 (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'), 255 (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'),
269 (new Bookmark()) 256 (new Bookmark())
270 ->setId(2) 257 ->setId(2)
@@ -320,14 +307,7 @@ class DailyControllerTest extends TestCase
320 // Links dataset: 2 links with thumbnails 307 // Links dataset: 2 links with thumbnails
321 $this->container->bookmarkService 308 $this->container->bookmarkService
322 ->expects(static::once()) 309 ->expects(static::once())
323 ->method('days') 310 ->method('findByDate')
324 ->willReturnCallback(function (): array {
325 return [];
326 })
327 ;
328 $this->container->bookmarkService
329 ->expects(static::once())
330 ->method('filterDay')
331 ->willReturnCallback(function (): array { 311 ->willReturnCallback(function (): array {
332 return []; 312 return [];
333 }) 313 })
@@ -347,7 +327,7 @@ class DailyControllerTest extends TestCase
347 static::assertSame(200, $result->getStatusCode()); 327 static::assertSame(200, $result->getStatusCode());
348 static::assertSame('daily', (string) $result->getBody()); 328 static::assertSame('daily', (string) $result->getBody());
349 static::assertCount(0, $assignedVariables['linksToDisplay']); 329 static::assertCount(0, $assignedVariables['linksToDisplay']);
350 static::assertSame('Today', $assignedVariables['dayDesc']); 330 static::assertSame('Today - ' . (new \DateTime())->format('F j, Y'), $assignedVariables['dayDesc']);
351 static::assertEquals((new \DateTime())->setTime(0, 0)->getTimestamp(), $assignedVariables['day']); 331 static::assertEquals((new \DateTime())->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
352 static::assertEquals((new \DateTime())->setTime(0, 0), $assignedVariables['dayDate']); 332 static::assertEquals((new \DateTime())->setTime(0, 0), $assignedVariables['dayDate']);
353 } 333 }
@@ -361,6 +341,7 @@ class DailyControllerTest extends TestCase
361 new \DateTimeImmutable('2020-05-17'), 341 new \DateTimeImmutable('2020-05-17'),
362 new \DateTimeImmutable('2020-05-15'), 342 new \DateTimeImmutable('2020-05-15'),
363 new \DateTimeImmutable('2020-05-13'), 343 new \DateTimeImmutable('2020-05-13'),
344 new \DateTimeImmutable('+1 month'),
364 ]; 345 ];
365 346
366 $request = $this->createMock(Request::class); 347 $request = $this->createMock(Request::class);
@@ -371,6 +352,7 @@ class DailyControllerTest extends TestCase
371 (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'), 352 (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
372 (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'), 353 (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
373 (new Bookmark())->setId(4)->setCreated($dates[2])->setUrl('http://domain.tld/4'), 354 (new Bookmark())->setId(4)->setCreated($dates[2])->setUrl('http://domain.tld/4'),
355 (new Bookmark())->setId(5)->setCreated($dates[3])->setUrl('http://domain.tld/5'),
374 ]); 356 ]);
375 357
376 $this->container->pageCacheManager 358 $this->container->pageCacheManager
@@ -397,13 +379,14 @@ class DailyControllerTest extends TestCase
397 static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']); 379 static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
398 static::assertSame('http://shaarli/subfolder/daily-rss', $assignedVariables['page_url']); 380 static::assertSame('http://shaarli/subfolder/daily-rss', $assignedVariables['page_url']);
399 static::assertFalse($assignedVariables['hide_timestamps']); 381 static::assertFalse($assignedVariables['hide_timestamps']);
400 static::assertCount(2, $assignedVariables['days']); 382 static::assertCount(3, $assignedVariables['days']);
401 383
402 $day = $assignedVariables['days'][$dates[0]->format('Ymd')]; 384 $day = $assignedVariables['days'][$dates[0]->format('Ymd')];
385 $date = $dates[0]->setTime(23, 59, 59);
403 386
404 static::assertEquals($dates[0], $day['date']); 387 static::assertEquals($date, $day['date']);
405 static::assertSame($dates[0]->format(\DateTime::RSS), $day['date_rss']); 388 static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
406 static::assertSame(format_date($dates[0], false), $day['date_human']); 389 static::assertSame(format_date($date, false), $day['date_human']);
407 static::assertSame('http://shaarli/subfolder/daily?day='. $dates[0]->format('Ymd'), $day['absolute_url']); 390 static::assertSame('http://shaarli/subfolder/daily?day='. $dates[0]->format('Ymd'), $day['absolute_url']);
408 static::assertCount(1, $day['links']); 391 static::assertCount(1, $day['links']);
409 static::assertSame(1, $day['links'][0]['id']); 392 static::assertSame(1, $day['links'][0]['id']);
@@ -411,10 +394,11 @@ class DailyControllerTest extends TestCase
411 static::assertEquals($dates[0], $day['links'][0]['created']); 394 static::assertEquals($dates[0], $day['links'][0]['created']);
412 395
413 $day = $assignedVariables['days'][$dates[1]->format('Ymd')]; 396 $day = $assignedVariables['days'][$dates[1]->format('Ymd')];
397 $date = $dates[1]->setTime(23, 59, 59);
414 398
415 static::assertEquals($dates[1], $day['date']); 399 static::assertEquals($date, $day['date']);
416 static::assertSame($dates[1]->format(\DateTime::RSS), $day['date_rss']); 400 static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
417 static::assertSame(format_date($dates[1], false), $day['date_human']); 401 static::assertSame(format_date($date, false), $day['date_human']);
418 static::assertSame('http://shaarli/subfolder/daily?day='. $dates[1]->format('Ymd'), $day['absolute_url']); 402 static::assertSame('http://shaarli/subfolder/daily?day='. $dates[1]->format('Ymd'), $day['absolute_url']);
419 static::assertCount(2, $day['links']); 403 static::assertCount(2, $day['links']);
420 404
@@ -424,6 +408,18 @@ class DailyControllerTest extends TestCase
424 static::assertSame(3, $day['links'][1]['id']); 408 static::assertSame(3, $day['links'][1]['id']);
425 static::assertSame('http://domain.tld/3', $day['links'][1]['url']); 409 static::assertSame('http://domain.tld/3', $day['links'][1]['url']);
426 static::assertEquals($dates[1], $day['links'][1]['created']); 410 static::assertEquals($dates[1], $day['links'][1]['created']);
411
412 $day = $assignedVariables['days'][$dates[2]->format('Ymd')];
413 $date = $dates[2]->setTime(23, 59, 59);
414
415 static::assertEquals($date, $day['date']);
416 static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
417 static::assertSame(format_date($date, false), $day['date_human']);
418 static::assertSame('http://shaarli/subfolder/daily?day='. $dates[2]->format('Ymd'), $day['absolute_url']);
419 static::assertCount(1, $day['links']);
420 static::assertSame(4, $day['links'][0]['id']);
421 static::assertSame('http://domain.tld/4', $day['links'][0]['url']);
422 static::assertEquals($dates[2], $day['links'][0]['created']);
427 } 423 }
428 424
429 /** 425 /**
@@ -475,4 +471,246 @@ class DailyControllerTest extends TestCase
475 static::assertFalse($assignedVariables['hide_timestamps']); 471 static::assertFalse($assignedVariables['hide_timestamps']);
476 static::assertCount(0, $assignedVariables['days']); 472 static::assertCount(0, $assignedVariables['days']);
477 } 473 }
474
475 /**
476 * Test simple display index with week parameter
477 */
478 public function testSimpleIndexWeekly(): void
479 {
480 $currentDay = new \DateTimeImmutable('2020-05-13');
481 $expectedDay = new \DateTimeImmutable('2020-05-11');
482
483 $request = $this->createMock(Request::class);
484 $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
485 return $key === 'week' ? $currentDay->format('YW') : null;
486 });
487 $response = new Response();
488
489 // Save RainTPL assigned variables
490 $assignedVariables = [];
491 $this->assignTemplateVars($assignedVariables);
492
493 $this->container->bookmarkService
494 ->expects(static::once())
495 ->method('findByDate')
496 ->willReturnCallback(
497 function (): array {
498 return [
499 (new Bookmark())
500 ->setId(1)
501 ->setUrl('http://url.tld')
502 ->setTitle(static::generateString(50))
503 ->setDescription(static::generateString(500))
504 ,
505 (new Bookmark())
506 ->setId(2)
507 ->setUrl('http://url2.tld')
508 ->setTitle(static::generateString(50))
509 ->setDescription(static::generateString(500))
510 ,
511 ];
512 }
513 )
514 ;
515
516 $result = $this->controller->index($request, $response);
517
518 static::assertSame(200, $result->getStatusCode());
519 static::assertSame('daily', (string) $result->getBody());
520 static::assertSame(
521 'Weekly - Week 20 (May 11, 2020) - Shaarli',
522 $assignedVariables['pagetitle']
523 );
524
525 static::assertCount(2, $assignedVariables['linksToDisplay']);
526 static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
527 static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
528 static::assertSame('', $assignedVariables['previousday']);
529 static::assertSame('', $assignedVariables['nextday']);
530 static::assertSame('Week 20 (May 11, 2020)', $assignedVariables['dayDesc']);
531 static::assertSame('week', $assignedVariables['type']);
532 static::assertSame('Weekly', $assignedVariables['localizedType']);
533 }
534
535 /**
536 * Test simple display index with month parameter
537 */
538 public function testSimpleIndexMonthly(): void
539 {
540 $currentDay = new \DateTimeImmutable('2020-05-13');
541 $expectedDay = new \DateTimeImmutable('2020-05-01');
542
543 $request = $this->createMock(Request::class);
544 $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
545 return $key === 'month' ? $currentDay->format('Ym') : null;
546 });
547 $response = new Response();
548
549 // Save RainTPL assigned variables
550 $assignedVariables = [];
551 $this->assignTemplateVars($assignedVariables);
552
553 $this->container->bookmarkService
554 ->expects(static::once())
555 ->method('findByDate')
556 ->willReturnCallback(
557 function (): array {
558 return [
559 (new Bookmark())
560 ->setId(1)
561 ->setUrl('http://url.tld')
562 ->setTitle(static::generateString(50))
563 ->setDescription(static::generateString(500))
564 ,
565 (new Bookmark())
566 ->setId(2)
567 ->setUrl('http://url2.tld')
568 ->setTitle(static::generateString(50))
569 ->setDescription(static::generateString(500))
570 ,
571 ];
572 }
573 )
574 ;
575
576 $result = $this->controller->index($request, $response);
577
578 static::assertSame(200, $result->getStatusCode());
579 static::assertSame('daily', (string) $result->getBody());
580 static::assertSame(
581 'Monthly - May, 2020 - Shaarli',
582 $assignedVariables['pagetitle']
583 );
584
585 static::assertCount(2, $assignedVariables['linksToDisplay']);
586 static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
587 static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
588 static::assertSame('', $assignedVariables['previousday']);
589 static::assertSame('', $assignedVariables['nextday']);
590 static::assertSame('May, 2020', $assignedVariables['dayDesc']);
591 static::assertSame('month', $assignedVariables['type']);
592 static::assertSame('Monthly', $assignedVariables['localizedType']);
593 }
594
595 /**
596 * Test simple display RSS with week parameter
597 */
598 public function testSimpleRssWeekly(): void
599 {
600 $dates = [
601 new \DateTimeImmutable('2020-05-19'),
602 new \DateTimeImmutable('2020-05-13'),
603 ];
604 $expectedDates = [
605 new \DateTimeImmutable('2020-05-24 23:59:59'),
606 new \DateTimeImmutable('2020-05-17 23:59:59'),
607 ];
608
609 $this->container->environment['QUERY_STRING'] = 'week';
610 $request = $this->createMock(Request::class);
611 $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
612 return $key === 'week' ? '' : null;
613 });
614 $response = new Response();
615
616 $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
617 (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
618 (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
619 (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
620 ]);
621
622 // Save RainTPL assigned variables
623 $assignedVariables = [];
624 $this->assignTemplateVars($assignedVariables);
625
626 $result = $this->controller->rss($request, $response);
627
628 static::assertSame(200, $result->getStatusCode());
629 static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
630 static::assertSame('dailyrss', (string) $result->getBody());
631 static::assertSame('Shaarli', $assignedVariables['title']);
632 static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
633 static::assertSame('http://shaarli/subfolder/daily-rss?week', $assignedVariables['page_url']);
634 static::assertFalse($assignedVariables['hide_timestamps']);
635 static::assertCount(2, $assignedVariables['days']);
636
637 $day = $assignedVariables['days'][$dates[0]->format('YW')];
638 $date = $expectedDates[0];
639
640 static::assertEquals($date, $day['date']);
641 static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
642 static::assertSame('Week 21 (May 18, 2020)', $day['date_human']);
643 static::assertSame('http://shaarli/subfolder/daily?week='. $dates[0]->format('YW'), $day['absolute_url']);
644 static::assertCount(1, $day['links']);
645
646 $day = $assignedVariables['days'][$dates[1]->format('YW')];
647 $date = $expectedDates[1];
648
649 static::assertEquals($date, $day['date']);
650 static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
651 static::assertSame('Week 20 (May 11, 2020)', $day['date_human']);
652 static::assertSame('http://shaarli/subfolder/daily?week='. $dates[1]->format('YW'), $day['absolute_url']);
653 static::assertCount(2, $day['links']);
654 }
655
656 /**
657 * Test simple display RSS with month parameter
658 */
659 public function testSimpleRssMonthly(): void
660 {
661 $dates = [
662 new \DateTimeImmutable('2020-05-19'),
663 new \DateTimeImmutable('2020-04-13'),
664 ];
665 $expectedDates = [
666 new \DateTimeImmutable('2020-05-31 23:59:59'),
667 new \DateTimeImmutable('2020-04-30 23:59:59'),
668 ];
669
670 $this->container->environment['QUERY_STRING'] = 'month';
671 $request = $this->createMock(Request::class);
672 $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
673 return $key === 'month' ? '' : null;
674 });
675 $response = new Response();
676
677 $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
678 (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
679 (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
680 (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
681 ]);
682
683 // Save RainTPL assigned variables
684 $assignedVariables = [];
685 $this->assignTemplateVars($assignedVariables);
686
687 $result = $this->controller->rss($request, $response);
688
689 static::assertSame(200, $result->getStatusCode());
690 static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
691 static::assertSame('dailyrss', (string) $result->getBody());
692 static::assertSame('Shaarli', $assignedVariables['title']);
693 static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
694 static::assertSame('http://shaarli/subfolder/daily-rss?month', $assignedVariables['page_url']);
695 static::assertFalse($assignedVariables['hide_timestamps']);
696 static::assertCount(2, $assignedVariables['days']);
697
698 $day = $assignedVariables['days'][$dates[0]->format('Ym')];
699 $date = $expectedDates[0];
700
701 static::assertEquals($date, $day['date']);
702 static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
703 static::assertSame('May, 2020', $day['date_human']);
704 static::assertSame('http://shaarli/subfolder/daily?month='. $dates[0]->format('Ym'), $day['absolute_url']);
705 static::assertCount(1, $day['links']);
706
707 $day = $assignedVariables['days'][$dates[1]->format('Ym')];
708 $date = $expectedDates[1];
709
710 static::assertEquals($date, $day['date']);
711 static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
712 static::assertSame('April, 2020', $day['date_human']);
713 static::assertSame('http://shaarli/subfolder/daily?month='. $dates[1]->format('Ym'), $day['absolute_url']);
714 static::assertCount(2, $day['links']);
715 }
478} 716}
diff --git a/tests/front/controller/visitor/ErrorControllerTest.php b/tests/front/controller/visitor/ErrorControllerTest.php
index 75408cf4..e18a6fa2 100644
--- a/tests/front/controller/visitor/ErrorControllerTest.php
+++ b/tests/front/controller/visitor/ErrorControllerTest.php
@@ -50,7 +50,31 @@ class ErrorControllerTest extends TestCase
50 } 50 }
51 51
52 /** 52 /**
53 * Test displaying error with any exception (no debug): only display an error occurred with HTTP 500. 53 * Test displaying error with any exception (no debug) while logged in:
54 * display full error details
55 */
56 public function testDisplayAnyExceptionErrorNoDebugLoggedIn(): void
57 {
58 $request = $this->createMock(Request::class);
59 $response = new Response();
60
61 // Save RainTPL assigned variables
62 $assignedVariables = [];
63 $this->assignTemplateVars($assignedVariables);
64
65 $this->container->loginManager->method('isLoggedIn')->willReturn(true);
66
67 $result = ($this->controller)($request, $response, new \Exception('abc'));
68
69 static::assertSame(500, $result->getStatusCode());
70 static::assertSame('Error: abc', $assignedVariables['message']);
71 static::assertContainsPolyfill('Please report it on Github', $assignedVariables['text']);
72 static::assertArrayHasKey('stacktrace', $assignedVariables);
73 }
74
75 /**
76 * Test displaying error with any exception (no debug) while logged out:
77 * display standard error without detail
54 */ 78 */
55 public function testDisplayAnyExceptionErrorNoDebug(): void 79 public function testDisplayAnyExceptionErrorNoDebug(): void
56 { 80 {
@@ -61,10 +85,13 @@ class ErrorControllerTest extends TestCase
61 $assignedVariables = []; 85 $assignedVariables = [];
62 $this->assignTemplateVars($assignedVariables); 86 $this->assignTemplateVars($assignedVariables);
63 87
88 $this->container->loginManager->method('isLoggedIn')->willReturn(false);
89
64 $result = ($this->controller)($request, $response, new \Exception('abc')); 90 $result = ($this->controller)($request, $response, new \Exception('abc'));
65 91
66 static::assertSame(500, $result->getStatusCode()); 92 static::assertSame(500, $result->getStatusCode());
67 static::assertSame('An unexpected error occurred.', $assignedVariables['message']); 93 static::assertSame('An unexpected error occurred.', $assignedVariables['message']);
94 static::assertArrayNotHasKey('text', $assignedVariables);
68 static::assertArrayNotHasKey('stacktrace', $assignedVariables); 95 static::assertArrayNotHasKey('stacktrace', $assignedVariables);
69 } 96 }
70} 97}
diff --git a/tests/front/controller/visitor/FrontControllerMockHelper.php b/tests/front/controller/visitor/FrontControllerMockHelper.php
index fc0bb7d1..02229f68 100644
--- a/tests/front/controller/visitor/FrontControllerMockHelper.php
+++ b/tests/front/controller/visitor/FrontControllerMockHelper.php
@@ -41,6 +41,10 @@ trait FrontControllerMockHelper
41 // Config 41 // Config
42 $this->container->conf = $this->createMock(ConfigManager::class); 42 $this->container->conf = $this->createMock(ConfigManager::class);
43 $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) { 43 $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
44 if ($parameter === 'general.tags_separator') {
45 return '@';
46 }
47
44 return $default === null ? $parameter : $default; 48 return $default === null ? $parameter : $default;
45 }); 49 });
46 50
diff --git a/tests/front/controller/visitor/InstallControllerTest.php b/tests/front/controller/visitor/InstallControllerTest.php
index 345ad544..2105ed77 100644
--- a/tests/front/controller/visitor/InstallControllerTest.php
+++ b/tests/front/controller/visitor/InstallControllerTest.php
@@ -79,6 +79,15 @@ class InstallControllerTest extends TestCase
79 static::assertIsArray($assignedVariables['languages']); 79 static::assertIsArray($assignedVariables['languages']);
80 static::assertSame('Automatic', $assignedVariables['languages']['auto']); 80 static::assertSame('Automatic', $assignedVariables['languages']['auto']);
81 static::assertSame('French', $assignedVariables['languages']['fr']); 81 static::assertSame('French', $assignedVariables['languages']['fr']);
82
83 static::assertSame(PHP_VERSION, $assignedVariables['php_version']);
84 static::assertArrayHasKey('php_has_reached_eol', $assignedVariables);
85 static::assertArrayHasKey('php_eol', $assignedVariables);
86 static::assertArrayHasKey('php_extensions', $assignedVariables);
87 static::assertArrayHasKey('permissions', $assignedVariables);
88 static::assertEmpty($assignedVariables['permissions']);
89
90 static::assertSame('Install Shaarli', $assignedVariables['pagetitle']);
82 } 91 }
83 92
84 /** 93 /**
diff --git a/tests/front/controller/visitor/LoginControllerTest.php b/tests/front/controller/visitor/LoginControllerTest.php
index 1312ccb7..00d9eab3 100644
--- a/tests/front/controller/visitor/LoginControllerTest.php
+++ b/tests/front/controller/visitor/LoginControllerTest.php
@@ -195,7 +195,7 @@ class LoginControllerTest extends TestCase
195 $this->container->loginManager 195 $this->container->loginManager
196 ->expects(static::once()) 196 ->expects(static::once())
197 ->method('checkCredentials') 197 ->method('checkCredentials')
198 ->with('1.2.3.4', '1.2.3.4', 'bob', 'pass') 198 ->with('1.2.3.4', 'bob', 'pass')
199 ->willReturn(true) 199 ->willReturn(true)
200 ; 200 ;
201 $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8))); 201 $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
diff --git a/tests/front/controller/visitor/TagCloudControllerTest.php b/tests/front/controller/visitor/TagCloudControllerTest.php
index 9305612e..4915573d 100644
--- a/tests/front/controller/visitor/TagCloudControllerTest.php
+++ b/tests/front/controller/visitor/TagCloudControllerTest.php
@@ -100,7 +100,7 @@ class TagCloudControllerTest extends TestCase
100 ->with() 100 ->with()
101 ->willReturnCallback(function (string $key): ?string { 101 ->willReturnCallback(function (string $key): ?string {
102 if ('searchtags' === $key) { 102 if ('searchtags' === $key) {
103 return 'ghi def'; 103 return 'ghi@def';
104 } 104 }
105 105
106 return null; 106 return null;
@@ -131,7 +131,7 @@ class TagCloudControllerTest extends TestCase
131 ->withConsecutive(['render_tagcloud']) 131 ->withConsecutive(['render_tagcloud'])
132 ->willReturnCallback(function (string $hook, array $data, array $param): array { 132 ->willReturnCallback(function (string $hook, array $data, array $param): array {
133 if ('render_tagcloud' === $hook) { 133 if ('render_tagcloud' === $hook) {
134 static::assertSame('ghi def', $data['search_tags']); 134 static::assertSame('ghi@def@', $data['search_tags']);
135 static::assertCount(1, $data['tags']); 135 static::assertCount(1, $data['tags']);
136 136
137 static::assertArrayHasKey('loggedin', $param); 137 static::assertArrayHasKey('loggedin', $param);
@@ -147,7 +147,7 @@ class TagCloudControllerTest extends TestCase
147 static::assertSame('tag.cloud', (string) $result->getBody()); 147 static::assertSame('tag.cloud', (string) $result->getBody());
148 static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']); 148 static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']);
149 149
150 static::assertSame('ghi def', $assignedVariables['search_tags']); 150 static::assertSame('ghi@def@', $assignedVariables['search_tags']);
151 static::assertCount(1, $assignedVariables['tags']); 151 static::assertCount(1, $assignedVariables['tags']);
152 152
153 static::assertArrayHasKey('abc', $assignedVariables['tags']); 153 static::assertArrayHasKey('abc', $assignedVariables['tags']);
@@ -277,7 +277,7 @@ class TagCloudControllerTest extends TestCase
277 ->with() 277 ->with()
278 ->willReturnCallback(function (string $key): ?string { 278 ->willReturnCallback(function (string $key): ?string {
279 if ('searchtags' === $key) { 279 if ('searchtags' === $key) {
280 return 'ghi def'; 280 return 'ghi@def';
281 } elseif ('sort' === $key) { 281 } elseif ('sort' === $key) {
282 return 'alpha'; 282 return 'alpha';
283 } 283 }
@@ -310,7 +310,7 @@ class TagCloudControllerTest extends TestCase
310 ->withConsecutive(['render_taglist']) 310 ->withConsecutive(['render_taglist'])
311 ->willReturnCallback(function (string $hook, array $data, array $param): array { 311 ->willReturnCallback(function (string $hook, array $data, array $param): array {
312 if ('render_taglist' === $hook) { 312 if ('render_taglist' === $hook) {
313 static::assertSame('ghi def', $data['search_tags']); 313 static::assertSame('ghi@def@', $data['search_tags']);
314 static::assertCount(1, $data['tags']); 314 static::assertCount(1, $data['tags']);
315 315
316 static::assertArrayHasKey('loggedin', $param); 316 static::assertArrayHasKey('loggedin', $param);
@@ -326,7 +326,7 @@ class TagCloudControllerTest extends TestCase
326 static::assertSame('tag.list', (string) $result->getBody()); 326 static::assertSame('tag.list', (string) $result->getBody());
327 static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']); 327 static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']);
328 328
329 static::assertSame('ghi def', $assignedVariables['search_tags']); 329 static::assertSame('ghi@def@', $assignedVariables['search_tags']);
330 static::assertCount(1, $assignedVariables['tags']); 330 static::assertCount(1, $assignedVariables['tags']);
331 static::assertSame(3, $assignedVariables['tags']['abc']); 331 static::assertSame(3, $assignedVariables['tags']['abc']);
332 } 332 }
diff --git a/tests/front/controller/visitor/TagControllerTest.php b/tests/front/controller/visitor/TagControllerTest.php
index 750ea02d..5a556c6d 100644
--- a/tests/front/controller/visitor/TagControllerTest.php
+++ b/tests/front/controller/visitor/TagControllerTest.php
@@ -50,7 +50,7 @@ class TagControllerTest extends TestCase
50 50
51 static::assertInstanceOf(Response::class, $result); 51 static::assertInstanceOf(Response::class, $result);
52 static::assertSame(302, $result->getStatusCode()); 52 static::assertSame(302, $result->getStatusCode());
53 static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); 53 static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
54 } 54 }
55 55
56 public function testAddTagWithoutRefererAndExistingSearch(): void 56 public function testAddTagWithoutRefererAndExistingSearch(): void
@@ -80,7 +80,7 @@ class TagControllerTest extends TestCase
80 80
81 static::assertInstanceOf(Response::class, $result); 81 static::assertInstanceOf(Response::class, $result);
82 static::assertSame(302, $result->getStatusCode()); 82 static::assertSame(302, $result->getStatusCode());
83 static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); 83 static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
84 } 84 }
85 85
86 public function testAddTagResetPagination(): void 86 public function testAddTagResetPagination(): void
@@ -96,7 +96,7 @@ class TagControllerTest extends TestCase
96 96
97 static::assertInstanceOf(Response::class, $result); 97 static::assertInstanceOf(Response::class, $result);
98 static::assertSame(302, $result->getStatusCode()); 98 static::assertSame(302, $result->getStatusCode());
99 static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); 99 static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
100 } 100 }
101 101
102 public function testAddTagWithRefererAndEmptySearch(): void 102 public function testAddTagWithRefererAndEmptySearch(): void
diff --git a/tests/ApplicationUtilsTest.php b/tests/helper/ApplicationUtilsTest.php
index a232b351..654857b9 100644
--- a/tests/ApplicationUtilsTest.php
+++ b/tests/helper/ApplicationUtilsTest.php
@@ -1,7 +1,8 @@
1<?php 1<?php
2namespace Shaarli; 2namespace Shaarli\Helper;
3 3
4use Shaarli\Config\ConfigManager; 4use Shaarli\Config\ConfigManager;
5use Shaarli\FakeApplicationUtils;
5 6
6require_once 'tests/utils/FakeApplicationUtils.php'; 7require_once 'tests/utils/FakeApplicationUtils.php';
7 8
@@ -340,6 +341,35 @@ class ApplicationUtilsTest extends \Shaarli\TestCase
340 } 341 }
341 342
342 /** 343 /**
344 * Checks resource permissions in minimal mode.
345 */
346 public function testCheckCurrentResourcePermissionsErrorsMinimalMode(): void
347 {
348 $conf = new ConfigManager('');
349 $conf->set('resource.thumbnails_cache', 'null/cache');
350 $conf->set('resource.config', 'null/data/config.php');
351 $conf->set('resource.data_dir', 'null/data');
352 $conf->set('resource.datastore', 'null/data/store.php');
353 $conf->set('resource.ban_file', 'null/data/ipbans.php');
354 $conf->set('resource.log', 'null/data/log.txt');
355 $conf->set('resource.page_cache', 'null/pagecache');
356 $conf->set('resource.raintpl_tmp', 'null/tmp');
357 $conf->set('resource.raintpl_tpl', 'null/tpl');
358 $conf->set('resource.raintpl_theme', 'null/tpl/default');
359 $conf->set('resource.update_check', 'null/data/lastupdatecheck.txt');
360
361 static::assertSame(
362 [
363 '"null/tpl" directory is not readable',
364 '"null/tpl/default" directory is not readable',
365 '"null/tmp" directory is not readable',
366 '"null/tmp" directory is not writable'
367 ],
368 ApplicationUtils::checkResourcePermissions($conf, true)
369 );
370 }
371
372 /**
343 * Check update with 'dev' as curent version (master branch). 373 * Check update with 'dev' as curent version (master branch).
344 * It should always return false. 374 * It should always return false.
345 */ 375 */
@@ -349,4 +379,37 @@ class ApplicationUtilsTest extends \Shaarli\TestCase
349 ApplicationUtils::checkUpdate('dev', self::$testUpdateFile, 100, true, true) 379 ApplicationUtils::checkUpdate('dev', self::$testUpdateFile, 100, true, true)
350 ); 380 );
351 } 381 }
382
383 /**
384 * Basic test of getPhpExtensionsRequirement()
385 */
386 public function testGetPhpExtensionsRequirementSimple(): void
387 {
388 static::assertCount(8, ApplicationUtils::getPhpExtensionsRequirement());
389 static::assertSame([
390 'name' => 'json',
391 'required' => true,
392 'desc' => 'Configuration parsing',
393 'loaded' => true,
394 ], ApplicationUtils::getPhpExtensionsRequirement()[0]);
395 }
396
397 /**
398 * Test getPhpEol with a known version: 7.4 -> 2022
399 */
400 public function testGetKnownPhpEol(): void
401 {
402 static::assertSame('2022-11-28', ApplicationUtils::getPhpEol('7.4.7'));
403 }
404
405 /**
406 * Test getPhpEol with an unknown version: 7.4 -> 2022
407 */
408 public function testGetUnknownPhpEol(): void
409 {
410 static::assertSame(
411 (((int) (new \DateTime())->format('Y')) + 2) . (new \DateTime())->format('-m-d'),
412 ApplicationUtils::getPhpEol('7.51.34')
413 );
414 }
352} 415}
diff --git a/tests/helper/DailyPageHelperTest.php b/tests/helper/DailyPageHelperTest.php
new file mode 100644
index 00000000..2d745800
--- /dev/null
+++ b/tests/helper/DailyPageHelperTest.php
@@ -0,0 +1,341 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Helper;
6
7use DateTimeImmutable;
8use DateTimeInterface;
9use Shaarli\Bookmark\Bookmark;
10use Shaarli\TestCase;
11use Slim\Http\Request;
12
13class DailyPageHelperTest extends TestCase
14{
15 /**
16 * @dataProvider getRequestedTypes
17 */
18 public function testExtractRequestedType(array $queryParams, string $expectedType): void
19 {
20 $request = $this->createMock(Request::class);
21 $request->method('getQueryParam')->willReturnCallback(function ($key) use ($queryParams): ?string {
22 return $queryParams[$key] ?? null;
23 });
24
25 $type = DailyPageHelper::extractRequestedType($request);
26
27 static::assertSame($type, $expectedType);
28 }
29
30 /**
31 * @dataProvider getRequestedDateTimes
32 */
33 public function testExtractRequestedDateTime(
34 string $type,
35 string $input,
36 ?Bookmark $bookmark,
37 DateTimeInterface $expectedDateTime,
38 string $compareFormat = 'Ymd'
39 ): void {
40 $dateTime = DailyPageHelper::extractRequestedDateTime($type, $input, $bookmark);
41
42 static::assertSame($dateTime->format($compareFormat), $expectedDateTime->format($compareFormat));
43 }
44
45 public function testExtractRequestedDateTimeExceptionUnknownType(): void
46 {
47 $this->expectException(\Exception::class);
48 $this->expectExceptionMessage('Unsupported daily format type');
49
50 DailyPageHelper::extractRequestedDateTime('nope', null, null);
51 }
52
53 /**
54 * @dataProvider getFormatsByType
55 */
56 public function testGetFormatByType(string $type, string $expectedFormat): void
57 {
58 $format = DailyPageHelper::getFormatByType($type);
59
60 static::assertSame($expectedFormat, $format);
61 }
62
63 public function testGetFormatByTypeExceptionUnknownType(): void
64 {
65 $this->expectException(\Exception::class);
66 $this->expectExceptionMessage('Unsupported daily format type');
67
68 DailyPageHelper::getFormatByType('nope');
69 }
70
71 /**
72 * @dataProvider getStartDatesByType
73 */
74 public function testGetStartDatesByType(
75 string $type,
76 DateTimeImmutable $dateTime,
77 DateTimeInterface $expectedDateTime
78 ): void {
79 $startDateTime = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
80
81 static::assertEquals($expectedDateTime, $startDateTime);
82 }
83
84 public function testGetStartDatesByTypeExceptionUnknownType(): void
85 {
86 $this->expectException(\Exception::class);
87 $this->expectExceptionMessage('Unsupported daily format type');
88
89 DailyPageHelper::getStartDateTimeByType('nope', new DateTimeImmutable());
90 }
91
92 /**
93 * @dataProvider getEndDatesByType
94 */
95 public function testGetEndDatesByType(
96 string $type,
97 DateTimeImmutable $dateTime,
98 DateTimeInterface $expectedDateTime
99 ): void {
100 $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
101
102 static::assertEquals($expectedDateTime, $endDateTime);
103 }
104
105 public function testGetEndDatesByTypeExceptionUnknownType(): void
106 {
107 $this->expectException(\Exception::class);
108 $this->expectExceptionMessage('Unsupported daily format type');
109
110 DailyPageHelper::getEndDateTimeByType('nope', new DateTimeImmutable());
111 }
112
113 /**
114 * @dataProvider getDescriptionsByType
115 */
116 public function testGeDescriptionsByType(
117 string $type,
118 DateTimeImmutable $dateTime,
119 string $expectedDescription
120 ): void {
121 $description = DailyPageHelper::getDescriptionByType($type, $dateTime);
122
123 static::assertEquals($expectedDescription, $description);
124 }
125
126 /**
127 * @dataProvider getDescriptionsByTypeNotIncludeRelative
128 */
129 public function testGeDescriptionsByTypeNotIncludeRelative(
130 string $type,
131 \DateTimeImmutable $dateTime,
132 string $expectedDescription
133 ): void {
134 $description = DailyPageHelper::getDescriptionByType($type, $dateTime, false);
135
136 static::assertEquals($expectedDescription, $description);
137 }
138
139 public function getDescriptionByTypeExceptionUnknownType(): void
140 {
141 $this->expectException(\Exception::class);
142 $this->expectExceptionMessage('Unsupported daily format type');
143
144 DailyPageHelper::getDescriptionByType('nope', new DateTimeImmutable());
145 }
146
147 /**
148 * @dataProvider getRssLengthsByType
149 */
150 public function testGeRssLengthsByType(string $type): void {
151 $length = DailyPageHelper::getRssLengthByType($type);
152
153 static::assertIsInt($length);
154 }
155
156 public function testGeRssLengthsByTypeExceptionUnknownType(): void
157 {
158 $this->expectException(\Exception::class);
159 $this->expectExceptionMessage('Unsupported daily format type');
160
161 DailyPageHelper::getRssLengthByType('nope');
162 }
163
164 /**
165 * @dataProvider getCacheDatePeriodByType
166 */
167 public function testGetCacheDatePeriodByType(
168 string $type,
169 DateTimeImmutable $requested,
170 DateTimeInterface $start,
171 DateTimeInterface $end
172 ): void {
173 $period = DailyPageHelper::getCacheDatePeriodByType($type, $requested);
174
175 static::assertEquals($start, $period->getStartDate());
176 static::assertEquals($end, $period->getEndDate());
177 }
178
179 public function testGetCacheDatePeriodByTypeExceptionUnknownType(): void
180 {
181 $this->expectException(\Exception::class);
182 $this->expectExceptionMessage('Unsupported daily format type');
183
184 DailyPageHelper::getCacheDatePeriodByType('nope');
185 }
186
187 /**
188 * Data provider for testExtractRequestedType() test method.
189 */
190 public function getRequestedTypes(): array
191 {
192 return [
193 [['month' => null], DailyPageHelper::DAY],
194 [['month' => ''], DailyPageHelper::MONTH],
195 [['month' => 'content'], DailyPageHelper::MONTH],
196 [['week' => null], DailyPageHelper::DAY],
197 [['week' => ''], DailyPageHelper::WEEK],
198 [['week' => 'content'], DailyPageHelper::WEEK],
199 [['day' => null], DailyPageHelper::DAY],
200 [['day' => ''], DailyPageHelper::DAY],
201 [['day' => 'content'], DailyPageHelper::DAY],
202 ];
203 }
204
205 /**
206 * Data provider for testExtractRequestedDateTime() test method.
207 */
208 public function getRequestedDateTimes(): array
209 {
210 return [
211 [DailyPageHelper::DAY, '20201013', null, new \DateTime('2020-10-13')],
212 [
213 DailyPageHelper::DAY,
214 '',
215 (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
216 $date,
217 ],
218 [DailyPageHelper::DAY, '', null, new \DateTime()],
219 [DailyPageHelper::WEEK, '202030', null, new \DateTime('2020-07-20')],
220 [
221 DailyPageHelper::WEEK,
222 '',
223 (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
224 new \DateTime('2020-10-13'),
225 ],
226 [DailyPageHelper::WEEK, '', null, new \DateTime(), 'Ym'],
227 [DailyPageHelper::MONTH, '202008', null, new \DateTime('2020-08-01'), 'Ym'],
228 [
229 DailyPageHelper::MONTH,
230 '',
231 (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
232 new \DateTime('2020-10-13'),
233 'Ym'
234 ],
235 [DailyPageHelper::MONTH, '', null, new \DateTime(), 'Ym'],
236 ];
237 }
238
239 /**
240 * Data provider for testGetFormatByType() test method.
241 */
242 public function getFormatsByType(): array
243 {
244 return [
245 [DailyPageHelper::DAY, 'Ymd'],
246 [DailyPageHelper::WEEK, 'YW'],
247 [DailyPageHelper::MONTH, 'Ym'],
248 ];
249 }
250
251 /**
252 * Data provider for testGetStartDatesByType() test method.
253 */
254 public function getStartDatesByType(): array
255 {
256 return [
257 [DailyPageHelper::DAY, new DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 00:00:00')],
258 [DailyPageHelper::WEEK, new DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-05 00:00:00')],
259 [DailyPageHelper::MONTH, new DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-01 00:00:00')],
260 ];
261 }
262
263 /**
264 * Data provider for testGetEndDatesByType() test method.
265 */
266 public function getEndDatesByType(): array
267 {
268 return [
269 [DailyPageHelper::DAY, new DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 23:59:59')],
270 [DailyPageHelper::WEEK, new DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-11 23:59:59')],
271 [DailyPageHelper::MONTH, new DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-31 23:59:59')],
272 ];
273 }
274
275 /**
276 * Data provider for testGetDescriptionsByType() test method.
277 */
278 public function getDescriptionsByType(): array
279 {
280 return [
281 [DailyPageHelper::DAY, $date = new DateTimeImmutable(), 'Today - ' . $date->format('F j, Y')],
282 [DailyPageHelper::DAY, $date = new DateTimeImmutable('-1 day'), 'Yesterday - ' . $date->format('F j, Y')],
283 [DailyPageHelper::DAY, new DateTimeImmutable('2020-10-09 04:05:06'), 'October 9, 2020'],
284 [DailyPageHelper::WEEK, new DateTimeImmutable('2020-10-09 04:05:06'), 'Week 41 (October 5, 2020)'],
285 [DailyPageHelper::MONTH, new DateTimeImmutable('2020-10-09 04:05:06'), 'October, 2020'],
286 ];
287 }
288
289 /**
290 * Data provider for testGeDescriptionsByTypeNotIncludeRelative() test method.
291 */
292 public function getDescriptionsByTypeNotIncludeRelative(): array
293 {
294 return [
295 [DailyPageHelper::DAY, $date = new \DateTimeImmutable(), $date->format('F j, Y')],
296 [DailyPageHelper::DAY, $date = new \DateTimeImmutable('-1 day'), $date->format('F j, Y')],
297 [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October 9, 2020'],
298 [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), 'Week 41 (October 5, 2020)'],
299 [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October, 2020'],
300 ];
301 }
302
303 /**
304 * Data provider for testGetRssLengthsByType() test method.
305 */
306 public function getRssLengthsByType(): array
307 {
308 return [
309 [DailyPageHelper::DAY],
310 [DailyPageHelper::WEEK],
311 [DailyPageHelper::MONTH],
312 ];
313 }
314
315 /**
316 * Data provider for testGetCacheDatePeriodByType() test method.
317 */
318 public function getCacheDatePeriodByType(): array
319 {
320 return [
321 [
322 DailyPageHelper::DAY,
323 new DateTimeImmutable('2020-10-09 04:05:06'),
324 new \DateTime('2020-10-09 00:00:00'),
325 new \DateTime('2020-10-09 23:59:59'),
326 ],
327 [
328 DailyPageHelper::WEEK,
329 new DateTimeImmutable('2020-10-09 04:05:06'),
330 new \DateTime('2020-10-05 00:00:00'),
331 new \DateTime('2020-10-11 23:59:59'),
332 ],
333 [
334 DailyPageHelper::MONTH,
335 new DateTimeImmutable('2020-10-09 04:05:06'),
336 new \DateTime('2020-10-01 00:00:00'),
337 new \DateTime('2020-10-31 23:59:59'),
338 ],
339 ];
340 }
341}
diff --git a/tests/FileUtilsTest.php b/tests/helper/FileUtilsTest.php
index 9163bdf1..8035f79c 100644
--- a/tests/FileUtilsTest.php
+++ b/tests/helper/FileUtilsTest.php
@@ -1,27 +1,51 @@
1<?php 1<?php
2 2
3namespace Shaarli; 3namespace Shaarli\Helper;
4 4
5use Exception; 5use Exception;
6use Shaarli\Exceptions\IOException;
7use Shaarli\TestCase;
6 8
7/** 9/**
8 * Class FileUtilsTest 10 * Class FileUtilsTest
9 * 11 *
10 * Test file utility class. 12 * Test file utility class.
11 */ 13 */
12class FileUtilsTest extends \Shaarli\TestCase 14class FileUtilsTest extends TestCase
13{ 15{
14 /** 16 /**
15 * @var string Test file path. 17 * @var string Test file path.
16 */ 18 */
17 protected static $file = 'sandbox/flat.db'; 19 protected static $file = 'sandbox/flat.db';
18 20
21 protected function setUp(): void
22 {
23 @mkdir('sandbox');
24 mkdir('sandbox/folder2');
25 touch('sandbox/file1');
26 touch('sandbox/file2');
27 mkdir('sandbox/folder1');
28 touch('sandbox/folder1/file1');
29 touch('sandbox/folder1/file2');
30 mkdir('sandbox/folder3');
31 mkdir('/tmp/shaarli-to-delete');
32 }
33
19 /** 34 /**
20 * Delete test file after every test. 35 * Delete test file after every test.
21 */ 36 */
22 protected function tearDown(): void 37 protected function tearDown(): void
23 { 38 {
24 @unlink(self::$file); 39 @unlink(self::$file);
40
41 @unlink('sandbox/folder1/file1');
42 @unlink('sandbox/folder1/file2');
43 @rmdir('sandbox/folder1');
44 @unlink('sandbox/file1');
45 @unlink('sandbox/file2');
46 @rmdir('sandbox/folder2');
47 @rmdir('sandbox/folder3');
48 @rmdir('/tmp/shaarli-to-delete');
25 } 49 }
26 50
27 /** 51 /**
@@ -107,4 +131,67 @@ class FileUtilsTest extends \Shaarli\TestCase
107 $this->assertEquals(null, FileUtils::readFlatDB(self::$file)); 131 $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
108 $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test'])); 132 $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
109 } 133 }
134
135 /**
136 * Test clearFolder with self delete and excluded files
137 */
138 public function testClearFolderSelfDeleteWithExclusion(): void
139 {
140 FileUtils::clearFolder('sandbox', true, ['file2']);
141
142 static::assertFileExists('sandbox/folder1/file2');
143 static::assertFileExists('sandbox/folder1');
144 static::assertFileExists('sandbox/file2');
145 static::assertFileExists('sandbox');
146
147 static::assertFileNotExists('sandbox/folder1/file1');
148 static::assertFileNotExists('sandbox/file1');
149 static::assertFileNotExists('sandbox/folder3');
150 }
151
152 /**
153 * Test clearFolder with self delete and excluded files
154 */
155 public function testClearFolderSelfDeleteWithoutExclusion(): void
156 {
157 FileUtils::clearFolder('sandbox', true);
158
159 static::assertFileNotExists('sandbox');
160 }
161
162 /**
163 * Test clearFolder with self delete and excluded files
164 */
165 public function testClearFolderNoSelfDeleteWithoutExclusion(): void
166 {
167 FileUtils::clearFolder('sandbox', false);
168
169 static::assertFileExists('sandbox');
170
171 // 2 because '.' and '..'
172 static::assertCount(2, new \DirectoryIterator('sandbox'));
173 }
174
175 /**
176 * Test clearFolder on a file instead of a folder
177 */
178 public function testClearFolderOnANonDirectory(): void
179 {
180 $this->expectException(IOException::class);
181 $this->expectExceptionMessage('Provided path is not a directory.');
182
183 FileUtils::clearFolder('sandbox/file1', false);
184 }
185
186 /**
187 * Test clearFolder on a file instead of a folder
188 */
189 public function testClearFolderOutsideOfShaarliDirectory(): void
190 {
191 $this->expectException(IOException::class);
192 $this->expectExceptionMessage('Trying to delete a folder outside of Shaarli path.');
193
194
195 FileUtils::clearFolder('/tmp/shaarli-to-delete', true);
196 }
110} 197}
diff --git a/tests/http/MetadataRetrieverTest.php b/tests/http/MetadataRetrieverTest.php
new file mode 100644
index 00000000..cae65091
--- /dev/null
+++ b/tests/http/MetadataRetrieverTest.php
@@ -0,0 +1,154 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Http;
6
7use PHPUnit\Framework\TestCase;
8use Shaarli\Config\ConfigManager;
9
10class MetadataRetrieverTest extends TestCase
11{
12 /** @var MetadataRetriever */
13 protected $retriever;
14
15 /** @var ConfigManager */
16 protected $conf;
17
18 /** @var HttpAccess */
19 protected $httpAccess;
20
21 public function setUp(): void
22 {
23 $this->conf = $this->createMock(ConfigManager::class);
24 $this->httpAccess = $this->createMock(HttpAccess::class);
25 $this->retriever = new MetadataRetriever($this->conf, $this->httpAccess);
26
27 $this->conf->method('get')->willReturnCallback(function (string $param, $default) {
28 return $default === null ? $param : $default;
29 });
30 }
31
32 /**
33 * Test metadata retrieve() with values returned
34 */
35 public function testFullRetrieval(): void
36 {
37 $url = 'https://domain.tld/link';
38 $remoteTitle = 'Remote Title ';
39 $remoteDesc = 'Sometimes the meta description is relevant.';
40 $remoteTags = 'abc def';
41 $remoteCharset = 'utf-8';
42
43 $expectedResult = [
44 'title' => trim($remoteTitle),
45 'description' => $remoteDesc,
46 'tags' => $remoteTags,
47 ];
48
49 $this->httpAccess
50 ->expects(static::once())
51 ->method('getCurlHeaderCallback')
52 ->willReturnCallback(
53 function (&$charset) use (
54 $remoteCharset
55 ): callable {
56 return function () use (
57 &$charset,
58 $remoteCharset
59 ): void {
60 $charset = $remoteCharset;
61 };
62 }
63 )
64 ;
65 $this->httpAccess
66 ->expects(static::once())
67 ->method('getCurlDownloadCallback')
68 ->willReturnCallback(
69 function (&$charset, &$title, &$description, &$tags) use (
70 $remoteCharset,
71 $remoteTitle,
72 $remoteDesc,
73 $remoteTags
74 ): callable {
75 return function () use (
76 &$charset,
77 &$title,
78 &$description,
79 &$tags,
80 $remoteCharset,
81 $remoteTitle,
82 $remoteDesc,
83 $remoteTags
84 ): void {
85 static::assertSame($remoteCharset, $charset);
86
87 $title = $remoteTitle;
88 $description = $remoteDesc;
89 $tags = $remoteTags;
90 };
91 }
92 )
93 ;
94 $this->httpAccess
95 ->expects(static::once())
96 ->method('getHttpResponse')
97 ->with($url, 30, 4194304)
98 ->willReturnCallback(function($url, $timeout, $maxBytes, $headerCallback, $dlCallback): void {
99 $headerCallback();
100 $dlCallback();
101 })
102 ;
103
104 $result = $this->retriever->retrieve($url);
105
106 static::assertSame($expectedResult, $result);
107 }
108
109 /**
110 * Test metadata retrieve() without any value
111 */
112 public function testEmptyRetrieval(): void
113 {
114 $url = 'https://domain.tld/link';
115
116 $expectedResult = [
117 'title' => null,
118 'description' => null,
119 'tags' => null,
120 ];
121
122 $this->httpAccess
123 ->expects(static::once())
124 ->method('getCurlDownloadCallback')
125 ->willReturnCallback(
126 function (): callable {
127 return function (): void {};
128 }
129 )
130 ;
131 $this->httpAccess
132 ->expects(static::once())
133 ->method('getCurlHeaderCallback')
134 ->willReturnCallback(
135 function (): callable {
136 return function (): void {};
137 }
138 )
139 ;
140 $this->httpAccess
141 ->expects(static::once())
142 ->method('getHttpResponse')
143 ->with($url, 30, 4194304)
144 ->willReturnCallback(function($url, $timeout, $maxBytes, $headerCallback, $dlCallback): void {
145 $headerCallback();
146 $dlCallback();
147 })
148 ;
149
150 $result = $this->retriever->retrieve($url);
151
152 static::assertSame($expectedResult, $result);
153 }
154}
diff --git a/tests/legacy/LegacyUpdaterTest.php b/tests/legacy/LegacyUpdaterTest.php
index f7391b86..395dd4b7 100644
--- a/tests/legacy/LegacyUpdaterTest.php
+++ b/tests/legacy/LegacyUpdaterTest.php
@@ -51,10 +51,10 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
51 */ 51 */
52 public function testReadEmptyUpdatesFile() 52 public function testReadEmptyUpdatesFile()
53 { 53 {
54 $this->assertEquals(array(), UpdaterUtils::read_updates_file('')); 54 $this->assertEquals(array(), UpdaterUtils::readUpdatesFile(''));
55 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; 55 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
56 touch($updatesFile); 56 touch($updatesFile);
57 $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile)); 57 $this->assertEquals(array(), UpdaterUtils::readUpdatesFile($updatesFile));
58 unlink($updatesFile); 58 unlink($updatesFile);
59 } 59 }
60 60
@@ -66,14 +66,14 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
66 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; 66 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
67 $updatesMethods = array('m1', 'm2', 'm3'); 67 $updatesMethods = array('m1', 'm2', 'm3');
68 68
69 UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); 69 UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
70 $readMethods = UpdaterUtils::read_updates_file($updatesFile); 70 $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
71 $this->assertEquals($readMethods, $updatesMethods); 71 $this->assertEquals($readMethods, $updatesMethods);
72 72
73 // Update 73 // Update
74 $updatesMethods[] = 'm4'; 74 $updatesMethods[] = 'm4';
75 UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); 75 UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
76 $readMethods = UpdaterUtils::read_updates_file($updatesFile); 76 $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
77 $this->assertEquals($readMethods, $updatesMethods); 77 $this->assertEquals($readMethods, $updatesMethods);
78 unlink($updatesFile); 78 unlink($updatesFile);
79 } 79 }
@@ -86,7 +86,7 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
86 $this->expectException(\Exception::class); 86 $this->expectException(\Exception::class);
87 $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/'); 87 $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
88 88
89 UpdaterUtils::write_updates_file('', array('test')); 89 UpdaterUtils::writeUpdatesFile('', array('test'));
90 } 90 }
91 91
92 /** 92 /**
@@ -101,7 +101,7 @@ class LegacyUpdaterTest extends \Shaarli\TestCase
101 touch($updatesFile); 101 touch($updatesFile);
102 chmod($updatesFile, 0444); 102 chmod($updatesFile, 0444);
103 try { 103 try {
104 @UpdaterUtils::write_updates_file($updatesFile, array('test')); 104 @UpdaterUtils::writeUpdatesFile($updatesFile, array('test'));
105 } catch (Exception $e) { 105 } catch (Exception $e) {
106 unlink($updatesFile); 106 unlink($updatesFile);
107 throw $e; 107 throw $e;
diff --git a/tests/netscape/BookmarkImportTest.php b/tests/netscape/BookmarkImportTest.php
index c526d5c8..6856ebca 100644
--- a/tests/netscape/BookmarkImportTest.php
+++ b/tests/netscape/BookmarkImportTest.php
@@ -531,7 +531,7 @@ class BookmarkImportTest extends TestCase
531 { 531 {
532 $post = array( 532 $post = array(
533 'privacy' => 'public', 533 'privacy' => 'public',
534 'default_tags' => 'tag1,tag2 tag3' 534 'default_tags' => 'tag1 tag2 tag3'
535 ); 535 );
536 $files = file2array('netscape_basic.htm'); 536 $files = file2array('netscape_basic.htm');
537 $this->assertStringMatchesFormat( 537 $this->assertStringMatchesFormat(
@@ -552,7 +552,7 @@ class BookmarkImportTest extends TestCase
552 { 552 {
553 $post = array( 553 $post = array(
554 'privacy' => 'public', 554 'privacy' => 'public',
555 'default_tags' => 'tag1&,tag2 "tag3"' 555 'default_tags' => 'tag1& tag2 "tag3"'
556 ); 556 );
557 $files = file2array('netscape_basic.htm'); 557 $files = file2array('netscape_basic.htm');
558 $this->assertStringMatchesFormat( 558 $this->assertStringMatchesFormat(
@@ -573,6 +573,43 @@ class BookmarkImportTest extends TestCase
573 } 573 }
574 574
575 /** 575 /**
576 * Add user-specified tags to all imported bookmarks
577 */
578 public function testSetDefaultTagsWithCustomSeparator()
579 {
580 $separator = '@';
581 $this->conf->set('general.tags_separator', $separator);
582 $post = [
583 'privacy' => 'public',
584 'default_tags' => 'tag1@tag2@tag3@multiple words tag'
585 ];
586 $files = file2array('netscape_basic.htm');
587 $this->assertStringMatchesFormat(
588 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
589 .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
590 $this->netscapeBookmarkUtils->import($post, $files)
591 );
592 $this->assertEquals(2, $this->bookmarkService->count());
593 $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
594 $this->assertEquals(
595 'tag1@tag2@tag3@multiple words tag@private@secret',
596 $this->bookmarkService->get(0)->getTagsString($separator)
597 );
598 $this->assertEquals(
599 ['tag1', 'tag2', 'tag3', 'multiple words tag', 'private', 'secret'],
600 $this->bookmarkService->get(0)->getTags()
601 );
602 $this->assertEquals(
603 'tag1@tag2@tag3@multiple words tag@public@hello@world',
604 $this->bookmarkService->get(1)->getTagsString($separator)
605 );
606 $this->assertEquals(
607 ['tag1', 'tag2', 'tag3', 'multiple words tag', 'public', 'hello', 'world'],
608 $this->bookmarkService->get(1)->getTags()
609 );
610 }
611
612 /**
576 * Ensure each imported bookmark has a unique id 613 * Ensure each imported bookmark has a unique id
577 * 614 *
578 * See https://github.com/shaarli/Shaarli/issues/351 615 * See https://github.com/shaarli/Shaarli/issues/351
diff --git a/tests/plugins/PluginDefaultColorsTest.php b/tests/plugins/PluginDefaultColorsTest.php
index cc844c60..54e97612 100644
--- a/tests/plugins/PluginDefaultColorsTest.php
+++ b/tests/plugins/PluginDefaultColorsTest.php
@@ -193,4 +193,27 @@ class PluginDefaultColorsTest extends TestCase
193 $result = default_colors_format_css_rule($data, ''); 193 $result = default_colors_format_css_rule($data, '');
194 $this->assertEmpty($result); 194 $this->assertEmpty($result);
195 } 195 }
196
197 /**
198 * Make sure that a new CSS file is generated when save_plugin_parameters hook is triggered.
199 */
200 public function testHookSavePluginParameters(): void
201 {
202 $params = [
203 'other1' => true,
204 'DEFAULT_COLORS_BACKGROUND' => 'pink',
205 'other2' => ['yep'],
206 'DEFAULT_COLORS_DARK_MAIN' => '',
207 ];
208
209 hook_default_colors_save_plugin_parameters($params);
210 $this->assertFileExists($file = 'sandbox/default_colors/default_colors.css');
211 $content = file_get_contents($file);
212 $expected = ':root {
213 --background-color: pink;
214
215}
216';
217 $this->assertEquals($expected, $content);
218 }
196} 219}
diff --git a/tests/plugins/PluginWallabagTest.php b/tests/plugins/PluginWallabagTest.php
index 36317215..9a402fb7 100644
--- a/tests/plugins/PluginWallabagTest.php
+++ b/tests/plugins/PluginWallabagTest.php
@@ -49,14 +49,15 @@ class PluginWallabagTest extends \Shaarli\TestCase
49 $conf = new ConfigManager(''); 49 $conf = new ConfigManager('');
50 $conf->set('plugins.WALLABAG_URL', 'value'); 50 $conf->set('plugins.WALLABAG_URL', 'value');
51 $str = 'http://randomstr.com/test'; 51 $str = 'http://randomstr.com/test';
52 $data = array( 52 $data = [
53 'title' => $str, 53 'title' => $str,
54 'links' => array( 54 'links' => [
55 array( 55 [
56 'url' => $str, 56 'url' => $str,
57 ) 57 ]
58 ) 58 ],
59 ); 59 '_LOGGEDIN_' => true,
60 ];
60 61
61 $data = hook_wallabag_render_linklist($data, $conf); 62 $data = hook_wallabag_render_linklist($data, $conf);
62 $link = $data['links'][0]; 63 $link = $data['links'][0];
@@ -69,4 +70,26 @@ class PluginWallabagTest extends \Shaarli\TestCase
69 $this->assertNotFalse(strpos($link['link_plugin'][0], urlencode($str))); 70 $this->assertNotFalse(strpos($link['link_plugin'][0], urlencode($str)));
70 $this->assertNotFalse(strpos($link['link_plugin'][0], $conf->get('plugins.WALLABAG_URL'))); 71 $this->assertNotFalse(strpos($link['link_plugin'][0], $conf->get('plugins.WALLABAG_URL')));
71 } 72 }
73
74 /**
75 * Test render_linklist hook while logged out: no change.
76 */
77 public function testWallabagLinklistLoggedOut(): void
78 {
79 $conf = new ConfigManager('');
80 $str = 'http://randomstr.com/test';
81 $data = [
82 'title' => $str,
83 'links' => [
84 [
85 'url' => $str,
86 ]
87 ],
88 '_LOGGEDIN_' => false,
89 ];
90
91 $result = hook_wallabag_render_linklist($data, $conf);
92
93 static::assertSame($data, $result);
94 }
72} 95}
diff --git a/tests/plugins/test/test.php b/tests/plugins/test/test.php
index 03be4f4e..34cd339e 100644
--- a/tests/plugins/test/test.php
+++ b/tests/plugins/test/test.php
@@ -27,3 +27,19 @@ function hook_test_error()
27{ 27{
28 new Unknown(); 28 new Unknown();
29} 29}
30
31function test_register_routes(): array
32{
33 return [
34 [
35 'method' => 'GET',
36 'route' => '/test',
37 'callable' => 'getFunction',
38 ],
39 [
40 'method' => 'POST',
41 'route' => '/custom',
42 'callable' => 'postFunction',
43 ],
44 ];
45}
diff --git a/tests/plugins/test_route_invalid/test_route_invalid.php b/tests/plugins/test_route_invalid/test_route_invalid.php
new file mode 100644
index 00000000..0c5a5101
--- /dev/null
+++ b/tests/plugins/test_route_invalid/test_route_invalid.php
@@ -0,0 +1,12 @@
1<?php
2
3function test_route_invalid_register_routes(): array
4{
5 return [
6 [
7 'method' => 'GET',
8 'route' => 'not a route',
9 'callable' => 'getFunction',
10 ],
11 ];
12}
diff --git a/tests/security/BanManagerTest.php b/tests/security/BanManagerTest.php
index 698d3d10..29d2791b 100644
--- a/tests/security/BanManagerTest.php
+++ b/tests/security/BanManagerTest.php
@@ -3,7 +3,8 @@
3 3
4namespace Shaarli\Security; 4namespace Shaarli\Security;
5 5
6use Shaarli\FileUtils; 6use Psr\Log\LoggerInterface;
7use Shaarli\Helper\FileUtils;
7use Shaarli\TestCase; 8use Shaarli\TestCase;
8 9
9/** 10/**
@@ -387,7 +388,7 @@ class BanManagerTest extends TestCase
387 3, 388 3,
388 1800, 389 1800,
389 $this->banFile, 390 $this->banFile,
390 $this->logFile 391 $this->createMock(LoggerInterface::class)
391 ); 392 );
392 } 393 }
393} 394}
diff --git a/tests/security/LoginManagerTest.php b/tests/security/LoginManagerTest.php
index d302983d..f7609fc6 100644
--- a/tests/security/LoginManagerTest.php
+++ b/tests/security/LoginManagerTest.php
@@ -2,6 +2,8 @@
2 2
3namespace Shaarli\Security; 3namespace Shaarli\Security;
4 4
5use Psr\Log\LoggerInterface;
6use Shaarli\FakeConfigManager;
5use Shaarli\TestCase; 7use Shaarli\TestCase;
6 8
7/** 9/**
@@ -9,7 +11,7 @@ use Shaarli\TestCase;
9 */ 11 */
10class LoginManagerTest extends TestCase 12class LoginManagerTest extends TestCase
11{ 13{
12 /** @var \FakeConfigManager Configuration Manager instance */ 14 /** @var FakeConfigManager Configuration Manager instance */
13 protected $configManager = null; 15 protected $configManager = null;
14 16
15 /** @var LoginManager Login Manager instance */ 17 /** @var LoginManager Login Manager instance */
@@ -60,6 +62,9 @@ class LoginManagerTest extends TestCase
60 /** @var CookieManager */ 62 /** @var CookieManager */
61 protected $cookieManager; 63 protected $cookieManager;
62 64
65 /** @var BanManager */
66 protected $banManager;
67
63 /** 68 /**
64 * Prepare or reset test resources 69 * Prepare or reset test resources
65 */ 70 */
@@ -71,7 +76,7 @@ class LoginManagerTest extends TestCase
71 76
72 $this->passwordHash = sha1($this->password . $this->login . $this->salt); 77 $this->passwordHash = sha1($this->password . $this->login . $this->salt);
73 78
74 $this->configManager = new \FakeConfigManager([ 79 $this->configManager = new FakeConfigManager([
75 'credentials.login' => $this->login, 80 'credentials.login' => $this->login,
76 'credentials.hash' => $this->passwordHash, 81 'credentials.hash' => $this->passwordHash,
77 'credentials.salt' => $this->salt, 82 'credentials.salt' => $this->salt,
@@ -91,18 +96,29 @@ class LoginManagerTest extends TestCase
91 return $this->cookie[$key] ?? null; 96 return $this->cookie[$key] ?? null;
92 }); 97 });
93 $this->sessionManager = new SessionManager($this->session, $this->configManager, 'session_path'); 98 $this->sessionManager = new SessionManager($this->session, $this->configManager, 'session_path');
94 $this->loginManager = new LoginManager($this->configManager, $this->sessionManager, $this->cookieManager); 99 $this->banManager = $this->createMock(BanManager::class);
100 $this->loginManager = new LoginManager(
101 $this->configManager,
102 $this->sessionManager,
103 $this->cookieManager,
104 $this->banManager,
105 $this->createMock(LoggerInterface::class)
106 );
95 $this->server['REMOTE_ADDR'] = $this->ipAddr; 107 $this->server['REMOTE_ADDR'] = $this->ipAddr;
96 } 108 }
97 109
98 /** 110 /**
99 * Record a failed login attempt 111 * Record a failed login attempt
100 */ 112 */
101 public function testHandleFailedLogin() 113 public function testHandleFailedLogin(): void
102 { 114 {
115 $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt');
116 $this->banManager->method('isBanned')->willReturn(true);
117
103 $this->loginManager->handleFailedLogin($this->server); 118 $this->loginManager->handleFailedLogin($this->server);
104 $this->loginManager->handleFailedLogin($this->server); 119 $this->loginManager->handleFailedLogin($this->server);
105 $this->assertFalse($this->loginManager->canLogin($this->server)); 120
121 static::assertFalse($this->loginManager->canLogin($this->server));
106 } 122 }
107 123
108 /** 124 /**
@@ -114,8 +130,13 @@ class LoginManagerTest extends TestCase
114 'REMOTE_ADDR' => $this->trustedProxy, 130 'REMOTE_ADDR' => $this->trustedProxy,
115 'HTTP_X_FORWARDED_FOR' => $this->ipAddr, 131 'HTTP_X_FORWARDED_FOR' => $this->ipAddr,
116 ]; 132 ];
133
134 $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt');
135 $this->banManager->method('isBanned')->willReturn(true);
136
117 $this->loginManager->handleFailedLogin($server); 137 $this->loginManager->handleFailedLogin($server);
118 $this->loginManager->handleFailedLogin($server); 138 $this->loginManager->handleFailedLogin($server);
139
119 $this->assertFalse($this->loginManager->canLogin($server)); 140 $this->assertFalse($this->loginManager->canLogin($server));
120 } 141 }
121 142
@@ -196,10 +217,16 @@ class LoginManagerTest extends TestCase
196 */ 217 */
197 public function testCheckLoginStateNotConfigured() 218 public function testCheckLoginStateNotConfigured()
198 { 219 {
199 $configManager = new \FakeConfigManager([ 220 $configManager = new FakeConfigManager([
200 'resource.ban_file' => $this->banFile, 221 'resource.ban_file' => $this->banFile,
201 ]); 222 ]);
202 $loginManager = new LoginManager($configManager, null, $this->cookieManager); 223 $loginManager = new LoginManager(
224 $configManager,
225 $this->sessionManager,
226 $this->cookieManager,
227 $this->banManager,
228 $this->createMock(LoggerInterface::class)
229 );
203 $loginManager->checkLoginState(''); 230 $loginManager->checkLoginState('');
204 231
205 $this->assertFalse($loginManager->isLoggedIn()); 232 $this->assertFalse($loginManager->isLoggedIn());
@@ -270,7 +297,7 @@ class LoginManagerTest extends TestCase
270 public function testCheckCredentialsWrongLogin() 297 public function testCheckCredentialsWrongLogin()
271 { 298 {
272 $this->assertFalse( 299 $this->assertFalse(
273 $this->loginManager->checkCredentials('', '', 'b4dl0g1n', $this->password) 300 $this->loginManager->checkCredentials('', 'b4dl0g1n', $this->password)
274 ); 301 );
275 } 302 }
276 303
@@ -280,7 +307,7 @@ class LoginManagerTest extends TestCase
280 public function testCheckCredentialsWrongPassword() 307 public function testCheckCredentialsWrongPassword()
281 { 308 {
282 $this->assertFalse( 309 $this->assertFalse(
283 $this->loginManager->checkCredentials('', '', $this->login, 'b4dp455wd') 310 $this->loginManager->checkCredentials('', $this->login, 'b4dp455wd')
284 ); 311 );
285 } 312 }
286 313
@@ -290,7 +317,7 @@ class LoginManagerTest extends TestCase
290 public function testCheckCredentialsWrongLoginAndPassword() 317 public function testCheckCredentialsWrongLoginAndPassword()
291 { 318 {
292 $this->assertFalse( 319 $this->assertFalse(
293 $this->loginManager->checkCredentials('', '', 'b4dl0g1n', 'b4dp455wd') 320 $this->loginManager->checkCredentials('', 'b4dl0g1n', 'b4dp455wd')
294 ); 321 );
295 } 322 }
296 323
@@ -300,7 +327,7 @@ class LoginManagerTest extends TestCase
300 public function testCheckCredentialsGoodLoginAndPassword() 327 public function testCheckCredentialsGoodLoginAndPassword()
301 { 328 {
302 $this->assertTrue( 329 $this->assertTrue(
303 $this->loginManager->checkCredentials('', '', $this->login, $this->password) 330 $this->loginManager->checkCredentials('', $this->login, $this->password)
304 ); 331 );
305 } 332 }
306 333
@@ -311,7 +338,7 @@ class LoginManagerTest extends TestCase
311 { 338 {
312 $this->configManager->set('ldap.host', 'dummy'); 339 $this->configManager->set('ldap.host', 'dummy');
313 $this->assertFalse( 340 $this->assertFalse(
314 $this->loginManager->checkCredentials('', '', $this->login, $this->password) 341 $this->loginManager->checkCredentials('', $this->login, $this->password)
315 ); 342 );
316 } 343 }
317 344
diff --git a/tests/security/SessionManagerTest.php b/tests/security/SessionManagerTest.php
index 3f9c3ef5..6830d714 100644
--- a/tests/security/SessionManagerTest.php
+++ b/tests/security/SessionManagerTest.php
@@ -2,6 +2,7 @@
2 2
3namespace Shaarli\Security; 3namespace Shaarli\Security;
4 4
5use Shaarli\FakeConfigManager;
5use Shaarli\TestCase; 6use Shaarli\TestCase;
6 7
7/** 8/**
@@ -12,7 +13,7 @@ class SessionManagerTest extends TestCase
12 /** @var array Session ID hashes */ 13 /** @var array Session ID hashes */
13 protected static $sidHashes = null; 14 protected static $sidHashes = null;
14 15
15 /** @var \FakeConfigManager ConfigManager substitute for testing */ 16 /** @var FakeConfigManager ConfigManager substitute for testing */
16 protected $conf = null; 17 protected $conf = null;
17 18
18 /** @var array $_SESSION array for testing */ 19 /** @var array $_SESSION array for testing */
@@ -34,7 +35,7 @@ class SessionManagerTest extends TestCase
34 */ 35 */
35 protected function setUp(): void 36 protected function setUp(): void
36 { 37 {
37 $this->conf = new \FakeConfigManager([ 38 $this->conf = new FakeConfigManager([
38 'credentials.login' => 'johndoe', 39 'credentials.login' => 'johndoe',
39 'credentials.salt' => 'salt', 40 'credentials.salt' => 'salt',
40 'security.session_protection_disabled' => false, 41 'security.session_protection_disabled' => false,
diff --git a/tests/updater/UpdaterTest.php b/tests/updater/UpdaterTest.php
index 47332544..cadd8265 100644
--- a/tests/updater/UpdaterTest.php
+++ b/tests/updater/UpdaterTest.php
@@ -60,10 +60,10 @@ class UpdaterTest extends TestCase
60 */ 60 */
61 public function testReadEmptyUpdatesFile() 61 public function testReadEmptyUpdatesFile()
62 { 62 {
63 $this->assertEquals(array(), UpdaterUtils::read_updates_file('')); 63 $this->assertEquals(array(), UpdaterUtils::readUpdatesFile(''));
64 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; 64 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
65 touch($updatesFile); 65 touch($updatesFile);
66 $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile)); 66 $this->assertEquals(array(), UpdaterUtils::readUpdatesFile($updatesFile));
67 unlink($updatesFile); 67 unlink($updatesFile);
68 } 68 }
69 69
@@ -75,14 +75,14 @@ class UpdaterTest extends TestCase
75 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; 75 $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
76 $updatesMethods = array('m1', 'm2', 'm3'); 76 $updatesMethods = array('m1', 'm2', 'm3');
77 77
78 UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); 78 UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
79 $readMethods = UpdaterUtils::read_updates_file($updatesFile); 79 $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
80 $this->assertEquals($readMethods, $updatesMethods); 80 $this->assertEquals($readMethods, $updatesMethods);
81 81
82 // Update 82 // Update
83 $updatesMethods[] = 'm4'; 83 $updatesMethods[] = 'm4';
84 UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); 84 UpdaterUtils::writeUpdatesFile($updatesFile, $updatesMethods);
85 $readMethods = UpdaterUtils::read_updates_file($updatesFile); 85 $readMethods = UpdaterUtils::readUpdatesFile($updatesFile);
86 $this->assertEquals($readMethods, $updatesMethods); 86 $this->assertEquals($readMethods, $updatesMethods);
87 unlink($updatesFile); 87 unlink($updatesFile);
88 } 88 }
@@ -95,7 +95,7 @@ class UpdaterTest extends TestCase
95 $this->expectException(\Exception::class); 95 $this->expectException(\Exception::class);
96 $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/'); 96 $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
97 97
98 UpdaterUtils::write_updates_file('', array('test')); 98 UpdaterUtils::writeUpdatesFile('', array('test'));
99 } 99 }
100 100
101 /** 101 /**
@@ -110,7 +110,7 @@ class UpdaterTest extends TestCase
110 touch($updatesFile); 110 touch($updatesFile);
111 chmod($updatesFile, 0444); 111 chmod($updatesFile, 0444);
112 try { 112 try {
113 @UpdaterUtils::write_updates_file($updatesFile, array('test')); 113 @UpdaterUtils::writeUpdatesFile($updatesFile, array('test'));
114 } catch (Exception $e) { 114 } catch (Exception $e) {
115 unlink($updatesFile); 115 unlink($updatesFile);
116 throw $e; 116 throw $e;
diff --git a/tests/utils/FakeApplicationUtils.php b/tests/utils/FakeApplicationUtils.php
index de83d598..d5289ede 100644
--- a/tests/utils/FakeApplicationUtils.php
+++ b/tests/utils/FakeApplicationUtils.php
@@ -2,6 +2,8 @@
2 2
3namespace Shaarli; 3namespace Shaarli;
4 4
5use Shaarli\Helper\ApplicationUtils;
6
5/** 7/**
6 * Fake ApplicationUtils class to avoid HTTP requests 8 * Fake ApplicationUtils class to avoid HTTP requests
7 */ 9 */
diff --git a/tests/utils/FakeConfigManager.php b/tests/utils/FakeConfigManager.php
index 360b34a9..014c2af0 100644
--- a/tests/utils/FakeConfigManager.php
+++ b/tests/utils/FakeConfigManager.php
@@ -1,9 +1,13 @@
1<?php 1<?php
2 2
3namespace Shaarli;
4
5use Shaarli\Config\ConfigManager;
6
3/** 7/**
4 * Fake ConfigManager 8 * Fake ConfigManager
5 */ 9 */
6class FakeConfigManager 10class FakeConfigManager extends ConfigManager
7{ 11{
8 protected $values = []; 12 protected $values = [];
9 13
@@ -23,7 +27,7 @@ class FakeConfigManager
23 * @param string $key Key of the value to set 27 * @param string $key Key of the value to set
24 * @param mixed $value Value to set 28 * @param mixed $value Value to set
25 */ 29 */
26 public function set($key, $value) 30 public function set($key, $value, $write = false, $isLoggedIn = false)
27 { 31 {
28 $this->values[$key] = $value; 32 $this->values[$key] = $value;
29 } 33 }
@@ -35,7 +39,7 @@ class FakeConfigManager
35 * 39 *
36 * @return mixed The value if set, else the name of the key 40 * @return mixed The value if set, else the name of the key
37 */ 41 */
38 public function get($key) 42 public function get($key, $default = '')
39 { 43 {
40 if (isset($this->values[$key])) { 44 if (isset($this->values[$key])) {
41 return $this->values[$key]; 45 return $this->values[$key];
diff --git a/tests/utils/ReferenceHistory.php b/tests/utils/ReferenceHistory.php
index 516c9f51..aed5d2cf 100644
--- a/tests/utils/ReferenceHistory.php
+++ b/tests/utils/ReferenceHistory.php
@@ -1,6 +1,6 @@
1<?php 1<?php
2 2
3use Shaarli\FileUtils; 3use Shaarli\Helper\FileUtils;
4use Shaarli\History; 4use Shaarli\History;
5 5
6/** 6/**
diff --git a/tpl/default/addlink.html b/tpl/default/addlink.html
index 67d3ebd1..4aac7ff1 100644
--- a/tpl/default/addlink.html
+++ b/tpl/default/addlink.html
@@ -20,6 +20,62 @@
20 </form> 20 </form>
21 </div> 21 </div>
22</div> 22</div>
23
24<div class="pure-g addlink-batch-show-more-block pure-u-0">
25 <div class="pure-u-lg-1-3 pure-u-1-24"></div>
26 <div class="pure-u-lg-1-3 pure-u-22-24 addlink-batch-show-more">
27 <a href="#">{'BULK CREATION'|t}&nbsp;<i class="fa fa-plus-circle" aria-hidden="true"></i></a>
28 </div>
29</div>
30
31<div class="addlink-batch-form-block">
32 {if="empty($async_metadata)"}
33 <div class="pure-g pure-alert pure-alert-warning pure-alert-closable">
34 <div class="pure-u-2-24"></div>
35 <div class="pure-u-20-24">
36 <p>
37 {'Metadata asynchronous retrieval is disabled.'|t}
38 {'We recommend that you enable the setting <em>general > enable_async_metadata</em> in your configuration file to use bulk link creation.'|t}
39 </p>
40 </div>
41 <div class="pure-u-2-24">
42 <i class="fa fa-times pure-alert-close"></i>
43 </div>
44 </div>
45 {/if}
46
47 <div class="pure-g">
48 <div class="pure-u-lg-1-3 pure-u-1-24"></div>
49 <div id="batch-addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
50 <h2 class="window-title">{"Shaare multiple new links"|t}</h2>
51 <form method="POST" action="{$base_path}/admin/shaare-batch" name="batch-addform" class="batch-addform">
52 <div>
53 <label for="urls">{'Add one URL per line to create multiple bookmarks.'|t}</label>
54 <textarea name="urls" id="urls"></textarea>
55
56 <div>
57 <label for="tags">{'Tags'|t}</label>
58 </div>
59 <div>
60 <input type="text" name="tags" id="tags" class="lf_input"
61 data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off">
62 </div>
63
64 <div>
65 <input type="hidden" name="private" value="0">
66 <input type="checkbox" name="private" {if="$default_private_links"} checked="checked"{/if}>
67 &nbsp; <label for="lf_private">{'Private'|t}</label>
68 </div>
69 </div>
70 <div>
71 <input type="hidden" name="token" value="{$token}">
72 <input type="submit" value="{'Add links'|t}">
73 </div>
74 </form>
75 </div>
76 </div>
77</div>
78
23{include="page.footer"} 79{include="page.footer"}
24</body> 80</body>
25</html> 81</html>
diff --git a/tpl/default/changetag.html b/tpl/default/changetag.html
index 89d08e2c..13b7f24a 100644
--- a/tpl/default/changetag.html
+++ b/tpl/default/changetag.html
@@ -28,13 +28,37 @@
28 <input type="hidden" name="token" value="{$token}"> 28 <input type="hidden" name="token" value="{$token}">
29 <div> 29 <div>
30 <input type="submit" value="{'Rename tag'|t}" name="renametag"> 30 <input type="submit" value="{'Rename tag'|t}" name="renametag">
31 <input type="submit" value="{'Delete tag'|t}" name="deletetag" class="button button-red confirm-delete"> 31 <input type="submit" value="{'Delete tag'|t}" name="deletetag"
32 class="button button-red confirm-delete" data-type="tag">
32 </div> 33 </div>
33 </form> 34 </form>
34 35
35 <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p> 36 <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p>
36 </div> 37 </div>
37</div> 38</div>
39
40<div class="pure-g">
41 <div class="pure-u-lg-1-3 pure-u-1-24"></div>
42 <div class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
43 <h2 class="window-title">{"Change tags separator"|t}</h2>
44 <form method="POST" action="{$base_path}/admin/tags/change-separator" name="changeseparator" id="changeseparator">
45 <p>
46 {'Your current tag separator is'|t} <code>{$tags_separator}</code>{if="!empty($tags_separator_desc)"} ({$tags_separator_desc}){/if}.
47 </p>
48 <div>
49 <input type="text" name="separator" placeholder="{'New separator'|t}"
50 id="separator">
51 </div>
52 <input type="hidden" name="token" value="{$token}">
53 <div>
54 <input type="submit" value="{'Save'|t}" name="saveseparator">
55 </div>
56 <p>
57 {'Note that hashtags won\'t fully work with a non-whitespace separator.'|t}
58 </p>
59 </form>
60 </div>
61</div>
38{include="page.footer"} 62{include="page.footer"}
39</body> 63</body>
40</html> 64</html>
diff --git a/tpl/default/daily.html b/tpl/default/daily.html
index 3749bffb..5e038c39 100644
--- a/tpl/default/daily.html
+++ b/tpl/default/daily.html
@@ -7,11 +7,24 @@
7{include="page.header"} 7{include="page.header"}
8 8
9<div class="pure-g"> 9<div class="pure-g">
10 <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
11 <a href="{$base_path}/daily?day">{'Daily'|t}</a>
12 <a href="{$base_path}/daily?week">{'Weekly'|t}</a>
13 <a href="{$base_path}/daily?month">{'Monthly'|t}</a>
14 </div>
15</div>
16
17
18<div class="pure-g">
10 <div class="pure-u-lg-1-6 pure-u-1-24"></div> 19 <div class="pure-u-lg-1-6 pure-u-1-24"></div>
11 <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor" id="daily"> 20 <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor" id="daily">
12 <h2 class="window-title"> 21 <h2 class="window-title">
13 {'The Daily Shaarli'|t} 22 {$localizedType} Shaarli
14 <a href="{$base_path}/daily-rss" title="{'1 RSS entry per day'|t}"><i class="fa fa-rss"></i></a> 23 <a href="{$base_path}/daily-rss?{$type}"
24 title="{function="t('1 RSS entry per :type', '', 1, 'shaarli', [':type' => t($type)])"}"
25 >
26 <i class="fa fa-rss"></i>
27 </a>
15 </h2> 28 </h2>
16 29
17 <div id="plugin_zone_start_daily" class="plugin_zone"> 30 <div id="plugin_zone_start_daily" class="plugin_zone">
@@ -25,19 +38,19 @@
25 <div class="pure-g"> 38 <div class="pure-g">
26 <div class="pure-u-lg-1-3 pure-u-1 center"> 39 <div class="pure-u-lg-1-3 pure-u-1 center">
27 {if="$previousday"} 40 {if="$previousday"}
28 <a href="{$base_path}/daily?day={$previousday}"> 41 <a href="{$base_path}/daily?{$type}={$previousday}">
29 <i class="fa fa-arrow-left"></i> 42 <i class="fa fa-arrow-left"></i>
30 {'Previous day'|t} 43 {function="t('Previous :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
31 </a> 44 </a>
32 {/if} 45 {/if}
33 </div> 46 </div>
34 <div class="daily-desc pure-u-lg-1-3 pure-u-1 center"> 47 <div class="daily-desc pure-u-lg-1-3 pure-u-1 center">
35 {'All links of one day in a single page.'|t} 48 {function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}
36 </div> 49 </div>
37 <div class="pure-u-lg-1-3 pure-u-1 center"> 50 <div class="pure-u-lg-1-3 pure-u-1 center">
38 {if="$nextday"} 51 {if="$nextday"}
39 <a href="{$base_path}/daily?day={$nextday}"> 52 <a href="{$base_path}/daily?{$type}={$nextday}">
40 {'Next day'|t} 53 {function="t('Next :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
41 <i class="fa fa-arrow-right"></i> 54 <i class="fa fa-arrow-right"></i>
42 </a> 55 </a>
43 {/if} 56 {/if}
@@ -45,10 +58,7 @@
45 </div> 58 </div>
46 <div> 59 <div>
47 <h3 class="window-subtitle"> 60 <h3 class="window-subtitle">
48 {if="!empty($dayDesc)"} 61 {$dayDesc}
49 {$dayDesc} -
50 {/if}
51 {function="format_date($dayDate, false)"}
52 </h3> 62 </h3>
53 63
54 <div id="plugin_zone_about_daily" class="plugin_zone"> 64 <div id="plugin_zone_about_daily" class="plugin_zone">
diff --git a/tpl/default/dailyrss.html b/tpl/default/dailyrss.html
index d40d9496..871a3ba7 100644
--- a/tpl/default/dailyrss.html
+++ b/tpl/default/dailyrss.html
@@ -1,9 +1,9 @@
1<?xml version="1.0" encoding="UTF-8"?> 1<?xml version="1.0" encoding="UTF-8"?>
2<rss version="2.0"> 2<rss version="2.0">
3 <channel> 3 <channel>
4 <title>Daily - {$title}</title> 4 <title>{$localizedType} - {$title}</title>
5 <link>{$index_url}</link> 5 <link>{$index_url}</link>
6 <description>Daily shaared bookmarks</description> 6 <description>{function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}</description>
7 <language>{$language}</language> 7 <language>{$language}</language>
8 <copyright>{$index_url}</copyright> 8 <copyright>{$index_url}</copyright>
9 <generator>Shaarli</generator> 9 <generator>Shaarli</generator>
@@ -18,12 +18,15 @@
18 {loop="$value.links"} 18 {loop="$value.links"}
19 <h3><a href="{$value.url}">{$value.title}</a></h3> 19 <h3><a href="{$value.url}">{$value.title}</a></h3>
20 <small> 20 <small>
21 {if="!$hide_timestamps"}{$value.created|format_date} - {/if}{if="$value.tags"}{$value.tags}{/if}<br> 21 {if="!$hide_timestamps"}{$value.created|format_date} &#8212; {/if}
22 <a href="{$index_url}shaare/{$value.shorturl}">{'Permalink'|t}</a>
23 {if="$value.tags"} &#8212; {$value.tags}{/if}
24 <br>
22 {$value.url} 25 {$value.url}
23 </small><br> 26 </small><br>
24 {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br> 27 {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
25 {if="$value.description"}{$value.description}{/if} 28 {if="$value.description"}{$value.description}{/if}
26 <br><br><hr> 29 <br><hr>
27 {/loop} 30 {/loop}
28 ]]></description> 31 ]]></description>
29 </item> 32 </item>
diff --git a/tpl/default/editlink.batch.html b/tpl/default/editlink.batch.html
new file mode 100644
index 00000000..b1f8e5bd
--- /dev/null
+++ b/tpl/default/editlink.batch.html
@@ -0,0 +1,32 @@
1<!DOCTYPE html>
2<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
3<head>
4 {include="includes"}
5</head>
6<body>
7<div class="dark-layer">
8 <div class="screen-center">
9 <div><span class="progressbar-current"></span> / <span class="progressbar-max"></span></div>
10 <div class="progressbar">
11 <div></div>
12 </div>
13 </div>
14</div>
15
16{include="page.header"}
17
18<div class="center">
19 <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
20</div>
21
22{loop="$links"}
23 {include="editlink"}
24{/loop}
25
26<div class="center">
27 <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
28</div>
29
30{include="page.footer"}
31{if="$async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
32<script src="{$asset_path}/js/shaare_batch.min.js?v={$version_hash}#"></script>
diff --git a/tpl/default/editlink.html b/tpl/default/editlink.html
index 568545bd..83e541fd 100644
--- a/tpl/default/editlink.html
+++ b/tpl/default/editlink.html
@@ -1,3 +1,4 @@
1{if="empty($batch_mode)"}
1<!DOCTYPE html> 2<!DOCTYPE html>
2<html{if="$language !== 'auto'"} lang="{$language}"{/if}> 3<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
3<head> 4<head>
@@ -5,6 +6,10 @@
5</head> 6</head>
6<body> 7<body>
7 {include="page.header"} 8 {include="page.header"}
9{else}
10 {ignore}Lil hack: when included in a loop in batch mode, `$value` is assigned by RainTPL with template vars.{/ignore}
11 {function="extract($value) ? '' : ''"}
12{/if}
8 <div id="editlinkform" class="edit-link-container" class="pure-g"> 13 <div id="editlinkform" class="edit-link-container" class="pure-g">
9 <div class="pure-u-lg-1-5 pure-u-1-24"></div> 14 <div class="pure-u-lg-1-5 pure-u-1-24"></div>
10 <form method="post" 15 <form method="post"
@@ -12,6 +17,8 @@
12 action="{$base_path}/admin/shaare" 17 action="{$base_path}/admin/shaare"
13 class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light" 18 class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
14 > 19 >
20 {$asyncLoadClass=$link_is_new && $async_metadata && empty($link.title) ? 'loading-input' : ''}
21
15 <h2 class="window-title"> 22 <h2 class="window-title">
16 {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if} 23 {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
17 </h2> 24 </h2>
@@ -28,26 +35,37 @@
28 <div> 35 <div>
29 <label for="lf_title">{'Title'|t}</label> 36 <label for="lf_title">{'Title'|t}</label>
30 </div> 37 </div>
31 <div> 38 <div class="{$asyncLoadClass}">
32 <input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input autofocus"> 39 <input type="text" name="lf_title" id="lf_title" value="{$link.title}"
40 class="lf_input {if="!$async_metadata"}autofocus{/if}"
41 >
42 <div class="icon-container">
43 <i class="loader"></i>
44 </div>
33 </div> 45 </div>
34 <div> 46 <div>
35 <label for="lf_description">{'Description'|t}</label> 47 <label for="lf_description">{'Description'|t}</label>
36 </div> 48 </div>
37 <div> 49 <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
38 <textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea> 50 <textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea>
51 <div class="icon-container">
52 <i class="loader"></i>
53 </div>
39 </div> 54 </div>
40 <div> 55 <div>
41 <label for="lf_tags">{'Tags'|t}</label> 56 <label for="lf_tags">{'Tags'|t}</label>
42 </div> 57 </div>
43 <div> 58 <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
44 <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input autofocus" 59 <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input autofocus"
45 data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" > 60 data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" >
61 <div class="icon-container">
62 <i class="loader"></i>
63 </div>
46 </div> 64 </div>
47 65
48 <div> 66 <div>
49 <input type="checkbox" name="lf_private" id="lf_private" 67 <input type="checkbox" name="lf_private" id="lf_private"
50 {if="($link_is_new && $default_private_links || $link.private == true)"} 68 {if="$link.private === true"}
51 checked="checked" 69 checked="checked"
52 {/if}> 70 {/if}>
53 &nbsp;<label for="lf_private">{'Private'|t}</label> 71 &nbsp;<label for="lf_private">{'Private'|t}</label>
@@ -70,6 +88,13 @@
70 88
71 89
72 <div class="submit-buttons center"> 90 <div class="submit-buttons center">
91 {if="!empty($batch_mode)"}
92 <a href="#" class="button button-grey" name="cancel-batch-link"
93 title="{'Remove this bookmark from batch creation/modification.'}"
94 >
95 {'Cancel'|t}
96 </a>
97 {/if}
73 <input type="submit" name="save_edit" class="" id="button-save-edit" 98 <input type="submit" name="save_edit" class="" id="button-save-edit"
74 value="{if="$link_is_new"}{'Save'|t}{else}{'Apply Changes'|t}{/if}"> 99 value="{if="$link_is_new"}{'Save'|t}{else}{'Apply Changes'|t}{/if}">
75 {if="!$link_is_new"} 100 {if="!$link_is_new"}
@@ -87,6 +112,10 @@
87 {/if} 112 {/if}
88 </form> 113 </form>
89 </div> 114 </div>
115
116{if="empty($batch_mode)"}
90 {include="page.footer"} 117 {include="page.footer"}
118 {if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
91</body> 119</body>
92</html> 120</html>
121{/if}
diff --git a/tpl/default/error.html b/tpl/default/error.html
index c3e0c3c1..34f9707d 100644
--- a/tpl/default/error.html
+++ b/tpl/default/error.html
@@ -9,13 +9,17 @@
9<div id="pageError" class="page-error-container center"> 9<div id="pageError" class="page-error-container center">
10 <h2>{$message}</h2> 10 <h2>{$message}</h2>
11 11
12 <img src="{$asset_path}/img/sad_star.png#" alt="">
13
14 {if="!empty($text)"}
15 <p>{$text}</p>
16 {/if}
17
12 {if="!empty($stacktrace)"} 18 {if="!empty($stacktrace)"}
13 <pre> 19 <pre>
14 {$stacktrace} 20 {$stacktrace}
15 </pre> 21 </pre>
16 {/if} 22 {/if}
17
18 <img src="{$asset_path}/img/sad_star.png#" alt="">
19</div> 23</div>
20{include="page.footer"} 24{include="page.footer"}
21</body> 25</body>
diff --git a/tpl/default/install.html b/tpl/default/install.html
index a506a2eb..4f98d49d 100644
--- a/tpl/default/install.html
+++ b/tpl/default/install.html
@@ -163,6 +163,16 @@
163 </div> 163 </div>
164</div> 164</div>
165</form> 165</form>
166
167<div class="pure-g">
168 <div class="pure-u-lg-1-6 pure-u-1-24"></div>
169 <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-form-complete">
170 <h2 class="window-title">{'Server requirements'|t}</h2>
171
172 {include="server.requirements"}
173 </div>
174</div>
175
166{include="page.footer"} 176{include="page.footer"}
167</body> 177</body>
168</html> 178</html>
diff --git a/tpl/default/linklist.html b/tpl/default/linklist.html
index beab0eac..7208a3b6 100644
--- a/tpl/default/linklist.html
+++ b/tpl/default/linklist.html
@@ -90,7 +90,7 @@
90 {'for'|t} <em><strong>{$search_term}</strong></em> 90 {'for'|t} <em><strong>{$search_term}</strong></em>
91 {/if} 91 {/if}
92 {if="!empty($search_tags)"} 92 {if="!empty($search_tags)"}
93 {$exploded_tags=explode(' ', $search_tags)} 93 {$exploded_tags=tags_str2array($search_tags, $tags_separator)}
94 {'tagged'|t} 94 {'tagged'|t}
95 {loop="$exploded_tags"} 95 {loop="$exploded_tags"}
96 <span class="label label-tag" title="{'Remove tag'|t}"> 96 <span class="label label-tag" title="{'Remove tag'|t}">
@@ -129,14 +129,19 @@
129 {$strAddTag=t('Add tag')} 129 {$strAddTag=t('Add tag')}
130 {$strToggleSticky=t('Toggle sticky')} 130 {$strToggleSticky=t('Toggle sticky')}
131 {$strSticky=t('Sticky')} 131 {$strSticky=t('Sticky')}
132 {$strShaarePrivate=t('Share a private link')}
132 {ignore}End of translations{/ignore} 133 {ignore}End of translations{/ignore}
133 {loop="links"} 134 {loop="links"}
134 <div class="anchor" id="{$value.shorturl}"></div> 135 <div class="anchor" id="{$value.shorturl}"></div>
135 136
136 <div class="linklist-item linklist-item{if="$value.class"} {$value.class}{/if}" data-id="{$value.id}"> 137 <div class="linklist-item linklist-item{if="$value.class"} {$value.class}{/if}" data-id="{$value.id}">
137 <div class="linklist-item-title"> 138 <div class="linklist-item-title">
138 {if="$thumbnails_enabled && !empty($value.thumbnail)"} 139 {if="$thumbnails_enabled && $value.thumbnail !== false"}
139 <div class="linklist-item-thumbnail" style="width:{$thumbnails_width}px;height:{$thumbnails_height}px;"> 140 <div
141 class="linklist-item-thumbnail {if="$value.thumbnail === null"}hidden{/if}"
142 style="width:{$thumbnails_width}px;height:{$thumbnails_height}px;"
143 {if="$value.thumbnail === null"}data-async-thumbnail="1"{/if}
144 >
140 <div class="thumbnail"> 145 <div class="thumbnail">
141 {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore} 146 {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
142 <a href="{$value.real_url}" aria-hidden="true" tabindex="-1"> 147 <a href="{$value.real_url}" aria-hidden="true" tabindex="-1">
@@ -158,7 +163,7 @@
158 </div> 163 </div>
159 164
160 <h2> 165 <h2>
161 <a href="{$value.real_url}"> 166 <a href="{$value.real_url}" class="linklist-real-url">
162 {if="strpos($value.url, $value.shorturl) === false"} 167 {if="strpos($value.url, $value.shorturl) === false"}
163 <i class="fa fa-external-link" aria-hidden="true"></i> 168 <i class="fa fa-external-link" aria-hidden="true"></i>
164 {else} 169 {else}
@@ -237,6 +242,12 @@
237 {$strPermalinkLc} 242 {$strPermalinkLc}
238 </a> 243 </a>
239 244
245 {if="$is_logged_in && $value.private"}
246 <a href="{$base_path}/admin/shaare/private/{$value.shorturl}?token={$token}" title="{$strShaarePrivate}">
247 <i class="fa fa-share-alt"></i>
248 </a>
249 {/if}
250
240 <div class="pure-u-0 pure-u-lg-visible"> 251 <div class="pure-u-0 pure-u-lg-visible">
241 {if="isset($value.link_plugin)"} 252 {if="isset($value.link_plugin)"}
242 &middot; 253 &middot;
@@ -308,5 +319,6 @@
308 319
309{include="page.footer"} 320{include="page.footer"}
310<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script> 321<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
322{if="$is_logged_in && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
311</body> 323</body>
312</html> 324</html>
diff --git a/tpl/default/page.footer.html b/tpl/default/page.footer.html
index c153def0..58ca18c5 100644
--- a/tpl/default/page.footer.html
+++ b/tpl/default/page.footer.html
@@ -18,8 +18,6 @@
18 <div class="pure-u-2-24"></div> 18 <div class="pure-u-2-24"></div>
19</div> 19</div>
20 20
21<input type="hidden" name="token" value="{$token}" id="token" />
22
23{loop="$plugins_footer.endofpage"} 21{loop="$plugins_footer.endofpage"}
24 {$value} 22 {$value}
25{/loop} 23{/loop}
@@ -28,16 +26,20 @@
28 <script src="{$root_path}/{$value}#"></script> 26 <script src="{$root_path}/{$value}#"></script>
29{/loop} 27{/loop}
30 28
31<div id="js-translations" class="hidden"> 29<div id="js-translations" class="hidden" aria-hidden="true">
32 <span id="translation-fold">{'Fold'|t}</span> 30 <span id="translation-fold">{'Fold'|t}</span>
33 <span id="translation-fold-all">{'Fold all'|t}</span> 31 <span id="translation-fold-all">{'Fold all'|t}</span>
34 <span id="translation-expand">{'Expand'|t}</span> 32 <span id="translation-expand">{'Expand'|t}</span>
35 <span id="translation-expand-all">{'Expand all'|t}</span> 33 <span id="translation-expand-all">{'Expand all'|t}</span>
36 <span id="translation-delete-link">{'Are you sure you want to delete this tag?'|t}</span> 34 <span id="translation-delete-link">{'Are you sure you want to delete this link?'|t}</span>
35 <span id="translation-delete-tag">{'Are you sure you want to delete this tag?'|t}</span>
37 <span id="translation-shaarli-desc"> 36 <span id="translation-shaarli-desc">
38 {'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t} 37 {'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t}
39 </span> 38 </span>
40</div> 39</div>
41 40
42<input type="hidden" name="js_base_path" value="{$base_path}" /> 41<input type="hidden" name="js_base_path" value="{$base_path}" />
42<input type="hidden" name="token" value="{$token}" id="token" />
43<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
44
43<script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script> 45<script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script>
diff --git a/tpl/default/pluginscontent.html b/tpl/default/pluginscontent.html
new file mode 100644
index 00000000..1e4f6b80
--- /dev/null
+++ b/tpl/default/pluginscontent.html
@@ -0,0 +1,13 @@
1<!DOCTYPE html>
2<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
3<head>
4 {include="includes"}
5</head>
6<body>
7 {include="page.header"}
8
9 {$content}
10
11 {include="page.footer"}
12</body>
13</html>
diff --git a/tpl/default/server.html b/tpl/default/server.html
new file mode 100644
index 00000000..de1c8b53
--- /dev/null
+++ b/tpl/default/server.html
@@ -0,0 +1,129 @@
1<!DOCTYPE html>
2<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
3<head>
4 {include="includes"}
5</head>
6<body>
7{include="page.header"}
8
9<div class="pure-g">
10 <div class="pure-u-lg-1-4 pure-u-1-24"></div>
11 <div class="pure-u-lg-1-2 pure-u-22-24 page-form server-tables-page">
12 <h2 class="window-title">{'Server administration'|t}</h2>
13
14 <h3 class="window-subtitle">{'General'|t}</h3>
15
16 <div class="pure-g server-row">
17 <div class="pure-u-lg-1-2 pure-u-1 server-label">
18 <p>{'Index URL'|t}</p>
19 </div>
20 <div class="pure-u-lg-1-2 pure-u-1">
21 <p><a href="{$index_url}" title="{$pagetitle}">{$index_url}</a></p>
22 </div>
23 </div>
24 <div class="pure-g server-row">
25 <div class="pure-u-lg-1-2 pure-u-1 server-label">
26 <p>{'Base path'|t}</p>
27 </div>
28 <div class="pure-u-lg-1-2 pure-u-1">
29 <p>{$base_path}</p>
30 </div>
31 </div>
32 <div class="pure-g server-row">
33 <div class="pure-u-lg-1-2 pure-u-1 server-label">
34 <p>{'Client IP'|t}</p>
35 </div>
36 <div class="pure-u-lg-1-2 pure-u-1">
37 <p>{$client_ip}</p>
38 </div>
39 </div>
40 <div class="pure-g server-row">
41 <div class="pure-u-lg-1-2 pure-u-1 server-label">
42 <p>{'Trusted reverse proxies'|t}</p>
43 </div>
44 <div class="pure-u-lg-1-2 pure-u-1">
45 {if="count($trusted_proxies) > 0"}
46 <p>
47 {loop="$trusted_proxies"}
48 {$value}<br>
49 {/loop}
50 </p>
51 {else}
52 <p>{'N/A'|t}</p>
53 {/if}
54 </div>
55 </div>
56
57 {include="server.requirements"}
58
59 <h3 class="window-subtitle">Version</h3>
60
61 <div class="pure-g server-row">
62 <div class="pure-u-lg-1-2 pure-u-1 server-label">
63 <p>Current version</p>
64 </div>
65 <div class="pure-u-lg-1-2 pure-u-1">
66 <p>{$current_version}</p>
67 </div>
68 </div>
69
70 <div class="pure-g server-row">
71 <div class="pure-u-lg-1-2 pure-u-1 server-label">
72 <p>Latest release</p>
73 </div>
74 <div class="pure-u-lg-1-2 pure-u-1">
75 <p>
76 <a href="{$release_url}" title="{'Visit releases page on Github'|t}">
77 {$latest_version}
78 </a>
79 </p>
80 </div>
81 </div>
82
83 <h3 class="window-subtitle">Thumbnails</h3>
84
85 <div class="pure-g server-row">
86 <div class="pure-u-lg-1-2 pure-u-1 server-label">
87 <p>Thumbnails status</p>
88 </div>
89 <div class="pure-u-lg-1-2 pure-u-1">
90 <p>
91 {if="$thumbnails_mode==='all'"}
92 {'All'|t}
93 {elseif="$thumbnails_mode==='common'"}
94 {'Only common media hosts'|t}
95 {else}
96 {'None'|t}
97 {/if}
98 </p>
99 </div>
100 </div>
101
102 {if="$thumbnails_mode!=='none'"}
103 <div class="center tools-item">
104 <a href="{$base_path}/admin/thumbnails" title="{'Synchronize all link thumbnails'|t}">
105 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
106 </a>
107 </div>
108 {/if}
109
110 <h3 class="window-subtitle">Cache</h3>
111
112 <div class="center tools-item">
113 <a href="{$base_path}/admin/clear-cache?type=main">
114 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Clear main cache</span>
115 </a>
116 </div>
117
118 <div class="center tools-item">
119 <a href="{$base_path}/admin/clear-cache?type=thumbnails">
120 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Clear thumbnails cache</span>
121 </a>
122 </div>
123 </div>
124</div>
125
126{include="page.footer"}
127
128</body>
129</html>
diff --git a/tpl/default/server.requirements.html b/tpl/default/server.requirements.html
new file mode 100644
index 00000000..85def9b7
--- /dev/null
+++ b/tpl/default/server.requirements.html
@@ -0,0 +1,68 @@
1<div class="server-tables">
2 <h3 class="window-subtitle">{'Permissions'|t}</h3>
3
4 {if="count($permissions) > 0"}
5 <p class="center">
6 <i class="fa fa-close fa-color-red" aria-hidden="true"></i>
7 {'There are permissions that need to be fixed.'|t}
8 </p>
9
10 <p>
11 {loop="$permissions"}
12 <div class="center">{$value}</div>
13 {/loop}
14 </p>
15 {else}
16 <p class="center">
17 <i class="fa fa-check fa-color-green" aria-hidden="true"></i>
18 {'All read/write permissions are properly set.'|t}
19 </p>
20 {/if}
21
22 <h3 class="window-subtitle">PHP</h3>
23
24 <p class="center">
25 <strong>{'Running PHP'|t} {$php_version}</strong>
26 {if="$php_has_reached_eol"}
27 <i class="fa fa-circle fa-color-orange" aria-label="hidden"></i><br>
28 {'End of life: '|t} {$php_eol}
29 {else}
30 <i class="fa fa-circle fa-color-green" aria-label="hidden"></i><br>
31 {/if}
32 </p>
33
34 <table class="center">
35 <thead>
36 <tr>
37 <th>{'Extension'|t}</th>
38 <th>{'Usage'|t}</th>
39 <th>{'Status'|t}</th>
40 <th>{'Loaded'|t}</th>
41 </tr>
42 </thead>
43 <tbody>
44 {loop="$php_extensions"}
45 <tr>
46 <td>{$value.name}</td>
47 <td>{$value.desc}</td>
48 <td>{$value.required ? t('Required') : t('Optional')}</td>
49 <td>
50 {if="$value.loaded"}
51 {$classLoaded="fa-color-green"}
52 {$strLoaded=t('Loaded')}
53 {else}
54 {$strLoaded=t('Not loaded')}
55 {if="$value.required"}
56 {$classLoaded="fa-color-red"}
57 {else}
58 {$classLoaded="fa-color-orange"}
59 {/if}
60 {/if}
61
62 <i class="fa fa-circle {$classLoaded}" aria-label="{$strLoaded}" title="{$strLoaded}"></i>
63 </td>
64 </tr>
65 {/loop}
66 </tbody>
67 </table>
68</div>
diff --git a/tpl/default/tag.cloud.html b/tpl/default/tag.cloud.html
index c067e1d4..01b50b02 100644
--- a/tpl/default/tag.cloud.html
+++ b/tpl/default/tag.cloud.html
@@ -48,7 +48,7 @@
48 48
49 <div id="cloudtag" class="cloudtag-container"> 49 <div id="cloudtag" class="cloudtag-container">
50 {loop="tags"} 50 {loop="tags"}
51 <a href="{$base_path}/?searchtags={$tags_url.$key1} {$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a 51 <a href="{$base_path}/?searchtags={$tags_url.$key1}{$tags_separator|urlencode}{$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a
52 ><a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value.count}</a> 52 ><a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
53 {loop="$value.tag_plugin"} 53 {loop="$value.tag_plugin"}
54 {$value} 54 {$value}
diff --git a/tpl/default/tools.html b/tpl/default/tools.html
index 2cb08e38..2df73598 100644
--- a/tpl/default/tools.html
+++ b/tpl/default/tools.html
@@ -20,6 +20,12 @@
20 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Plugin administration'|t}</span> 20 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Plugin administration'|t}</span>
21 </a> 21 </a>
22 </div> 22 </div>
23 <div class="tools-item">
24 <a href="{$base_path}/admin/server"
25 title="{'Check instance\'s server configuration'|t}">
26 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Server administration'|t}</span>
27 </a>
28 </div>
23 {if="!$openshaarli"} 29 {if="!$openshaarli"}
24 <div class="tools-item"> 30 <div class="tools-item">
25 <a href="{$base_path}/admin/password" title="{'Change your password'|t}"> 31 <a href="{$base_path}/admin/password" title="{'Change your password'|t}">
@@ -45,14 +51,6 @@
45 </a> 51 </a>
46 </div> 52 </div>
47 53
48 {if="$thumbnails_enabled"}
49 <div class="tools-item">
50 <a href="{$base_path}/admin/thumbnails" title="{'Synchronize all link thumbnails'|t}">
51 <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
52 </a>
53 </div>
54 {/if}
55
56 {loop="$tools_plugin"} 54 {loop="$tools_plugin"}
57 <div class="tools-item"> 55 <div class="tools-item">
58 {$value} 56 {$value}
diff --git a/tpl/vintage/daily.html b/tpl/vintage/daily.html
index 74f6cdc7..28ba9f90 100644
--- a/tpl/vintage/daily.html
+++ b/tpl/vintage/daily.html
@@ -14,9 +14,9 @@
14 14
15 <div class="dailyAbout"> 15 <div class="dailyAbout">
16 All links of one day<br>in a single page.<br> 16 All links of one day<br>in a single page.<br>
17 {if="$previousday"} <a href="{$base_path}/daily&amp;day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if} 17 {if="$previousday"} <a href="{$base_path}/daily?day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if}
18 - 18 -
19 {if="$nextday"}<a href="{$base_path}/daily&amp;day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</b>{/if} 19 {if="$nextday"}<a href="{$base_path}/daily?day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</b>{/if}
20 <br> 20 <br>
21 21
22 {loop="$daily_about_plugin"} 22 {loop="$daily_about_plugin"}
@@ -52,13 +52,13 @@
52 {$link=$value} 52 {$link=$value}
53 <div class="dailyEntry"> 53 <div class="dailyEntry">
54 <div class="dailyEntryPermalink"> 54 <div class="dailyEntryPermalink">
55 <a href="{$base_path}/?{$value.shorturl}"> 55 <a href="{$base_path}/shaare/{$value.shorturl}">
56 <img src="{$asset_path}/img/squiggle.png#" width="25" height="26" title="permalink" alt="permalink"> 56 <img src="{$asset_path}/img/squiggle.png#" width="25" height="26" title="permalink" alt="permalink">
57 </a> 57 </a>
58 </div> 58 </div>
59 {if="!$hide_timestamps || $is_logged_in"} 59 {if="!$hide_timestamps || $is_logged_in"}
60 <div class="dailyEntryLinkdate"> 60 <div class="dailyEntryLinkdate">
61 <a href="{$base_path}/?{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a> 61 <a href="{$base_path}/shaare/{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a>
62 </div> 62 </div>
63 {/if} 63 {/if}
64 {if="$link.tags"} 64 {if="$link.tags"}
diff --git a/tpl/vintage/editlink.html b/tpl/vintage/editlink.html
index eb8807b5..343418bc 100644
--- a/tpl/vintage/editlink.html
+++ b/tpl/vintage/editlink.html
@@ -6,6 +6,7 @@
6{if="$link.title==''"}onload="document.linkform.lf_title.focus();" 6{if="$link.title==''"}onload="document.linkform.lf_title.focus();"
7{elseif="$link.description==''"}onload="document.linkform.lf_description.focus();" 7{elseif="$link.description==''"}onload="document.linkform.lf_description.focus();"
8{else}onload="document.linkform.lf_tags.focus();"{/if} > 8{else}onload="document.linkform.lf_tags.focus();"{/if} >
9{$asyncLoadClass=$link_is_new && $async_metadata && empty($link.title) ? 'loading-input' : ''}
9<div id="pageheader"> 10<div id="pageheader">
10 {include="page.header"} 11 {include="page.header"}
11 <div id="shaarli_title"><a href="{$titleLink}">{$shaarlititle}</a></div> 12 <div id="shaarli_title"><a href="{$titleLink}">{$shaarlititle}</a></div>
@@ -14,12 +15,29 @@
14 {if="isset($link.id)"} 15 {if="isset($link.id)"}
15 <input type="hidden" name="lf_id" value="{$link.id}"> 16 <input type="hidden" name="lf_id" value="{$link.id}">
16 {/if} 17 {/if}
17 <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> 18 <label for="lf_url"><i>URL</i></label><br><input type="text" name="lf_url" id="lf_url" value="{$link.url}" class="lf_input">
18 <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> 19 <label for="lf_title"><i>Title</i></label>
19 <label for="lf_description"><i>Description</i></label><br><textarea name="lf_description" id="lf_description" rows="4" cols="25">{$link.description}</textarea><br> 20 <div class="{$asyncLoadClass}">
20 <label for="lf_tags"><i>Tags</i></label><br> 21 <input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input">
21 <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input" 22 <div class="icon-container">
22 data-list="{loop="$tags"}{$key}, {/loop}" data-multiple autocomplete="off" ><br> 23 <i class="loader"></i>
24 </div>
25 </div>
26 <label for="lf_description"><i>Description</i></label>
27 <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
28 <textarea name="lf_description" id="lf_description" rows="4" cols="25">{$link.description}</textarea>
29 <div class="icon-container">
30 <i class="loader"></i>
31 </div>
32 </div>
33 <label for="lf_tags"><i>Tags</i></label>
34 <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
35 <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input"
36 data-list="{loop="$tags"}{$key}, {/loop}" data-multiple autocomplete="off" >
37 <div class="icon-container">
38 <i class="loader"></i>
39 </div>
40 </div>
23 41
24 {if="$formatter==='markdown'"} 42 {if="$formatter==='markdown'"}
25 <div class="md_help"> 43 <div class="md_help">
@@ -56,5 +74,5 @@
56 </div> 74 </div>
57</div> 75</div>
58{include="page.footer"} 76{include="page.footer"}
59</body> 77{if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}</body>
60</html> 78</html>
diff --git a/tpl/vintage/includes.html b/tpl/vintage/includes.html
index eac05701..2ce9da42 100644
--- a/tpl/vintage/includes.html
+++ b/tpl/vintage/includes.html
@@ -5,13 +5,13 @@
5<meta name="referrer" content="same-origin"> 5<meta name="referrer" content="same-origin">
6<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" /> 6<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" />
7<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" /> 7<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" />
8<link href="img/favicon.ico" rel="shortcut icon" type="image/x-icon" /> 8<link href="{$asset_path}/img/favicon.ico#" rel="shortcut icon" type="image/x-icon" />
9<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css#" /> 9<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css#" />
10{if="$formatter==='markdown'"} 10{if="$formatter==='markdown'"}
11 <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" /> 11 <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
12{/if} 12{/if}
13{loop="$plugins_includes.css_files"} 13{loop="$plugins_includes.css_files"}
14<link type="text/css" rel="stylesheet" href="{$base_path}/{$value}#"/> 14<link type="text/css" rel="stylesheet" href="{$root_path}/{$value}#"/>
15{/loop} 15{/loop}
16{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />{/if} 16{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />{/if}
17<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#" 17<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
diff --git a/tpl/vintage/linklist.html b/tpl/vintage/linklist.html
index 00896eb5..ff0dd40c 100644
--- a/tpl/vintage/linklist.html
+++ b/tpl/vintage/linklist.html
@@ -61,7 +61,7 @@
61 for <em>{$search_term}</em> 61 for <em>{$search_term}</em>
62 {/if} 62 {/if}
63 {if="!empty($search_tags)"} 63 {if="!empty($search_tags)"}
64 {$exploded_tags=explode(' ', $search_tags)} 64 {$exploded_tags=tags_str2array($search_tags, $tags_separator)}
65 tagged 65 tagged
66 {loop="$exploded_tags"} 66 {loop="$exploded_tags"}
67 <span class="linktag" title="Remove tag"> 67 <span class="linktag" title="Remove tag">
@@ -77,10 +77,10 @@
77 {/if} 77 {/if}
78 <ul> 78 <ul>
79 {loop="$links"} 79 {loop="$links"}
80 <li{if="$value.class"} class="{$value.class}"{/if}> 80 <li{if="$value.class"} class="{$value.class}"{/if} data-id="{$value.id}">
81 <a id="{$value.shorturl}"></a> 81 <a id="{$value.shorturl}"></a>
82 {if="$thumbnails_enabled && !empty($value.thumbnail)"} 82 {if="$thumbnails_enabled && $value.thumbnail !== false"}
83 <div class="thumbnail"> 83 <div class="thumbnail" {if="$value.thumbnail === null"}data-async-thumbnail="1"{/if}>
84 <a href="{$value.real_url}"> 84 <a href="{$value.real_url}">
85 {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore} 85 {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
86 <img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy" 86 <img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy"
@@ -153,6 +153,7 @@
153 153
154 {include="page.footer"} 154 {include="page.footer"}
155<script src="{$asset_path}/js/thumbnails.min.js#"></script> 155<script src="{$asset_path}/js/thumbnails.min.js#"></script>
156{if="$is_logged_in && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
156 157
157</body> 158</body>
158</html> 159</html>
diff --git a/tpl/vintage/page.footer.html b/tpl/vintage/page.footer.html
index 0fe4c736..be709aeb 100644
--- a/tpl/vintage/page.footer.html
+++ b/tpl/vintage/page.footer.html
@@ -23,8 +23,6 @@
23</div> 23</div>
24{/if} 24{/if}
25 25
26<script src="{$asset_path}/js/shaarli.min.js#"></script>
27
28{if="$is_logged_in"} 26{if="$is_logged_in"}
29<script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script> 27<script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script>
30{/if} 28{/if}
@@ -34,3 +32,7 @@
34{/loop} 32{/loop}
35 33
36<input type="hidden" name="js_base_path" value="{$base_path}" /> 34<input type="hidden" name="js_base_path" value="{$base_path}" />
35<input type="hidden" name="token" value="{$token}" id="token" />
36<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
37
38<script src="{$asset_path}/js/shaarli.min.js#"></script>
diff --git a/tpl/vintage/page.header.html b/tpl/vintage/page.header.html
index 0a33523b..64d7f656 100644
--- a/tpl/vintage/page.header.html
+++ b/tpl/vintage/page.header.html
@@ -54,6 +54,30 @@
54 </ul> 54 </ul>
55{/if} 55{/if}
56 56
57{if="!empty($global_errors)"}
58 <ul class="errors">
59 {loop="$global_errors"}
60 <li>{$value}</li>
61 {/loop}
62 </ul>
63{/if}
64
65{if="!empty($global_warnings)"}
66 <ul class="warnings">
67 {loop="$global_warnings"}
68 <li>{$value}</li>
69 {/loop}
70 </ul>
71{/if}
72
73{if="!empty($global_successes)"}
74 <ul class="successes">
75 {loop="$global_successes"}
76 <li>{$value}</li>
77 {/loop}
78 </ul>
79{/if}
80
57<div class="clear"></div> 81<div class="clear"></div>
58 82
59 83
diff --git a/webpack.config.js b/webpack.config.js
index a73758cc..2c316d32 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -18,8 +18,10 @@ module.exports = [
18 { 18 {
19 mode: 'production', 19 mode: 'production',
20 entry: { 20 entry: {
21 shaare_batch: './assets/common/js/shaare-batch.js',
21 thumbnails: './assets/common/js/thumbnails.js', 22 thumbnails: './assets/common/js/thumbnails.js',
22 thumbnails_update: './assets/common/js/thumbnails-update.js', 23 thumbnails_update: './assets/common/js/thumbnails-update.js',
24 metadata: './assets/common/js/metadata.js',
23 pluginsadmin: './assets/default/js/plugins-admin.js', 25 pluginsadmin: './assets/default/js/plugins-admin.js',
24 shaarli: [ 26 shaarli: [
25 './assets/default/js/base.js', 27 './assets/default/js/base.js',
@@ -99,6 +101,7 @@ module.exports = [
99 ].concat(glob.sync('./assets/vintage/img/*')), 101 ].concat(glob.sync('./assets/vintage/img/*')),
100 markdown: './assets/common/css/markdown.css', 102 markdown: './assets/common/css/markdown.css',
101 thumbnails: './assets/common/js/thumbnails.js', 103 thumbnails: './assets/common/js/thumbnails.js',
104 metadata: './assets/common/js/metadata.js',
102 thumbnails_update: './assets/common/js/thumbnails-update.js', 105 thumbnails_update: './assets/common/js/thumbnails-update.js',
103 }, 106 },
104 output: { 107 output: {
@@ -139,7 +142,8 @@ module.exports = [
139 loader: 'file-loader', 142 loader: 'file-loader',
140 options: { 143 options: {
141 name: '../img/[name].[ext]', 144 name: '../img/[name].[ext]',
142 publicPath: '', 145 // do not add a publicPath here because it's already handled by CSS's publicPath
146 publicPath: '../vintage',
143 } 147 }
144 } 148 }
145 ], 149 ],
diff --git a/yarn.lock b/yarn.lock
index 0a12820c..97fb0fad 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2912,6 +2912,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
2912 inherits "^2.0.3" 2912 inherits "^2.0.3"
2913 minimalistic-assert "^1.0.1" 2913 minimalistic-assert "^1.0.1"
2914 2914
2915he@^1.2.0:
2916 version "1.2.0"
2917 resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
2918 integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
2919
2915hmac-drbg@^1.0.0: 2920hmac-drbg@^1.0.0:
2916 version "1.0.1" 2921 version "1.0.1"
2917 resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" 2922 resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@@ -3047,9 +3052,9 @@ inherits@2.0.3:
3047 integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= 3052 integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
3048 3053
3049ini@^1.3.4, ini@^1.3.5: 3054ini@^1.3.4, ini@^1.3.5:
3050 version "1.3.5" 3055 version "1.3.7"
3051 resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" 3056 resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.7.tgz#a09363e1911972ea16d7a8851005d84cf09a9a84"
3052 integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== 3057 integrity sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==
3053 3058
3054interpret@^1.4.0: 3059interpret@^1.4.0:
3055 version "1.4.0" 3060 version "1.4.0"