]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #1616 from dimtion/fix-api-redirect
authorArthurHoaro <arthur@hoa.ro>
Thu, 29 Oct 2020 15:03:07 +0000 (16:03 +0100)
committerGitHub <noreply@github.com>
Thu, 29 Oct 2020 15:03:07 +0000 (16:03 +0100)
API postLink: change relative path to absolute path

380 files changed:
.dev/.sasslintrc [deleted file]
.dev/.stylelintrc.js [new file with mode: 0644]
.docker/nginx.conf
.editorconfig
.github/mailmap
.htaccess
.travis.yml
AUTHORS
CHANGELOG.md
Dockerfile
Makefile
README.md
application/History.php
application/Languages.php
application/Router.php [deleted file]
application/Thumbnailer.php
application/Utils.php
application/api/ApiMiddleware.php
application/api/ApiUtils.php
application/api/controllers/ApiController.php
application/api/controllers/Links.php
application/bookmark/Bookmark.php
application/bookmark/BookmarkArray.php
application/bookmark/BookmarkFileService.php
application/bookmark/BookmarkFilter.php
application/bookmark/BookmarkIO.php
application/bookmark/BookmarkInitializer.php
application/bookmark/BookmarkServiceInterface.php
application/bookmark/LinkUtils.php
application/bookmark/exception/DatastoreNotInitializedException.php [new file with mode: 0644]
application/config/ConfigJson.php
application/config/ConfigManager.php
application/config/ConfigPlugin.php
application/container/ContainerBuilder.php
application/container/ShaarliContainer.php
application/feed/Cache.php [deleted file]
application/feed/FeedBuilder.php
application/formatter/BookmarkDefaultFormatter.php
application/formatter/BookmarkFormatter.php
application/formatter/BookmarkMarkdownExtraFormatter.php [new file with mode: 0644]
application/formatter/BookmarkMarkdownFormatter.php
application/formatter/FormatterFactory.php
application/front/ShaarliAdminMiddleware.php [new file with mode: 0644]
application/front/ShaarliMiddleware.php
application/front/controller/admin/ConfigureController.php [new file with mode: 0644]
application/front/controller/admin/ExportController.php [new file with mode: 0644]
application/front/controller/admin/ImportController.php [new file with mode: 0644]
application/front/controller/admin/LogoutController.php [new file with mode: 0644]
application/front/controller/admin/ManageTagController.php [new file with mode: 0644]
application/front/controller/admin/MetadataController.php [new file with mode: 0644]
application/front/controller/admin/PasswordController.php [new file with mode: 0644]
application/front/controller/admin/PluginsController.php [new file with mode: 0644]
application/front/controller/admin/ServerController.php [new file with mode: 0644]
application/front/controller/admin/SessionFilterController.php [new file with mode: 0644]
application/front/controller/admin/ShaareAddController.php [new file with mode: 0644]
application/front/controller/admin/ShaareManageController.php [new file with mode: 0644]
application/front/controller/admin/ShaarePublishController.php [new file with mode: 0644]
application/front/controller/admin/ShaarliAdminController.php [new file with mode: 0644]
application/front/controller/admin/ThumbnailsController.php [new file with mode: 0644]
application/front/controller/admin/TokenController.php [new file with mode: 0644]
application/front/controller/admin/ToolsController.php [new file with mode: 0644]
application/front/controller/visitor/BookmarkListController.php [new file with mode: 0644]
application/front/controller/visitor/DailyController.php [new file with mode: 0644]
application/front/controller/visitor/ErrorController.php [new file with mode: 0644]
application/front/controller/visitor/ErrorNotFoundController.php [new file with mode: 0644]
application/front/controller/visitor/FeedController.php [new file with mode: 0644]
application/front/controller/visitor/InstallController.php [new file with mode: 0644]
application/front/controller/visitor/LoginController.php [new file with mode: 0644]
application/front/controller/visitor/OpenSearchController.php [new file with mode: 0644]
application/front/controller/visitor/PictureWallController.php [new file with mode: 0644]
application/front/controller/visitor/PublicSessionFilterController.php [new file with mode: 0644]
application/front/controller/visitor/ShaarliVisitorController.php [new file with mode: 0644]
application/front/controller/visitor/TagCloudController.php [new file with mode: 0644]
application/front/controller/visitor/TagController.php [new file with mode: 0644]
application/front/controllers/LoginController.php [deleted file]
application/front/controllers/ShaarliController.php [deleted file]
application/front/exceptions/AlreadyInstalledException.php [new file with mode: 0644]
application/front/exceptions/CantLoginException.php [new file with mode: 0644]
application/front/exceptions/LoginBannedException.php
application/front/exceptions/OpenShaarliPasswordException.php [new file with mode: 0644]
application/front/exceptions/ResourcePermissionException.php [new file with mode: 0644]
application/front/exceptions/ShaarliFrontException.php [moved from application/front/exceptions/ShaarliException.php with 73% similarity]
application/front/exceptions/ThumbnailsDisabledException.php [new file with mode: 0644]
application/front/exceptions/UnauthorizedException.php [new file with mode: 0644]
application/front/exceptions/WrongTokenException.php [new file with mode: 0644]
application/helper/ApplicationUtils.php [moved from application/ApplicationUtils.php with 69% similarity]
application/helper/DailyPageHelper.php [new file with mode: 0644]
application/helper/FileUtils.php [moved from application/FileUtils.php with 57% similarity]
application/http/HttpAccess.php [new file with mode: 0644]
application/http/HttpUtils.php
application/http/MetadataRetriever.php [new file with mode: 0644]
application/legacy/LegacyController.php [new file with mode: 0644]
application/legacy/LegacyLinkDB.php
application/legacy/LegacyRouter.php [new file with mode: 0644]
application/legacy/LegacyUpdater.php
application/legacy/UnknowLegacyRouteException.php [new file with mode: 0644]
application/netscape/NetscapeBookmarkUtils.php
application/plugin/PluginManager.php
application/render/PageBuilder.php
application/render/PageCacheManager.php [new file with mode: 0644]
application/render/TemplatePage.php [new file with mode: 0644]
application/security/BanManager.php
application/security/CookieManager.php [new file with mode: 0644]
application/security/LoginManager.php
application/security/SessionManager.php
application/updater/Updater.php
assets/common/js/metadata.js [new file with mode: 0644]
assets/common/js/shaare-batch.js [new file with mode: 0644]
assets/common/js/thumbnails-update.js
assets/default/js/base.js
assets/default/scss/shaarli.scss
assets/vintage/css/shaarli.css
composer.json
composer.lock
doc/md/3rd-party-libraries.md [deleted file]
doc/md/Backup-and-restore.md [new file with mode: 0644]
doc/md/Browsing-and-searching.md [deleted file]
doc/md/Community-and-related-software.md [moved from doc/md/Community-&-Related-software.md with 74% similarity]
doc/md/Continuous-integration-tools.md [deleted file]
doc/md/Development-guidelines.md [deleted file]
doc/md/Directory-structure.md [deleted file]
doc/md/Docker.md [new file with mode: 0644]
doc/md/Download-and-Installation.md [deleted file]
doc/md/FAQ.md [deleted file]
doc/md/Installation.md [new file with mode: 0644]
doc/md/Link-structure.md [deleted file]
doc/md/Plugins.md
doc/md/REST-API.md
doc/md/RSS-feeds.md [deleted file]
doc/md/Release-Shaarli.md [deleted file]
doc/md/Reverse-proxy.md [new file with mode: 0644]
doc/md/Security.md [deleted file]
doc/md/Server-configuration.md
doc/md/Server-security.md [deleted file]
doc/md/Shaarli-configuration.md
doc/md/Sharing-content.md [deleted file]
doc/md/Static-analysis.md [deleted file]
doc/md/Troubleshooting.md
doc/md/Unit-tests.md [deleted file]
doc/md/Upgrade-and-migration.md
doc/md/Usage.md [new file with mode: 0644]
doc/md/dev/Development.md [new file with mode: 0644]
doc/md/dev/GnuPG-signature.md [moved from doc/md/GnuPG-signature.md with 66% similarity]
doc/md/dev/Plugin-system.md [moved from doc/md/Plugin-System.md with 81% similarity]
doc/md/dev/Release-Shaarli.md [new file with mode: 0644]
doc/md/dev/Theming.md [moved from doc/md/Theming.md with 95% similarity]
doc/md/dev/Translations.md [moved from doc/md/Translations.md with 57% similarity]
doc/md/dev/Unit-tests.md [new file with mode: 0644]
doc/md/dev/Versioning.md [moved from doc/md/Versioning-and-Branches.md with 58% similarity]
doc/md/dev/images/poedit-1.jpg [moved from doc/md/images/poedit-1.jpg with 100% similarity]
doc/md/docker/docker-101.md [deleted file]
doc/md/docker/resources.md [deleted file]
doc/md/docker/reverse-proxy-configuration.md [deleted file]
doc/md/docker/shaarli-images.md [deleted file]
doc/md/guides/backup-restore-import-export.md [deleted file]
doc/md/guides/images/01-create-droplet-distro.jpg [deleted file]
doc/md/guides/images/02-create-droplet-region.jpg [deleted file]
doc/md/guides/images/03-create-droplet-size.jpg [deleted file]
doc/md/guides/images/04-finalize.jpg [deleted file]
doc/md/guides/images/05-droplet.jpg [deleted file]
doc/md/guides/images/06-domain.jpg [deleted file]
doc/md/guides/install-shaarli-with-debian9-and-docker.md [deleted file]
doc/md/guides/various-hacks.md [deleted file]
doc/md/images/07-installation.jpg [moved from doc/md/guides/images/07-installation.jpg with 100% similarity]
doc/md/images/bookmarklet.png [deleted file]
doc/md/images/firefoxshare.png [deleted file]
doc/md/images/install-shaarli.png [deleted file]
doc/md/index.md
docker-compose.yml
inc/languages/fr/LC_MESSAGES/shaarli.po
inc/languages/ja/LC_MESSAGES/shaarli.po [deleted file]
inc/languages/jp/LC_MESSAGES/shaarli.po [new file with mode: 0644]
index.php
init.php [new file with mode: 0644]
mkdocs.yml
package.json
plugins/addlink_toolbar/addlink_toolbar.php
plugins/archiveorg/archiveorg.html
plugins/archiveorg/archiveorg.php
plugins/default_colors/default_colors.php
plugins/demo_plugin/demo_plugin.php
plugins/isso/isso.php
plugins/isso/isso_button.html [deleted file]
plugins/playvideos/README.md
plugins/playvideos/playvideos.php
plugins/pubsubhubbub/pubsubhubbub.php
plugins/qrcode/qrcode.php
plugins/qrcode/shaarli-qrcode.js
plugins/wallabag/README.md
plugins/wallabag/wallabag.php
tests/FileUtilsTest.php [deleted file]
tests/HistoryTest.php
tests/LanguagesTest.php
tests/PluginManagerTest.php
tests/RouterTest.php [deleted file]
tests/TestCase.php [new file with mode: 0644]
tests/ThumbnailerTest.php
tests/TimeZoneTest.php
tests/UtilsTest.php
tests/api/ApiMiddlewareTest.php
tests/api/ApiUtilsTest.php
tests/api/controllers/history/HistoryTest.php
tests/api/controllers/info/InfoTest.php
tests/api/controllers/links/DeleteLinkTest.php
tests/api/controllers/links/GetLinkIdTest.php
tests/api/controllers/links/GetLinksTest.php
tests/api/controllers/links/PostLinkTest.php
tests/api/controllers/links/PutLinkTest.php
tests/api/controllers/tags/DeleteTagTest.php
tests/api/controllers/tags/GetTagNameTest.php
tests/api/controllers/tags/GetTagsTest.php
tests/api/controllers/tags/PutTagTest.php
tests/bookmark/BookmarkArrayTest.php
tests/bookmark/BookmarkFileServiceTest.php
tests/bookmark/BookmarkFilterTest.php
tests/bookmark/BookmarkInitializerTest.php
tests/bookmark/BookmarkTest.php
tests/bookmark/LinkUtilsTest.php
tests/bootstrap.php
tests/config/ConfigJsonTest.php
tests/config/ConfigManagerTest.php
tests/config/ConfigPhpTest.php
tests/config/ConfigPluginTest.php
tests/container/ContainerBuilderTest.php
tests/container/ShaarliTestContainer.php [new file with mode: 0644]
tests/feed/CachedPageTest.php
tests/feed/FeedBuilderTest.php
tests/formatter/BookmarkDefaultFormatterTest.php
tests/formatter/BookmarkMarkdownExtraFormatterTest.php [new file with mode: 0644]
tests/formatter/BookmarkMarkdownFormatterTest.php
tests/formatter/BookmarkRawFormatterTest.php
tests/formatter/FormatterFactoryTest.php
tests/front/ShaarliAdminMiddlewareTest.php [new file with mode: 0644]
tests/front/ShaarliMiddlewareTest.php
tests/front/controller/LoginControllerTest.php [deleted file]
tests/front/controller/ShaarliControllerTest.php [deleted file]
tests/front/controller/admin/ConfigureControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ExportControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/FrontAdminControllerMockHelper.php [new file with mode: 0644]
tests/front/controller/admin/ImportControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/LogoutControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ManageTagControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/PasswordControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/PluginsControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ServerControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/SessionFilterControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareAddControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarliAdminControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ThumbnailsControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/TokenControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ToolsControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/BookmarkListControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/DailyControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/ErrorControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/ErrorNotFoundControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/FeedControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/FrontControllerMockHelper.php [new file with mode: 0644]
tests/front/controller/visitor/InstallControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/LoginControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/OpenSearchControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/PictureWallControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/PublicSessionFilterControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/ShaarliVisitorControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/TagCloudControllerTest.php [new file with mode: 0644]
tests/front/controller/visitor/TagControllerTest.php [new file with mode: 0644]
tests/helper/ApplicationUtilsTest.php [moved from tests/ApplicationUtilsTest.php with 77% similarity]
tests/helper/DailyPageHelperTest.php [new file with mode: 0644]
tests/helper/FileUtilsTest.php [new file with mode: 0644]
tests/http/HttpUtils/ClientIpIdTest.php
tests/http/HttpUtils/GetHttpUrlTest.php
tests/http/HttpUtils/GetIpAdressFromProxyTest.php
tests/http/HttpUtils/IndexUrlTest.php
tests/http/HttpUtils/IndexUrlTestWithConstant.php [new file with mode: 0644]
tests/http/HttpUtils/IsHttpsTest.php
tests/http/HttpUtils/PageUrlTest.php
tests/http/HttpUtils/ServerUrlTest.php
tests/http/MetadataRetrieverTest.php [new file with mode: 0644]
tests/http/UrlTest.php
tests/http/UrlUtils/CleanupUrlTest.php
tests/http/UrlUtils/GetUrlSchemeTest.php
tests/http/UrlUtils/UnparseUrlTest.php
tests/http/UrlUtils/WhitelistProtocolsTest.php
tests/languages/fr/LanguagesFrTest.php
tests/legacy/LegacyControllerTest.php [new file with mode: 0644]
tests/legacy/LegacyLinkDBTest.php
tests/legacy/LegacyLinkFilterTest.php
tests/legacy/LegacyUpdaterTest.php
tests/netscape/BookmarkExportTest.php
tests/netscape/BookmarkImportTest.php
tests/plugins/PluginAddlinkTest.php
tests/plugins/PluginArchiveorgTest.php
tests/plugins/PluginDefaultColorsTest.php
tests/plugins/PluginIssoTest.php
tests/plugins/PluginPlayvideosTest.php
tests/plugins/PluginPubsubhubbubTest.php
tests/plugins/PluginQrcodeTest.php
tests/plugins/PluginWallabagTest.php
tests/plugins/WallabagInstanceTest.php
tests/plugins/resources/hashtags.md [deleted file]
tests/plugins/resources/hashtags.raw [deleted file]
tests/plugins/resources/markdown.html [deleted file]
tests/plugins/resources/markdown.md [deleted file]
tests/plugins/test/test.php
tests/render/PageCacheManagerTest.php [moved from tests/feed/CacheTest.php with 65% similarity]
tests/render/ThemeUtilsTest.php
tests/security/BanManagerTest.php
tests/security/LoginManagerTest.php
tests/security/SessionManagerTest.php
tests/updater/DummyUpdater.php
tests/updater/UpdaterTest.php
tests/utils/FakeApplicationUtils.php
tests/utils/FakeConfigManager.php
tests/utils/ReferenceHistory.php
tests/utils/ReferenceLinkDB.php
tpl/default/404.html
tpl/default/addlink.html
tpl/default/changepassword.html
tpl/default/changetag.html
tpl/default/configure.html
tpl/default/daily.html
tpl/default/dailyrss.html
tpl/default/editlink.batch.html [new file with mode: 0644]
tpl/default/editlink.html
tpl/default/error.html
tpl/default/export.html
tpl/default/feed.atom.html
tpl/default/feed.rss.html
tpl/default/import.html
tpl/default/includes.html
tpl/default/install.html
tpl/default/linklist.html
tpl/default/linklist.paging.html
tpl/default/opensearch.html
tpl/default/page.footer.html
tpl/default/page.header.html
tpl/default/picwall.html
tpl/default/pluginsadmin.html
tpl/default/server.html [new file with mode: 0644]
tpl/default/server.requirements.html [new file with mode: 0644]
tpl/default/tag.cloud.html
tpl/default/tag.list.html
tpl/default/tag.sort.html
tpl/default/thumbnails.html
tpl/default/tools.html
tpl/vintage/404.html
tpl/vintage/addlink.html
tpl/vintage/changepassword.html
tpl/vintage/changetag.html
tpl/vintage/configure.html
tpl/vintage/daily.html
tpl/vintage/dailyrss.html
tpl/vintage/editlink.html
tpl/vintage/error.html
tpl/vintage/export.html
tpl/vintage/feed.atom.html
tpl/vintage/feed.rss.html
tpl/vintage/import.html
tpl/vintage/includes.html
tpl/vintage/install.html
tpl/vintage/linklist.html
tpl/vintage/linklist.paging.html
tpl/vintage/loginform.html
tpl/vintage/opensearch.html
tpl/vintage/page.footer.html
tpl/vintage/page.header.html
tpl/vintage/picwall.html
tpl/vintage/pluginsadmin.html
tpl/vintage/tag.cloud.html
tpl/vintage/thumbnails.html
tpl/vintage/tools.html
webpack.config.js
yarn.lock

diff --git a/.dev/.sasslintrc b/.dev/.sasslintrc
deleted file mode 100644 (file)
index 47c3145..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-options:
-  max-warnings: 0
-rules:
-  property-sort-order:
-    - 0
-# Sort order rule does not work with CSS variables: https://github.com/sasstools/sass-lint/issues/1161
-#    - 1
-#    -
-#      order: 'concentric'
-  no-important:
-    - 0
-  no-vendor-prefixes:
-    - 0 # this will be fixed with v2: see https://github.com/sasstools/sass-lint/pull/1137
-  nesting-depth:
-    - 1
-    -
-      max-depth: 4
diff --git a/.dev/.stylelintrc.js b/.dev/.stylelintrc.js
new file mode 100644 (file)
index 0000000..a754e33
--- /dev/null
@@ -0,0 +1,15 @@
+module.exports = {
+  extends: 'stylelint-config-standard',
+  plugins: [
+    "stylelint-scss"
+  ],
+  rules: {
+    "indentation": [2],
+    "number-leading-zero": null,
+    // Replace CSS @ with SASS ones
+    "at-rule-no-unknown": null,
+    "scss/at-rule-no-unknown": true,
+    // not compatible with SASS apparently
+    "no-descending-specificity": null
+  },
+}
index 07fba33fec11bcbf90741c086586550a0b86c57c..023f52c1d1be5f8502c0aad2567a054b7216eb05 100644 (file)
@@ -29,7 +29,7 @@ http {
             log_not_found off;
             deny all;
         }
-        
+
         location ~ ~$ {
             # deny access to temp editor files, e.g. "script.php~"
             access_log off;
@@ -65,6 +65,11 @@ http {
             include        fastcgi.conf;
         }
 
+        location ~ /doc/ {
+            default_type "text/html";
+            try_files $uri $uri/ $uri.html =404;
+        }
+
         location ~ \.php$ {
             # deny access to all other PHP scripts
             deny all;
index 34bd7994d68f53311b9b961692db7ba407503b76..c2ab80ebc8bc5e105ce209e23aa5cc3dcb84006f 100644 (file)
@@ -14,7 +14,7 @@ indent_size = 4
 indent_size = 2
 
 [*.php]
-max_line_length = 100
+max_line_length = 120
 
 [Dockerfile]
 max_line_length = 80
index 7633afcf23829481372b4810045db893ec4feaf5..15a25e43cbb562f1f96fb2b6a39e6d234678c39d 100644 (file)
@@ -1,13 +1,18 @@
-ArthurHoaro <arthur@hoa.ro>
+ArthurHoaro <arthur@hoa.ro> <arthur.hoareau@wizacha.com>
+ArthurHoaro <arthur@hoa.ro> Arthur
 Florian Eula <eula.florian@gmail.com> feula
 Florian Eula <eula.florian@gmail.com> <mr.pikzen@gmail.com>
 Immánuel Fodor <immanuelfactor+github@gmail.com>
+Immánuel Fodor <immanuelfactor+github@gmail.com> Immánuel! <21174107+immanuelfodor@users.noreply.github.com>
 kalvn <kalvnthereal@gmail.com> <kalvn@users.noreply.github.com>
+kalvn <kalvnthereal@gmail.com> <kalvn@pm.me>
+Neros <contact@neros.fr> <NerosTie@users.noreply.github.com>
 Nicolas Danelon <hi@nicolasmd.com.ar> nicolasm
 Nicolas Danelon <hi@nicolasmd.com.ar> <nda@3818.com.ar>
 Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@gmail.com>
 Nicolas Danelon <hi@nicolasmd.com.ar> <nicolasdanelon@users.noreply.github.com>
 Sébastien Sauvage <sebsauvage@sebsauvage.net>
+Sébastien NOBILI <code@pipoprods.org> <s-code-github@pipoprods.org>
 Timo Van Neerden <fire@lehollandaisvolant.net>
 Timo Van Neerden <fire@lehollandaisvolant.net> lehollandaisvolant <levoltigeurhollandais@gmail.com>
 VirtualTam <virtualtam@flibidi.net> <tamisier.aurelien@gmail.com>
index 4c00427195ecb0998589fc09dd02fa8580d97a3e..25fcfb034ee3e1bf1149eafdfccc5b9d27803fe2 100644 (file)
--- a/.htaccess
+++ b/.htaccess
@@ -7,31 +7,20 @@ RewriteEngine On
 RewriteRule ^(.git|doxygen|vendor) - [F]
 
 # Forward the "Authorization" HTTP header
+# fixes JWT token not correctly forwarded on some Apache/FastCGI setups
 RewriteCond %{HTTP:Authorization} ^(.*)
 RewriteRule .* - [e=HTTP_AUTHORIZATION:%1]
+# Alternative (if the 2 lines above don't work)
+# SetEnvIf Authorization .+ HTTP_AUTHORIZATION=$0
 
 # REST API
+# Ionos Hosting needs RewriteBase /
+# RewriteBase /
 RewriteCond %{REQUEST_FILENAME} !-f
 RewriteCond %{REQUEST_FILENAME} !-d
 RewriteRule ^ index.php [QSA,L]
 
-<Limit GET POST PUT DELETE OPTIONS>
-  <IfModule version_module>
-    <IfVersion >= 2.4>
-       Require all granted
-    </IfVersion>
-    <IfVersion < 2.4>
-       Allow from all
-       Deny from none
-    </IfVersion>
-  </IfModule>
-
-  <IfModule !version_module>
-    Require all granted
-  </IfModule>
-</Limit>
-
-<LimitExcept GET POST PUT DELETE OPTIONS>
+<LimitExcept GET POST PUT DELETE PATCH OPTIONS>
   <IfModule version_module>
     <IfVersion >= 2.4>
        Require all denied
index f466c3171d6d3192cf468b0acb0fcf88d356a1fa..d7460947383a0e595f472908da81ffcec78ef040 100644 (file)
@@ -1,8 +1,16 @@
-sudo: false
-dist: trusty
+dist: bionic
 
 matrix:
   include:
+    # jobs for each supported php version
+    - language: php
+      php: nightly # PHP 8.0
+      install:
+        - composer self-update --2
+        - composer update --ignore-platform-req=php
+        - composer remove --dev --ignore-platform-req=php phpunit/phpunit
+        - composer require --dev --ignore-platform-req=php phpunit/php-text-template ^2.0
+        - composer require --dev --ignore-platform-req=php phpunit/phpunit ^9.0
     - language: php
       php: 7.4
     - language: php
@@ -11,23 +19,22 @@ matrix:
       php: 7.2
     - language: php
       php: 7.1
+    # jobs for frontend builds
     - language: node_js
-      node_js: 8
+      node_js: 10
       cache:
         yarn: true
         directories:
           - $HOME/.cache/yarn
-
       install:
         - yarn install
-
       before_script:
         - PATH=${PATH//:\.\/node_modules\/\.bin/}
-
       script:
-        - yarn run build # Just to be sure that the build isn't broken
-        - make eslint
-        - make sasslint
+        - yarn run build # verify successful frontend builds
+        - make eslint # javascript static analysis
+        - make sasslint # linter for SASS syntax
+    # jobs for documentation builds
     - language: python
       python: 3.6
       cache:
@@ -43,7 +50,9 @@ cache:
     - $HOME/.composer/cache
 
 install:
-  - composer install --prefer-dist
+  # install/update composer and php dependencies
+  - composer config --unset platform && composer config platform.php $TRAVIS_PHP_VERSION
+  - composer update
 
 before_script:
   - PATH=${PATH//:\.\/node_modules\/\.bin/}
diff --git a/AUTHORS b/AUTHORS
index 505932185eb9319be5775a72e823aef21446ae7a..0ec52accb570c55861e047eed49290e420115582 100644 (file)
--- a/AUTHORS
+++ b/AUTHORS
@@ -1,46 +1,56 @@
-   782 ArthurHoaro <arthur@hoa.ro>
-   401 VirtualTam <virtualtam@flibidi.net>
-   218 nodiscc <nodiscc@gmail.com>
+   991 ArthurHoaro <arthur@hoa.ro>
+   402 VirtualTam <virtualtam@flibidi.net>
+   294 nodiscc <nodiscc@gmail.com>
     56 Sébastien Sauvage <sebsauvage@sebsauvage.net>
     16 Luce Carević <lcarevic@access42.net>
     15 Florian Eula <eula.florian@gmail.com>
     13 Emilien Klein <emilien@klein.st>
     12 Nicolas Danelon <hi@nicolasmd.com.ar>
+     9 Lucas Cimon <lucas.cimon@gmail.com>
      9 Willi Eggeling <thewilli@gmail.com>
      8 Christophe HENRY <christophe.henry@sbgodin.fr>
      6 B. van Berkum <dev@dotmpe.com>
+     6 Immánuel Fodor <immanuelfactor+github@gmail.com>
+     6 Keith Carangelo <mail@kcaran.com>
+     6 kalvn <kalvnthereal@gmail.com>
      6 llune <llune@users.noreply.github.com>
-     5 Lucas Cimon <lucas.cimon@gmail.com>
      5 Mark Schmitz <kramred@gmail.com>
-     5 kalvn <kalvnthereal@gmail.com>
+     5 Sébastien NOBILI <code@pipoprods.org>
+     5 dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
      4 Alexandre Alapetite <alexandre@alapetite.fr>
      4 David Sferruzza <david.sferruzza@gmail.com>
-     4 Immánuel Fodor <immanuelfactor+github@gmail.com>
      3 Agurato <mail.vmonot@gmail.com>
+     3 Christoph Stoettner <christoph.stoettner@stoeps.de>
      3 Teromene <teromene@teromene.fr>
      2 Alexandre G.-Raymond <alex@ndre.gr>
      2 Chris Kuethe <chris.kuethe@gmail.com>
      2 Felix Bartels <felix@host-consultants.de>
+     2 Guillaume Virlet <github@virlet.org>
      2 Knah Tsaeb <Knah-Tsaeb@knah-tsaeb.org>
      2 Mathieu Chabanon <git@matchab.fr>
      2 Miloš Jovanović <mjovanovic@gmail.com>
+     2 Neros <contact@neros.fr>
      2 Qwerty <champlywood@free.fr>
      2 Stephen Muth <smuth4@gmail.com>
      2 Timo Van Neerden <fire@lehollandaisvolant.net>
+     2 flow.gunso <flow.gunso@gmail.com>
      2 julienCXX <software@chmodplusx.eu>
      2 philipp-r <philipp-r@users.noreply.github.com>
      2 pips <pips@e5150.fr>
      2 trailjeep <trailjeep@gmail.com>
+     2 yude <yudesleepy@gmail.com>
      1 Adrien Oliva <adrien.oliva@yapbreak.fr>
      1 Adrien le Maire <adrien@alemaire.be>
      1 Alexis J <alexis@effingo.be>
      1 Angristan <angristan@users.noreply.github.com>
      1 Bish Erbas <42714627+bisherbas@users.noreply.github.com>
      1 BoboTiG <bobotig@gmail.com>
+     1 Brendan M. Sleight <bms.git@barwap.com>
      1 Bronco <bronco@warriordudimanche.net>
      1 Buster One <37770318+buster-one@users.noreply.github.com>
      1 D Low <daniellowtw@gmail.com>
      1 Daniel Jakots <vigdis@chown.me>
+     1 David Foucher <dev@tyjak.net>
      1 Dennis Verspuij <dennisverspuij@users.noreply.github.com>
      1 Dimtion <zizou.xena@gmail.com>
      1 Fanch <fanch-github@qth.fr>
      1 Florian Voigt <flvoigt@me.com>
      1 Franck Kerbiriou <FranckKe@users.noreply.github.com>
      1 Gary Marigliano <gmarigliano93@gmail.com>
-     1 Guillaume Virlet <github@virlet.org>
      1 Jonathan Amiez <jonathan.amiez@gmail.com>
      1 Jonathan Druart <jonathan.druart@gmail.com>
      1 Julien Pivotto <roidelapluie@inuits.eu>
      1 Kevin Canévet <kevin@streamroot.io>
+     1 Kevin Masson <kevin.masson@methodinthemadness.eu>
      1 Knah Tsaeb <knah-tsaeb@knah-tsaeb.org>
      1 Lionel Martin <renarddesmers@gmail.com>
      1 Mark Gerarts <mark.gerarts@gmail.com>
      1 Marsup <marsup@gmail.com>
-     1 Neros <contact@neros.fr>
+     1 Paul van den Burg <github@paulvandenburg.nl>
      1 Rajat Hans <rajathans9@gmail.com>
      1 Sbgodin <Sbgodin@users.noreply.github.com>
+     1 Sebastien Wains <sebw@users.noreply.github.com>
      1 TsT <tst2005@gmail.com>
      1 agentcobra <agentcobra@free.fr>
+     1 aguy <aguytech@users.noreply.github.com>
      1 dimtion <zizou.xena@gmail.com>
      1 durcheinandr <jochen@durcheinandr.de>
      1 lapineige <lapineige@users.noreply.github.com>
+     1 owen bell <66233223+xfnw@users.noreply.github.com>
+     1 rfolo9li <50079896+rfolo9li@users.noreply.github.com>
+     1 sprak3000 <sprak3000+github@gmail.com>
index abf802ead6c89a2663f843860b124d4226e49135..f1686d67f3564e913b30d483dcaf8eb52cff2b7f 100644 (file)
@@ -4,6 +4,78 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
+## [v0.12.1]() - UNRELEASED
+
+## [v0.12.0](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0) - 2020-10-13
+
+**Save you `data/` folder before updating!**
+
+### Added
+- Thumbnailer: add soundcloud.com to list of common media domains
+- Markdown rendering is now integrated into Shaarli core
+- Add autofocus on tag cloud filter input
+- Japanese translations
+- Japanese translation: add language to admin configuration page
+- Support for PHP 8.0
+- Support for local anchor URL (starting with `#`)
+- LDAP authentication
+- Encapsulated PageCacheManager
+- Docs:
+  - add screenshots of all pages
+  - section about mkdocs
+  - Ulauncher extension
+- CI: run against PHP 7.4
+- Added $links_per_page variable to template and display on default
+- Inject BookmarkServiceInterface in plugins data
+- Add manual configuration for root URL
+- Added PATCH to the allowed Apache request methods.
+- REST API: compatibility with ionos Apache's headers
+
+### Changed
+- Introduce Bookmark object and Service layer
+  - Save bookmark as objects in the datastore
+  - Handle bookmark as objects across the whole codebase (except templates and plugins)
+- Process all Shaarli page through Slim controller, with proper URL rewriting (see #1516)
+- Docs: the entire documentation has been reviewed, updated and improved, thanks to @nodiscc!
+- ATOM feed: use instance name as author name instead of URL
+- Updated French translation
+- Default colors plugin: generate CSS file during initialization
+- Improve default bookmarks after install
+- Upgrade all front end dependencies and webpack build
+- Default theme: Make tag cloud/list views buttons more obvious
+
+### Fixed
+- Undefined index: thumbnail in daily page
+- Undefined index: thumbnail on OpenGraph headers
+- Undefined index: updated on linklist
+- Make sure that bookmark sort is consistent, even with equal timestamps
+- Code PHP version check as requirement bumped to PHP 7.1
+- Thumbnail images lazy loading
+- Markdown plugin: fix RSS feed direct link reverse
+- Fix RSS permalink included in Markdown bloc
+- Demo plugin: multiple typos
+- Makefile target for releases
+- Makefile target for html documentation
+- Session cookie setting being set while session is active
+- Deprecated use of implode
+- Division by zero in tag cloud
+- CI: deprecated linux distribution and sudo directive
+- Docker build: gcc is no longer included in python alpine image
+- Default template: display pin button in mobile view
+- Pinned bookmarks are not longer displayed first in ATOM/RSS feeds
+- Docs:
+  - Outdated Docker documentation for stable branch
+  - Outdated links
+  - Plugin description in meta files
+- docker-compose.yml: pin traefik image to 1.7-alpine
+
+### Removed
+- Markdown plugin
+- Docs:
+  - emojione & twemoji removed
+- Makefile: remove static_analysis_summary from all: target
+- doc/Makefile: remove references to composer update
+
 ## [v0.11.1](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1) - 2019-08-03
 
 Release to fix broken Docker build on the latest version.
index f05cf3a45ccbdb360bcb29adf370f88d46b1043c..f6120b71f2b1d4507cd46dcaf7ebf09934d98139 100644 (file)
@@ -4,6 +4,7 @@
 FROM python:3-alpine as docs
 ADD . /usr/src/app/shaarli
 RUN cd /usr/src/app/shaarli \
+    && apk add --no-cache gcc musl-dev \
     && pip install --no-cache-dir mkdocs \
     && mkdocs build --clean
 
@@ -43,6 +44,7 @@ RUN apk --update --no-cache add \
         php7-openssl \
         php7-session \
         php7-xml \
+        php7-simplexml \
         php7-zlib \
         s6
 
index b52ba22f72382b1573d54591ac4e17e6ab848e3f..7415887a42cb572a7f632374193e91d87e8895f9 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@
 
 BIN = vendor/bin
 
-all: static_analysis_summary check_permissions test
+all: check_permissions test
 
 ##
 # Docker test adapter
@@ -85,6 +85,10 @@ all_tests: test locale_test_de_DE locale_test_en_US locale_test_fr_FR
        @# --text doesn't work with phpunit 4.* (v5 requires PHP 5.6)
        @#$(BIN)/phpcov merge --text coverage/txt coverage
 
+### download 3rd-party PHP libraries, including dev dependencies
+composer_dependencies_dev: clean
+       composer install --prefer-dist
+
 ##
 # Custom release archive generation
 #
@@ -171,7 +175,8 @@ translate:
 eslint:
        @yarn run eslint -c .dev/.eslintrc.js assets/vintage/js/
        @yarn run eslint -c .dev/.eslintrc.js assets/default/js/
+       @yarn run eslint -c .dev/.eslintrc.js assets/common/js/
 
 ### Run CSSLint check against Shaarli's SCSS files
 sasslint:
-       @yarn run sass-lint -c .dev/.sasslintrc 'assets/default/scss/*.scss' -v -q
+       @yarn run stylelint --config .dev/.stylelintrc.js 'assets/default/scss/*.scss'
index 4fb0bfe0d07748ce7c4b39bf5de0e84b95c70374..46dda8d5e3e0fb874419a0f617e65f36826d1229 100644 (file)
--- a/README.md
+++ b/README.md
@@ -6,13 +6,13 @@ _Do you want to share the links you discover?_
 _Shaarli is a minimalist link sharing service that you can install on your own server._
 _It is designed to be personal (single-user), fast and handy._
 
-[![](https://img.shields.io/badge/stable-v0.10.4-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4)
+[![](https://img.shields.io/badge/stable-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
 [![](https://img.shields.io/travis/shaarli/Shaarli/stable.svg?label=stable)](https://travis-ci.org/shaarli/Shaarli)
 &bull;
-[![](https://img.shields.io/badge/latest-v0.11.1-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.11.1)
+[![](https://img.shields.io/badge/latest-v0.12.0-blue.svg)](https://github.com/shaarli/Shaarli/releases/tag/v0.12.0)
 [![](https://img.shields.io/travis/shaarli/Shaarli/latest.svg?label=latest)](https://travis-ci.org/shaarli/Shaarli)
 &bull;
-[![](https://img.shields.io/badge/master-v0.11.x-blue.svg)](https://github.com/shaarli/Shaarli)
+[![](https://img.shields.io/badge/master-v0.12.x-blue.svg)](https://github.com/shaarli/Shaarli)
 [![](https://img.shields.io/travis/shaarli/Shaarli.svg?label=master)](https://travis-ci.org/shaarli/Shaarli)
 
 [![Join the chat at https://gitter.im/shaarli/Shaarli](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/shaarli/Shaarli)
index 4fd2f29444ea8a6122740a25f77fc97847e868d4..bd5c1bf7318b63cec18905f99075b5c3a89c3a40 100644 (file)
@@ -4,6 +4,7 @@ namespace Shaarli;
 use DateTime;
 use Exception;
 use Shaarli\Bookmark\Bookmark;
+use Shaarli\Helper\FileUtils;
 
 /**
  * Class History
index 5cda802e0c1faaf57058e0dca653cc713c90c0c3..d83e0765794af8cfbf0a5f762b5070162181a811 100644 (file)
@@ -179,9 +179,10 @@ class Languages
     {
         return [
             'auto' => t('Automatic'),
+            'de' => t('German'),
             'en' => t('English'),
             'fr' => t('French'),
-            'de' => t('German'),
+            'jp' => t('Japanese'),
         ];
     }
 }
diff --git a/application/Router.php b/application/Router.php
deleted file mode 100644 (file)
index d718748..0000000
+++ /dev/null
@@ -1,184 +0,0 @@
-<?php
-namespace Shaarli;
-
-/**
- * Class Router
- *
- * (only displayable pages here)
- */
-class Router
-{
-    public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';
-
-    public static $PAGE_LOGIN = 'login';
-
-    public static $PAGE_PICWALL = 'picwall';
-
-    public static $PAGE_TAGCLOUD = 'tagcloud';
-
-    public static $PAGE_TAGLIST = 'taglist';
-
-    public static $PAGE_DAILY = 'daily';
-
-    public static $PAGE_FEED_ATOM = 'atom';
-
-    public static $PAGE_FEED_RSS = 'rss';
-
-    public static $PAGE_TOOLS = 'tools';
-
-    public static $PAGE_CHANGEPASSWORD = 'changepasswd';
-
-    public static $PAGE_CONFIGURE = 'configure';
-
-    public static $PAGE_CHANGETAG = 'changetag';
-
-    public static $PAGE_ADDLINK = 'addlink';
-
-    public static $PAGE_EDITLINK = 'edit_link';
-
-    public static $PAGE_DELETELINK = 'delete_link';
-
-    public static $PAGE_CHANGE_VISIBILITY = 'change_visibility';
-
-    public static $PAGE_PINLINK = 'pin';
-
-    public static $PAGE_EXPORT = 'export';
-
-    public static $PAGE_IMPORT = 'import';
-
-    public static $PAGE_OPENSEARCH = 'opensearch';
-
-    public static $PAGE_LINKLIST = 'linklist';
-
-    public static $PAGE_PLUGINSADMIN = 'pluginadmin';
-
-    public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
-
-    public static $PAGE_THUMBS_UPDATE = 'thumbs_update';
-
-    public static $GET_TOKEN = 'token';
-
-    /**
-     * Reproducing renderPage() if hell, to avoid regression.
-     *
-     * This highlights how bad this needs to be rewrite,
-     * but let's focus on plugins for now.
-     *
-     * @param string $query    $_SERVER['QUERY_STRING'].
-     * @param array  $get      $_SERVER['GET'].
-     * @param bool   $loggedIn true if authenticated user.
-     *
-     * @return string page found.
-     */
-    public static function findPage($query, $get, $loggedIn)
-    {
-        $loggedIn = ($loggedIn === true) ? true : false;
-
-        if (empty($query) && !isset($get['edit_link']) && !isset($get['post'])) {
-            return self::$PAGE_LINKLIST;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_LOGIN) && $loggedIn === false) {
-            return self::$PAGE_LOGIN;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_PICWALL)) {
-            return self::$PAGE_PICWALL;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_TAGCLOUD)) {
-            return self::$PAGE_TAGCLOUD;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_TAGLIST)) {
-            return self::$PAGE_TAGLIST;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_OPENSEARCH)) {
-            return self::$PAGE_OPENSEARCH;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_DAILY)) {
-            return self::$PAGE_DAILY;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_FEED_ATOM)) {
-            return self::$PAGE_FEED_ATOM;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_FEED_RSS)) {
-            return self::$PAGE_FEED_RSS;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_THUMBS_UPDATE)) {
-            return self::$PAGE_THUMBS_UPDATE;
-        }
-
-        if (startsWith($query, 'do=' . self::$AJAX_THUMB_UPDATE)) {
-            return self::$AJAX_THUMB_UPDATE;
-        }
-
-        // At this point, only loggedin pages.
-        if (!$loggedIn) {
-            return self::$PAGE_LINKLIST;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_TOOLS)) {
-            return self::$PAGE_TOOLS;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_CHANGEPASSWORD)) {
-            return self::$PAGE_CHANGEPASSWORD;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_CONFIGURE)) {
-            return self::$PAGE_CONFIGURE;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_CHANGETAG)) {
-            return self::$PAGE_CHANGETAG;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_ADDLINK)) {
-            return self::$PAGE_ADDLINK;
-        }
-
-        if (isset($get['edit_link']) || isset($get['post'])) {
-            return self::$PAGE_EDITLINK;
-        }
-
-        if (isset($get['delete_link'])) {
-            return self::$PAGE_DELETELINK;
-        }
-
-        if (isset($get[self::$PAGE_CHANGE_VISIBILITY])) {
-            return self::$PAGE_CHANGE_VISIBILITY;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_PINLINK)) {
-            return self::$PAGE_PINLINK;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_EXPORT)) {
-            return self::$PAGE_EXPORT;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_IMPORT)) {
-            return self::$PAGE_IMPORT;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_PLUGINSADMIN)) {
-            return self::$PAGE_PLUGINSADMIN;
-        }
-
-        if (startsWith($query, 'do=' . self::$PAGE_SAVE_PLUGINSADMIN)) {
-            return self::$PAGE_SAVE_PLUGINSADMIN;
-        }
-
-        if (startsWith($query, 'do=' . self::$GET_TOKEN)) {
-            return self::$GET_TOKEN;
-        }
-
-        return self::$PAGE_LINKLIST;
-    }
-}
index 314baf0df615c5e3edf15ec5e9b0e60eac61f792..5aec23c8d7b6bbf59305f3e651a689cd3d781a21 100644 (file)
@@ -4,7 +4,6 @@ namespace Shaarli;
 
 use Shaarli\Config\ConfigManager;
 use WebThumbnailer\Application\ConfigManager as WTConfigManager;
-use WebThumbnailer\Exception\WebThumbnailerException;
 use WebThumbnailer\WebThumbnailer;
 
 /**
@@ -90,7 +89,7 @@ class Thumbnailer
 
         try {
             return $this->wt->thumbnail($url);
-        } catch (WebThumbnailerException $e) {
+        } catch (\Throwable $e) {
             // Exceptions are only thrown in debug mode.
             error_log(get_class($e) . ': ' . $e->getMessage());
         }
index 4b7fc5464916495ec9f9189270edbd7d43d2b53c..db046893166aaa5a773a815822007541d5d92824 100644 (file)
@@ -4,21 +4,23 @@
  */
 
 /**
- * Logs a message to a text file
+ * Format log using provided data.
  *
- * The log format is compatible with fail2ban.
+ * @param string      $message  the message to log
+ * @param string|null $clientIp the client's remote IPv4/IPv6 address
  *
- * @param string $logFile  where to write the logs
- * @param string $clientIp the client's remote IPv4/IPv6 address
- * @param string $message  the message to log
+ * @return string Formatted message to log
  */
-function logm($logFile, $clientIp, $message)
+function format_log(string $message, string $clientIp = null): string
 {
-    file_put_contents(
-        $logFile,
-        date('Y/m/d H:i:s').' - '.$clientIp.' - '.strval($message).PHP_EOL,
-        FILE_APPEND
-    );
+    $out = $message;
+
+    if (!empty($clientIp)) {
+        // Note: we keep the first dash to avoid breaking fail2ban configs
+        $out = '- ' . $clientIp . ' - ' . $out;
+    }
+
+    return $out;
 }
 
 /**
@@ -87,18 +89,22 @@ function endsWith($haystack, $needle, $case = true)
  *
  * @param mixed $input Data to escape: a single string or an array of strings.
  *
- * @return string escaped.
+ * @return string|array escaped.
  */
 function escape($input)
 {
-    if (is_bool($input)) {
+    if (null === $input) {
+        return null;
+    }
+
+    if (is_bool($input) || is_int($input) || is_float($input) || $input instanceof DateTimeInterface) {
         return $input;
     }
 
     if (is_array($input)) {
         $out = array();
         foreach ($input as $key => $value) {
-            $out[$key] = escape($value);
+            $out[escape($key)] = escape($value);
         }
         return $out;
     }
@@ -294,15 +300,15 @@ function normalize_spaces($string)
  * Requires php-intl to display international datetimes,
  * otherwise default format '%c' will be returned.
  *
- * @param DateTime $date to format.
- * @param bool     $time Displays time if true.
- * @param bool     $intl Use international format if true.
+ * @param DateTimeInterface $date to format.
+ * @param bool              $time Displays time if true.
+ * @param bool              $intl Use international format if true.
  *
  * @return bool|string Formatted date, or false if the input is invalid.
  */
 function format_date($date, $time = true, $intl = true)
 {
-    if (! $date instanceof DateTime) {
+    if (! $date instanceof DateTimeInterface) {
         return false;
     }
 
@@ -320,6 +326,23 @@ function format_date($date, $time = true, $intl = true)
     return $formatter->format($date);
 }
 
+/**
+ * Format the date month according to the locale.
+ *
+ * @param DateTimeInterface $date to format.
+ *
+ * @return bool|string Formatted date, or false if the input is invalid.
+ */
+function format_month(DateTimeInterface $date)
+{
+    if (! $date instanceof DateTimeInterface) {
+        return false;
+    }
+
+    return strftime('%B', $date->getTimestamp());
+}
+
+
 /**
  * Check if the input is an integer, no matter its real type.
  *
@@ -448,14 +471,27 @@ function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
  * Wrapper function for translation which match the API
  * of gettext()/_() and ngettext().
  *
- * @param string $text   Text to translate.
- * @param string $nText  The plural message ID.
- * @param int    $nb     The number of items for plural forms.
- * @param string $domain The domain where the translation is stored (default: shaarli).
+ * @param string $text      Text to translate.
+ * @param string $nText     The plural message ID.
+ * @param int    $nb        The number of items for plural forms.
+ * @param string $domain    The domain where the translation is stored (default: shaarli).
+ * @param array  $variables Associative array of variables to replace in translated text.
+ * @param bool   $fixCase   Apply `ucfirst` on the translated string, might be useful for strings with variables.
  *
  * @return string Text translated.
  */
-function t($text, $nText = '', $nb = 1, $domain = 'shaarli')
+function t($text, $nText = '', $nb = 1, $domain = 'shaarli', $variables = [], $fixCase = false)
+{
+    $postFunction = $fixCase ? 'ucfirst' : function ($input) { return $input; };
+
+    return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
+}
+
+/**
+ * Converts an exception into a printable stack trace string.
+ */
+function exception2text(Throwable $e): string
 {
-    return dn__($domain, $text, $nText, $nb);
+    return $e->getMessage() . PHP_EOL . $e->getFile() . $e->getLine() . PHP_EOL . $e->getTraceAsString();
 }
+
index 4745ac94101db8efb3c83bc760dd154a3af0445e..adc8b2666306d185f70fb0668fcefdf2b40b7d13 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace Shaarli\Api;
 
+use malkusch\lock\mutex\FlockMutex;
 use Shaarli\Api\Exceptions\ApiAuthorizationException;
 use Shaarli\Api\Exceptions\ApiException;
 use Shaarli\Bookmark\BookmarkFileService;
@@ -71,7 +72,14 @@ class ApiMiddleware
             $response = $e->getApiResponse();
         }
 
-        return $response;
+        return $response
+            ->withHeader('Access-Control-Allow-Origin', '*')
+            ->withHeader(
+                'Access-Control-Allow-Headers',
+                'X-Requested-With, Content-Type, Accept, Origin, Authorization'
+            )
+            ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
+        ;
     }
 
     /**
@@ -100,7 +108,9 @@ class ApiMiddleware
      */
     protected function checkToken($request)
     {
-        if (! $request->hasHeader('Authorization')) {
+        if (!$request->hasHeader('Authorization')
+            && !isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])
+        ) {
             throw new ApiAuthorizationException('JWT token not provided');
         }
 
@@ -108,7 +118,11 @@ class ApiMiddleware
             throw new ApiAuthorizationException('Token secret must be set in Shaarli\'s administration');
         }
 
-        $authorization = $request->getHeaderLine('Authorization');
+        if (isset($this->container->environment['REDIRECT_HTTP_AUTHORIZATION'])) {
+            $authorization = $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'];
+        } else {
+            $authorization = $request->getHeaderLine('Authorization');
+        }
 
         if (! preg_match('/^Bearer (.*)/i', $authorization, $matches)) {
             throw new ApiAuthorizationException('Invalid JWT header');
@@ -130,6 +144,7 @@ class ApiMiddleware
         $linkDb = new BookmarkFileService(
             $conf,
             $this->container->get('history'),
+            new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
             true
         );
         $this->container['db'] = $linkDb;
index 5156a5f783f0bc6767c0684f1c1a595959f3fbbd..eb1ca9bc2b6230e944350759c33386acced2770f 100644 (file)
@@ -67,7 +67,7 @@ class ApiUtils
         if (! $bookmark->isNote()) {
             $out['url'] = $bookmark->getUrl();
         } else {
-            $out['url'] = $indexUrl . $bookmark->getUrl();
+            $out['url'] = rtrim($indexUrl, '/') . '/' . ltrim($bookmark->getUrl(), '/');
         }
         $out['shorturl'] = $bookmark->getShortUrl();
         $out['title'] = $bookmark->getTitle();
@@ -89,12 +89,12 @@ class ApiUtils
      * If no URL is provided, it will generate a local note URL.
      * If no title is provided, it will use the URL as title.
      *
-     * @param array  $input          Request Link.
-     * @param bool   $defaultPrivate Request Link.
+     * @param array|null  $input          Request Link.
+     * @param bool        $defaultPrivate Setting defined if a bookmark is private by default.
      *
      * @return Bookmark instance.
      */
-    public static function buildLinkFromRequest($input, $defaultPrivate)
+    public static function buildBookmarkFromRequest(?array $input, bool $defaultPrivate): Bookmark
     {
         $bookmark = new Bookmark();
         $url = ! empty($input['url']) ? cleanup_url($input['url']) : '';
@@ -110,6 +110,15 @@ class ApiUtils
         $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []);
         $bookmark->setPrivate($private);
 
+        $created = \DateTime::createFromFormat(\DateTime::ATOM, $input['created'] ?? '');
+        if ($created instanceof \DateTimeInterface) {
+            $bookmark->setCreated($created);
+        }
+        $updated = \DateTime::createFromFormat(\DateTime::ATOM, $input['updated'] ?? '');
+        if ($updated instanceof \DateTimeInterface) {
+            $bookmark->setUpdated($updated);
+        }
+
         return $bookmark;
     }
 
index c4b3d0c3df983484c535d5396530c93843688cdd..88a845ebc0b8557be699a5292b4a55c91911dc6a 100644 (file)
@@ -4,6 +4,7 @@ namespace Shaarli\Api\Controllers;
 
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\History;
 use Slim\Container;
 
 /**
@@ -31,7 +32,7 @@ abstract class ApiController
     protected $bookmarkService;
 
     /**
-     * @var HistoryController
+     * @var History
      */
     protected $history;
 
index 16fc8688887e44af1d4c6c07e8f17f12f12182d4..6bf529e4a570a3ff5f789f2de34045946205cd67 100644 (file)
@@ -96,11 +96,12 @@ class Links extends ApiController
      */
     public function getLink($request, $response, $args)
     {
-        if (!$this->bookmarkService->exists($args['id'])) {
+        $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
+        if ($id === null || ! $this->bookmarkService->exists($id)) {
             throw new ApiLinkNotFoundException();
         }
         $index = index_url($this->ci['environment']);
-        $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index);
+        $out = ApiUtils::formatLink($this->bookmarkService->get($id), $index);
 
         return $response->withJson($out, 200, $this->jsonStyle);
     }
@@ -115,8 +116,8 @@ class Links extends ApiController
      */
     public function postLink($request, $response)
     {
-        $data = $request->getParsedBody();
-        $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
+        $data = (array) ($request->getParsedBody() ?? []);
+        $bookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
         // duplicate by URL, return 409 Conflict
         if (! empty($bookmark->getUrl())
             && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl()))
@@ -148,18 +149,19 @@ class Links extends ApiController
      */
     public function putLink($request, $response, $args)
     {
-        if (! $this->bookmarkService->exists($args['id'])) {
+        $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
+        if ($id === null || !$this->bookmarkService->exists($id)) {
             throw new ApiLinkNotFoundException();
         }
 
         $index = index_url($this->ci['environment']);
         $data = $request->getParsedBody();
 
-        $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links'));
+        $requestBookmark = ApiUtils::buildBookmarkFromRequest($data, $this->conf->get('privacy.default_private_links'));
         // duplicate URL on a different link, return 409 Conflict
         if (! empty($requestBookmark->getUrl())
             && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl()))
-            && $dup->getId() != $args['id']
+            && $dup->getId() != $id
         ) {
             return $response->withJson(
                 ApiUtils::formatLink($dup, $index),
@@ -168,7 +170,7 @@ class Links extends ApiController
             );
         }
 
-        $responseBookmark = $this->bookmarkService->get($args['id']);
+        $responseBookmark = $this->bookmarkService->get($id);
         $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark);
         $this->bookmarkService->set($responseBookmark);
 
@@ -189,10 +191,11 @@ class Links extends ApiController
      */
     public function deleteLink($request, $response, $args)
     {
-        if (! $this->bookmarkService->exists($args['id'])) {
+        $id = is_integer_mixed($args['id']) ? (int) $args['id'] : null;
+        if ($id === null || !$this->bookmarkService->exists($id)) {
             throw new ApiLinkNotFoundException();
         }
-        $bookmark = $this->bookmarkService->get($args['id']);
+        $bookmark = $this->bookmarkService->get($id);
         $this->bookmarkService->remove($bookmark);
 
         return $response->withStatus(204);
index f9b21d3d0b59dda47bf9f055607d258b7e13376b..4810c5e63b5b97b831ba7eb77cc6053c0dbd9d9d 100644 (file)
@@ -1,8 +1,11 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 use DateTime;
+use DateTimeInterface;
 use Shaarli\Bookmark\Exception\InvalidBookmarkException;
 
 /**
@@ -36,21 +39,24 @@ class Bookmark
     /** @var array List of bookmark's tags */
     protected $tags;
 
-    /** @var string Thumbnail's URL - false if no thumbnail could be found */
+    /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
     protected $thumbnail;
 
     /** @var bool Set to true if the bookmark is set as sticky */
     protected $sticky;
 
-    /** @var DateTime Creation datetime */
+    /** @var DateTimeInterface Creation datetime */
     protected $created;
 
-    /** @var DateTime Update datetime */
+    /** @var DateTimeInterface datetime */
     protected $updated;
 
     /** @var bool True if the bookmark can only be seen while logged in */
     protected $private;
 
+    /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
+    protected $additionalContent = [];
+
     /**
      * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
      *
@@ -58,25 +64,25 @@ class Bookmark
      *
      * @return $this
      */
-    public function fromArray($data)
+    public function fromArray(array $data): Bookmark
     {
-        $this->id = $data['id'];
-        $this->shortUrl = $data['shorturl'];
-        $this->url = $data['url'];
-        $this->title = $data['title'];
-        $this->description = $data['description'];
-        $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null;
-        $this->sticky = isset($data['sticky']) ? $data['sticky'] : false;
-        $this->created = $data['created'];
+        $this->id = $data['id'] ?? null;
+        $this->shortUrl = $data['shorturl'] ?? null;
+        $this->url = $data['url'] ?? null;
+        $this->title = $data['title'] ?? null;
+        $this->description = $data['description'] ?? null;
+        $this->thumbnail = $data['thumbnail'] ?? null;
+        $this->sticky = $data['sticky'] ?? false;
+        $this->created = $data['created'] ?? null;
         if (is_array($data['tags'])) {
             $this->tags = $data['tags'];
         } else {
-            $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY);
+            $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY);
         }
         if (! empty($data['updated'])) {
             $this->updated = $data['updated'];
         }
-        $this->private = $data['private'] ? true : false;
+        $this->private = ($data['private'] ?? false) ? true : false;
 
         return $this;
     }
@@ -92,24 +98,28 @@ class Bookmark
      *   - the URL with the permalink
      *   - the title with the URL
      *
+     * Also make sure that we do not save search highlights in the datastore.
+     *
      * @throws InvalidBookmarkException
      */
-    public function validate()
+    public function validate(): void
     {
         if ($this->id === null
             || ! is_int($this->id)
             || empty($this->shortUrl)
             || empty($this->created)
-            || ! $this->created instanceof DateTime
         ) {
             throw new InvalidBookmarkException($this);
         }
         if (empty($this->url)) {
-            $this->url = '?'. $this->shortUrl;
+            $this->url = '/shaare/'. $this->shortUrl;
         }
         if (empty($this->title)) {
             $this->title = $this->url;
         }
+        if (array_key_exists('search_highlight', $this->additionalContent)) {
+            unset($this->additionalContent['search_highlight']);
+        }
     }
 
     /**
@@ -118,11 +128,11 @@ class Bookmark
      *   - created: with the current datetime
      *   - shortUrl: with a generated small hash from the date and the given ID
      *
-     * @param int $id
+     * @param int|null $id
      *
      * @return Bookmark
      */
-    public function setId($id)
+    public function setId(?int $id): Bookmark
     {
         $this->id = $id;
         if (empty($this->created)) {
@@ -138,9 +148,9 @@ class Bookmark
     /**
      * Get the Id.
      *
-     * @return int
+     * @return int|null
      */
-    public function getId()
+    public function getId(): ?int
     {
         return $this->id;
     }
@@ -148,9 +158,9 @@ class Bookmark
     /**
      * Get the ShortUrl.
      *
-     * @return string
+     * @return string|null
      */
-    public function getShortUrl()
+    public function getShortUrl(): ?string
     {
         return $this->shortUrl;
     }
@@ -158,9 +168,9 @@ class Bookmark
     /**
      * Get the Url.
      *
-     * @return string
+     * @return string|null
      */
-    public function getUrl()
+    public function getUrl(): ?string
     {
         return $this->url;
     }
@@ -170,7 +180,7 @@ class Bookmark
      *
      * @return string
      */
-    public function getTitle()
+    public function getTitle(): ?string
     {
         return $this->title;
     }
@@ -180,7 +190,7 @@ class Bookmark
      *
      * @return string
      */
-    public function getDescription()
+    public function getDescription(): string
     {
         return ! empty($this->description) ? $this->description : '';
     }
@@ -188,9 +198,9 @@ class Bookmark
     /**
      * Get the Created.
      *
-     * @return DateTime
+     * @return DateTimeInterface
      */
-    public function getCreated()
+    public function getCreated(): ?DateTimeInterface
     {
         return $this->created;
     }
@@ -198,9 +208,9 @@ class Bookmark
     /**
      * Get the Updated.
      *
-     * @return DateTime
+     * @return DateTimeInterface
      */
-    public function getUpdated()
+    public function getUpdated(): ?DateTimeInterface
     {
         return $this->updated;
     }
@@ -208,11 +218,11 @@ class Bookmark
     /**
      * Set the ShortUrl.
      *
-     * @param string $shortUrl
+     * @param string|null $shortUrl
      *
      * @return Bookmark
      */
-    public function setShortUrl($shortUrl)
+    public function setShortUrl(?string $shortUrl): Bookmark
     {
         $this->shortUrl = $shortUrl;
 
@@ -222,14 +232,14 @@ class Bookmark
     /**
      * Set the Url.
      *
-     * @param string $url
-     * @param array  $allowedProtocols
+     * @param string|null $url
+     * @param string[]    $allowedProtocols
      *
      * @return Bookmark
      */
-    public function setUrl($url, $allowedProtocols = [])
+    public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
     {
-        $url = trim($url);
+        $url = $url !== null ? trim($url) : '';
         if (! empty($url)) {
             $url = whitelist_protocols($url, $allowedProtocols);
         }
@@ -241,13 +251,13 @@ class Bookmark
     /**
      * Set the Title.
      *
-     * @param string $title
+     * @param string|null $title
      *
      * @return Bookmark
      */
-    public function setTitle($title)
+    public function setTitle(?string $title): Bookmark
     {
-        $this->title = trim($title);
+        $this->title = $title !== null ? trim($title) : '';
 
         return $this;
     }
@@ -255,11 +265,11 @@ class Bookmark
     /**
      * Set the Description.
      *
-     * @param string $description
+     * @param string|null $description
      *
      * @return Bookmark
      */
-    public function setDescription($description)
+    public function setDescription(?string $description): Bookmark
     {
         $this->description = $description;
 
@@ -270,11 +280,11 @@ class Bookmark
      * Set the Created.
      * Note: you shouldn't set this manually except for special cases (like bookmark import)
      *
-     * @param DateTime $created
+     * @param DateTimeInterface|null $created
      *
      * @return Bookmark
      */
-    public function setCreated($created)
+    public function setCreated(?DateTimeInterface $created): Bookmark
     {
         $this->created = $created;
 
@@ -284,11 +294,11 @@ class Bookmark
     /**
      * Set the Updated.
      *
-     * @param DateTime $updated
+     * @param DateTimeInterface|null $updated
      *
      * @return Bookmark
      */
-    public function setUpdated($updated)
+    public function setUpdated(?DateTimeInterface $updated): Bookmark
     {
         $this->updated = $updated;
 
@@ -300,7 +310,7 @@ class Bookmark
      *
      * @return bool
      */
-    public function isPrivate()
+    public function isPrivate(): bool
     {
         return $this->private ? true : false;
     }
@@ -308,11 +318,11 @@ class Bookmark
     /**
      * Set the Private.
      *
-     * @param bool $private
+     * @param bool|null $private
      *
      * @return Bookmark
      */
-    public function setPrivate($private)
+    public function setPrivate(?bool $private): Bookmark
     {
         $this->private = $private ? true : false;
 
@@ -322,9 +332,9 @@ class Bookmark
     /**
      * Get the Tags.
      *
-     * @return array
+     * @return string[]
      */
-    public function getTags()
+    public function getTags(): array
     {
         return is_array($this->tags) ? $this->tags : [];
     }
@@ -332,13 +342,13 @@ class Bookmark
     /**
      * Set the Tags.
      *
-     * @param array $tags
+     * @param string[]|null $tags
      *
      * @return Bookmark
      */
-    public function setTags($tags)
+    public function setTags(?array $tags): Bookmark
     {
-        $this->setTagsString(implode(' ', $tags));
+        $this->setTagsString(implode(' ', $tags ?? []));
 
         return $this;
     }
@@ -346,7 +356,7 @@ class Bookmark
     /**
      * Get the Thumbnail.
      *
-     * @return string|bool
+     * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
      */
     public function getThumbnail()
     {
@@ -356,23 +366,41 @@ class Bookmark
     /**
      * Set the Thumbnail.
      *
-     * @param string|bool $thumbnail
+     * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
      *
      * @return Bookmark
      */
-    public function setThumbnail($thumbnail)
+    public function setThumbnail($thumbnail): Bookmark
     {
         $this->thumbnail = $thumbnail;
 
         return $this;
     }
 
+    /**
+     * Return true if:
+     *   - the bookmark's thumbnail is not already set to false (= not found)
+     *   - it's not a note
+     *   - it's an HTTP(S) link
+     *   - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
+     *
+     * @return bool True if the bookmark's thumbnail needs to be retrieved.
+     */
+    public function shouldUpdateThumbnail(): bool
+    {
+        return $this->thumbnail !== false
+            && !$this->isNote()
+            && startsWith(strtolower($this->url), 'http')
+            && (null === $this->thumbnail || !is_file($this->thumbnail))
+        ;
+    }
+
     /**
      * Get the Sticky.
      *
      * @return bool
      */
-    public function isSticky()
+    public function isSticky(): bool
     {
         return $this->sticky ? true : false;
     }
@@ -380,11 +408,11 @@ class Bookmark
     /**
      * Set the Sticky.
      *
-     * @param bool $sticky
+     * @param bool|null $sticky
      *
      * @return Bookmark
      */
-    public function setSticky($sticky)
+    public function setSticky(?bool $sticky): Bookmark
     {
         $this->sticky = $sticky ? true : false;
 
@@ -394,7 +422,7 @@ class Bookmark
     /**
      * @return string Bookmark's tags as a string, separated by a space
      */
-    public function getTagsString()
+    public function getTagsString(): string
     {
         return implode(' ', $this->getTags());
     }
@@ -402,10 +430,10 @@ class Bookmark
     /**
      * @return bool
      */
-    public function isNote()
+    public function isNote(): bool
     {
         // We check empty value to get a valid result if the link has not been saved yet
-        return empty($this->url) || $this->url[0] === '?';
+        return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
     }
 
     /**
@@ -415,14 +443,14 @@ class Bookmark
      *   - multiple spaces will be removed
      *   - trailing dash in tags will be removed
      *
-     * @param string $tags
+     * @param string|null $tags
      *
      * @return $this
      */
-    public function setTagsString($tags)
+    public function setTagsString(?string $tags): Bookmark
     {
         // Remove first '-' char in tags.
-        $tags = preg_replace('/(^| )\-/', '$1', $tags);
+        $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
         // Explode all tags separted by spaces or commas
         $tags = preg_split('/[\s,]+/', $tags);
         // Remove eventual empty values
@@ -433,13 +461,51 @@ class Bookmark
         return $this;
     }
 
+    /**
+     * Get entire additionalContent array.
+     *
+     * @return mixed[]
+     */
+    public function getAdditionalContent(): array
+    {
+        return $this->additionalContent;
+    }
+
+    /**
+     * Set a single entry in additionalContent, by key.
+     *
+     * @param string     $key
+     * @param mixed|null $value Any type of value can be set.
+     *
+     * @return $this
+     */
+    public function addAdditionalContentEntry(string $key, $value): self
+    {
+        $this->additionalContent[$key] = $value;
+
+        return $this;
+    }
+
+    /**
+     * Get a single entry in additionalContent, by key.
+     *
+     * @param string $key
+     * @param mixed|null $default
+     *
+     * @return mixed|null can be any type or even null.
+     */
+    public function getAdditionalContentEntry(string $key, $default = null)
+    {
+        return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
+    }
+
     /**
      * Rename a tag in tags list.
      *
      * @param string $fromTag
      * @param string $toTag
      */
-    public function renameTag($fromTag, $toTag)
+    public function renameTag(string $fromTag, string $toTag): void
     {
         if (($pos = array_search($fromTag, $this->tags)) !== false) {
             $this->tags[$pos] = trim($toTag);
@@ -451,7 +517,7 @@ class Bookmark
      *
      * @param string $tag
      */
-    public function deleteTag($tag)
+    public function deleteTag(string $tag): void
     {
         if (($pos = array_search($tag, $this->tags)) !== false) {
             unset($this->tags[$pos]);
index d87d43b41ae41a3753aff732aa3989636728c4b4..67bb3b73d55fbcca2f68c3a651f76fb873f140a3 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 use Shaarli\Bookmark\Exception\InvalidBookmarkException;
@@ -187,13 +189,13 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
     /**
      * Returns a bookmark offset in bookmarks array from its unique ID.
      *
-     * @param int $id Persistent ID of a bookmark.
+     * @param int|null $id Persistent ID of a bookmark.
      *
      * @return int Real offset in local array, or null if doesn't exist.
      */
-    protected function getBookmarkOffset($id)
+    protected function getBookmarkOffset(?int $id): ?int
     {
-        if (isset($this->ids[$id])) {
+        if ($id !== null && isset($this->ids[$id])) {
             return $this->ids[$id];
         }
         return null;
@@ -205,7 +207,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
      *
      * @return int next ID.
      */
-    public function getNextId()
+    public function getNextId(): int
     {
         if (!empty($this->ids)) {
             return max(array_keys($this->ids)) + 1;
@@ -214,11 +216,11 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
     }
 
     /**
-     * @param $url
+     * @param string $url
      *
      * @return Bookmark|null
      */
-    public function getByUrl($url)
+    public function getByUrl(string $url): ?Bookmark
     {
         if (! empty($url)
             && isset($this->urls[$url])
@@ -234,16 +236,17 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
      *
      * Also update the urls and ids mapping arrays.
      *
-     * @param string $order ASC|DESC
+     * @param string $order        ASC|DESC
+     * @param bool   $ignoreSticky If set to true, sticky bookmarks won't be first
      */
-    public function reorder($order = 'DESC')
+    public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
     {
         $order = $order === 'ASC' ? -1 : 1;
         // Reorder array by dates.
-        usort($this->bookmarks, function ($a, $b) use ($order) {
+        usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
             /** @var $a Bookmark */
             /** @var $b Bookmark */
-            if ($a->isSticky() !== $b->isSticky()) {
+            if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
                 return $a->isSticky() ? -1 : 1;
             }
             return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
index 9c59e1396a31418a957be70e9e776fcdd586978d..3ea98a45d6bf24c1ce501826d7dc84455bdae5ee 100644 (file)
@@ -1,17 +1,21 @@
 <?php
 
+declare(strict_types=1);
 
 namespace Shaarli\Bookmark;
 
-
+use DateTime;
 use Exception;
+use malkusch\lock\mutex\Mutex;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
 use Shaarli\Bookmark\Exception\EmptyDataStoreException;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Formatter\BookmarkMarkdownFormatter;
 use Shaarli\History;
 use Shaarli\Legacy\LegacyLinkDB;
 use Shaarli\Legacy\LegacyUpdater;
+use Shaarli\Render\PageCacheManager;
 use Shaarli\Updater\UpdaterUtils;
 
 /**
@@ -39,17 +43,25 @@ class BookmarkFileService implements BookmarkServiceInterface
     /** @var History instance */
     protected $history;
 
+    /** @var PageCacheManager instance */
+    protected $pageCacheManager;
+
     /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
     protected $isLoggedIn;
 
+    /** @var Mutex */
+    protected $mutex;
+
     /**
      * @inheritDoc
      */
-    public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
+    public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn)
     {
         $this->conf = $conf;
         $this->history = $history;
-        $this->bookmarksIO = new BookmarkIO($this->conf);
+        $this->mutex = $mutex;
+        $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
+        $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
         $this->isLoggedIn = $isLoggedIn;
 
         if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
@@ -57,10 +69,16 @@ class BookmarkFileService implements BookmarkServiceInterface
         } else {
             try {
                 $this->bookmarks = $this->bookmarksIO->read();
-            } catch (EmptyDataStoreException $e) {
+            } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
                 $this->bookmarks = new BookmarkArray();
-                if ($isLoggedIn) {
-                    $this->save();
+
+                if ($this->isLoggedIn) {
+                    // Datastore file does not exists, we initialize it with default bookmarks.
+                    if ($e instanceof DatastoreNotInitializedException) {
+                        $this->initialize();
+                    } else {
+                        $this->save();
+                    }
                 }
             }
 
@@ -79,22 +97,25 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function findByHash($hash)
+    public function findByHash(string $hash, string $privateKey = null): Bookmark
     {
         $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
         // PHP 7.3 introduced array_key_first() to avoid this hack
         $first = reset($bookmark);
-        if (! $this->isLoggedIn && $first->isPrivate()) {
-            throw new Exception('Not authorized');
+        if (!$this->isLoggedIn
+            && $first->isPrivate()
+            && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
+        ) {
+            throw new BookmarkNotFoundException();
         }
 
-        return $bookmark;
+        return $first;
     }
 
     /**
      * @inheritDoc
      */
-    public function findByUrl($url)
+    public function findByUrl(string $url): ?Bookmark
     {
         return $this->bookmarks->getByUrl($url);
     }
@@ -102,19 +123,28 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false)
-    {
+    public function search(
+        array $request = [],
+        string $visibility = null,
+        bool $caseSensitive = false,
+        bool $untaggedOnly = false,
+        bool $ignoreSticky = false
+    ) {
         if ($visibility === null) {
             $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
         }
 
         // Filter bookmark database according to parameters.
-        $searchtags = isset($request['searchtags']) ? $request['searchtags'] : '';
-        $searchterm = isset($request['searchterm']) ? $request['searchterm'] : '';
+        $searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
+        $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
+
+        if ($ignoreSticky) {
+            $this->bookmarks->reorder('DESC', true);
+        }
 
         return $this->bookmarkFilter->filter(
             BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
-            [$searchtags, $searchterm],
+            [$searchTags, $searchTerm],
             $caseSensitive,
             $visibility,
             $untaggedOnly
@@ -124,7 +154,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function get($id, $visibility = null)
+    public function get(int $id, string $visibility = null): Bookmark
     {
         if (! isset($this->bookmarks[$id])) {
             throw new BookmarkNotFoundException();
@@ -147,20 +177,17 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function set($bookmark, $save = true)
+    public function set(Bookmark $bookmark, bool $save = true): Bookmark
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception(t('Provided data is invalid'));
-        }
         if (! isset($this->bookmarks[$bookmark->getId()])) {
             throw new BookmarkNotFoundException();
         }
         $bookmark->validate();
 
-        $bookmark->setUpdated(new \DateTime());
+        $bookmark->setUpdated(new DateTime());
         $this->bookmarks[$bookmark->getId()] = $bookmark;
         if ($save === true) {
             $this->save();
@@ -172,15 +199,12 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function add($bookmark, $save = true)
+    public function add(Bookmark $bookmark, bool $save = true): Bookmark
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception(t('Provided data is invalid'));
-        }
-        if (! empty($bookmark->getId())) {
+        if (!empty($bookmark->getId())) {
             throw new Exception(t('This bookmarks already exists'));
         }
         $bookmark->setId($this->bookmarks->getNextId());
@@ -197,14 +221,11 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function addOrSet($bookmark, $save = true)
+    public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception('Provided data is invalid');
-        }
         if ($bookmark->getId() === null) {
             return $this->add($bookmark, $save);
         }
@@ -214,14 +235,11 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function remove($bookmark, $save = true)
+    public function remove(Bookmark $bookmark, bool $save = true): void
     {
-        if ($this->isLoggedIn !== true) {
+        if (true !== $this->isLoggedIn) {
             throw new Exception(t('You\'re not authorized to alter the datastore'));
         }
-        if (! $bookmark instanceof Bookmark) {
-            throw new Exception(t('Provided data is invalid'));
-        }
         if (! isset($this->bookmarks[$bookmark->getId()])) {
             throw new BookmarkNotFoundException();
         }
@@ -236,7 +254,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function exists($id, $visibility = null)
+    public function exists(int $id, string $visibility = null): bool
     {
         if (! isset($this->bookmarks[$id])) {
             return false;
@@ -259,7 +277,7 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function count($visibility = null)
+    public function count(string $visibility = null): int
     {
         return count($this->search([], $visibility));
     }
@@ -267,21 +285,22 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function save()
+    public function save(): void
     {
-        if (!$this->isLoggedIn) {
+        if (true !== $this->isLoggedIn) {
             // TODO: raise an Exception instead
             die('You are not authorized to change the database.');
         }
+
         $this->bookmarks->reorder();
         $this->bookmarksIO->write($this->bookmarks);
-        invalidateCaches($this->conf->get('resource.page_cache'));
+        $this->pageCacheManager->invalidateCaches();
     }
 
     /**
      * @inheritDoc
      */
-    public function bookmarksCountPerTag($filteringTags = [], $visibility = null)
+    public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
     {
         $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
         $tags = [];
@@ -291,6 +310,7 @@ class BookmarkFileService implements BookmarkServiceInterface
                 if (empty($tag)
                     || (! $this->isLoggedIn && startsWith($tag, '.'))
                     || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
+                    || in_array($tag, $filteringTags, true)
                 ) {
                     continue;
                 }
@@ -316,45 +336,68 @@ class BookmarkFileService implements BookmarkServiceInterface
         $keys = array_keys($tags);
         $tmpTags = array_combine($keys, $keys);
         array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
+
         return $tags;
     }
 
     /**
      * @inheritDoc
      */
-    public function days()
-    {
-        $bookmarkDays = [];
-        foreach ($this->search() as $bookmark) {
-            $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0;
+    public function findByDate(
+        \DateTimeInterface $from,
+        \DateTimeInterface $to,
+        ?\DateTimeInterface &$previous,
+        ?\DateTimeInterface &$next
+    ): array {
+        $out = [];
+        $previous = null;
+        $next = null;
+
+        foreach ($this->search([], null, false, false, true) as $bookmark) {
+            if ($to < $bookmark->getCreated()) {
+                $next = $bookmark->getCreated();
+            } else if ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
+                $out[] = $bookmark;
+            } else {
+                if ($previous !== null) {
+                    break;
+                }
+                $previous = $bookmark->getCreated();
+            }
         }
-        $bookmarkDays = array_keys($bookmarkDays);
-        sort($bookmarkDays);
 
-        return $bookmarkDays;
+        return $out;
     }
 
     /**
      * @inheritDoc
      */
-    public function filterDay($request)
+    public function getLatest(): ?Bookmark
     {
-        return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request);
+        foreach ($this->search([], null, false, false, true) as $bookmark) {
+            return $bookmark;
+        }
+
+        return null;
     }
 
     /**
      * @inheritDoc
      */
-    public function initialize()
+    public function initialize(): void
     {
         $initializer = new BookmarkInitializer($this);
         $initializer->initialize();
+
+        if (true === $this->isLoggedIn) {
+            $this->save();
+        }
     }
 
     /**
      * Handles migration to the new database format (BookmarksArray).
      */
-    protected function migrate()
+    protected function migrate(): void
     {
         $bookmarkDb = new LegacyLinkDB(
             $this->conf->get('resource.datastore'),
index fd5566790447838e0b7566e09f12695ffe680b0f..c79386ea7ba750db4d1d7d7974ea7564154e943a 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 use Exception;
@@ -77,8 +79,13 @@ class BookmarkFilter
      *
      * @throws BookmarkNotFoundException
      */
-    public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
-    {
+    public function filter(
+        string $type,
+        $request,
+        bool $casesensitive = false,
+        string $visibility = 'all',
+        bool $untaggedonly = false
+    ) {
         if (!in_array($visibility, ['all', 'public', 'private'])) {
             $visibility = 'all';
         }
@@ -115,7 +122,7 @@ class BookmarkFilter
                     return $this->filterTags($request, $casesensitive, $visibility);
                 }
             case self::$FILTER_DAY:
-                return $this->filterDay($request);
+                return $this->filterDay($request, $visibility);
             default:
                 return $this->noFilter($visibility);
         }
@@ -128,7 +135,7 @@ class BookmarkFilter
      *
      * @return Bookmark[] filtered bookmarks.
      */
-    private function noFilter($visibility = 'all')
+    private function noFilter(string $visibility = 'all')
     {
         if ($visibility === 'all') {
             return $this->bookmarks;
@@ -151,11 +158,11 @@ class BookmarkFilter
      *
      * @param string $smallHash permalink hash.
      *
-     * @return array $filtered array containing permalink data.
+     * @return Bookmark[] $filtered array containing permalink data.
      *
-     * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link.
+     * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
      */
-    private function filterSmallHash($smallHash)
+    private function filterSmallHash(string $smallHash)
     {
         foreach ($this->bookmarks as $key => $l) {
             if ($smallHash == $l->getShortUrl()) {
@@ -186,15 +193,15 @@ class BookmarkFilter
      * @param string $searchterms search query.
      * @param string $visibility  Optional: return only all/private/public bookmarks.
      *
-     * @return array search results.
+     * @return Bookmark[] search results.
      */
-    private function filterFulltext($searchterms, $visibility = 'all')
+    private function filterFulltext(string $searchterms, string $visibility = 'all')
     {
         if (empty($searchterms)) {
             return $this->noFilter($visibility);
         }
 
-        $filtered = array();
+        $filtered = [];
         $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
         $exactRegex = '/"([^"]+)"/';
         // Retrieve exact search terms.
@@ -206,8 +213,8 @@ class BookmarkFilter
         $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
 
         // Filter excluding terms and update andSearch.
-        $excludeSearch = array();
-        $andSearch = array();
+        $excludeSearch = [];
+        $andSearch = [];
         foreach ($explodedSearchAnd as $needle) {
             if ($needle[0] == '-' && strlen($needle) > 1) {
                 $excludeSearch[] = substr($needle, 1);
@@ -227,33 +234,38 @@ class BookmarkFilter
                 }
             }
 
-            // Concatenate link fields to search across fields.
-            // Adds a '\' separator for exact search terms.
-            $content  = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
-            $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
-            $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
-            $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
+            $lengths = [];
+            $content = $this->buildFullTextSearchableLink($link, $lengths);
 
             // Be optimistic
             $found = true;
+            $foundPositions = [];
 
             // First, we look for exact term search
-            for ($i = 0; $i < count($exactSearch) && $found; $i++) {
-                $found = strpos($content, $exactSearch[$i]) !== false;
-            }
-
-            // Iterate over keywords, if keyword is not found,
+            // Then iterate over keywords, if keyword is not found,
             // no need to check for the others. We want all or nothing.
-            for ($i = 0; $i < count($andSearch) && $found; $i++) {
-                $found = strpos($content, $andSearch[$i]) !== false;
+            foreach ([$exactSearch, $andSearch] as $search) {
+                for ($i = 0; $i < count($search) && $found !== false; $i++) {
+                    $found = mb_strpos($content, $search[$i]);
+                    if ($found === false) {
+                        break;
+                    }
+
+                    $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
+                }
             }
 
             // Exclude terms.
-            for ($i = 0; $i < count($excludeSearch) && $found; $i++) {
+            for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
                 $found = strpos($content, $excludeSearch[$i]) === false;
             }
 
-            if ($found) {
+            if ($found !== false) {
+                $link->addAdditionalContentEntry(
+                    'search_highlight',
+                    $this->postProcessFoundPositions($lengths, $foundPositions)
+                );
+
                 $filtered[$id] = $link;
             }
         }
@@ -268,7 +280,7 @@ class BookmarkFilter
      *
      * @return string generated regex fragment
      */
-    private static function tag2regex($tag)
+    private static function tag2regex(string $tag): string
     {
         $len = strlen($tag);
         if (!$len || $tag === "-" || $tag === "*") {
@@ -314,13 +326,13 @@ class BookmarkFilter
      * You can specify one or more tags, separated by space or a comma, e.g.
      *  print_r($mydb->filterTags('linux programming'));
      *
-     * @param string $tags          list of tags separated by commas or blank spaces.
-     * @param bool   $casesensitive ignore case if false.
-     * @param string $visibility    Optional: return only all/private/public bookmarks.
+     * @param string|array $tags          list of tags, separated by commas or blank spaces if passed as string.
+     * @param bool         $casesensitive ignore case if false.
+     * @param string       $visibility    Optional: return only all/private/public bookmarks.
      *
-     * @return array filtered bookmarks.
+     * @return Bookmark[] filtered bookmarks.
      */
-    public function filterTags($tags, $casesensitive = false, $visibility = 'all')
+    public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
     {
         // get single tags (we may get passed an array, even though the docs say different)
         $inputTags = $tags;
@@ -396,9 +408,9 @@ class BookmarkFilter
      *
      * @param string $visibility return only all/private/public bookmarks.
      *
-     * @return array filtered bookmarks.
+     * @return Bookmark[] filtered bookmarks.
      */
-    public function filterUntagged($visibility)
+    public function filterUntagged(string $visibility)
     {
         $filtered = [];
         foreach ($this->bookmarks as $key => $link) {
@@ -425,21 +437,26 @@ class BookmarkFilter
      *  print_r($mydb->filterDay('20120125'));
      *
      * @param string $day day to filter.
-     *
-     * @return array all link matching given day.
+     * @param string $visibility return only all/private/public bookmarks.
+
+     * @return Bookmark[] all link matching given day.
      *
      * @throws Exception if date format is invalid.
      */
-    public function filterDay($day)
+    public function filterDay(string $day, string $visibility)
     {
         if (!checkDateFormat('Ymd', $day)) {
             throw new Exception('Invalid date format');
         }
 
-        $filtered = array();
-        foreach ($this->bookmarks as $key => $l) {
-            if ($l->getCreated()->format('Ymd') == $day) {
-                $filtered[$key] = $l;
+        $filtered = [];
+        foreach ($this->bookmarks as $key => $bookmark) {
+            if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) {
+                continue;
+            }
+
+            if ($bookmark->getCreated()->format('Ymd') == $day) {
+                $filtered[$key] = $bookmark;
             }
         }
 
@@ -455,9 +472,9 @@ class BookmarkFilter
      * @param string $tags          string containing a list of tags.
      * @param bool   $casesensitive will convert everything to lowercase if false.
      *
-     * @return array filtered tags string.
+     * @return string[] filtered tags string.
      */
-    public static function tagsStrToArray($tags, $casesensitive)
+    public static function tagsStrToArray(string $tags, bool $casesensitive): array
     {
         // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
         $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
@@ -465,4 +482,74 @@ class BookmarkFilter
 
         return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
     }
+
+    /**
+     * This method finalize the content of the foundPositions array,
+     * by associated all search results to their associated bookmark field,
+     * making sure that there is no overlapping results, etc.
+     *
+     * @param array $fieldLengths   Start and end positions of every bookmark fields in the aggregated bookmark content.
+     * @param array $foundPositions Positions where the search results were found in the aggregated content.
+     *
+     * @return array Updated $foundPositions, by bookmark field.
+     */
+    protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
+    {
+        // Sort results by starting position ASC.
+        usort($foundPositions, function (array $entryA, array $entryB): int {
+            return $entryA['start'] > $entryB['start'] ? 1 : -1;
+        });
+
+        $out = [];
+        $currentMax = -1;
+        foreach ($foundPositions as $foundPosition) {
+            // we do not allow overlapping highlights
+            if ($foundPosition['start'] < $currentMax) {
+                continue;
+            }
+
+            $currentMax = $foundPosition['end'];
+            foreach ($fieldLengths as $part => $length) {
+                if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
+                    continue;
+                }
+
+                $out[$part][] = [
+                    'start' => $foundPosition['start'] - $length['start'],
+                    'end' => $foundPosition['end'] - $length['start'],
+                ];
+                break;
+            }
+        }
+
+        return $out;
+    }
+
+    /**
+     * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
+     * Also populate $length array with starting and ending positions of every bookmark field
+     * inside concatenated content.
+     *
+     * @param Bookmark $link
+     * @param array    $lengths (by reference)
+     *
+     * @return string Lowercase concatenated fields content.
+     */
+    protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
+    {
+        $content  = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
+        $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
+        $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
+        $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
+
+        $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
+        $nextField = $lengths['title']['end'] + 1;
+        $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
+        $nextField = $lengths['description']['end'] + 1;
+        $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
+        $nextField = $lengths['url']['end'] + 1;
+        $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())];
+
+        return $content;
+    }
 }
index ae9ffcb4612bd1acbc633d51b3c48c46cd05ff76..f40fa476247ff2fc9fd75c1048c4b7a340516b61 100644 (file)
@@ -1,7 +1,12 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
+use malkusch\lock\mutex\Mutex;
+use malkusch\lock\mutex\NoMutex;
+use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
 use Shaarli\Bookmark\Exception\EmptyDataStoreException;
 use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
 use Shaarli\Config\ConfigManager;
@@ -26,11 +31,14 @@ class BookmarkIO
      */
     protected $conf;
 
+
+    /** @var Mutex */
+    protected $mutex;
+
     /**
      * string Datastore PHP prefix
      */
     protected static $phpPrefix = '<?php /* ';
-
     /**
      * string Datastore PHP suffix
      */
@@ -41,35 +49,46 @@ class BookmarkIO
      *
      * @param ConfigManager $conf instance
      */
-    public function __construct($conf)
+    public function __construct(ConfigManager $conf, Mutex $mutex = null)
     {
+        if ($mutex === null) {
+            // This should only happen with legacy classes
+            $mutex = new NoMutex();
+        }
         $this->conf = $conf;
         $this->datastore = $conf->get('resource.datastore');
+        $this->mutex = $mutex;
     }
 
     /**
      * Reads database from disk to memory
      *
-     * @return BookmarkArray instance
+     * @return Bookmark[]
      *
-     * @throws NotWritableDataStoreException Data couldn't be loaded
-     * @throws EmptyDataStoreException       Datastore doesn't exist
+     * @throws NotWritableDataStoreException    Data couldn't be loaded
+     * @throws EmptyDataStoreException          Datastore file exists but does not contain any bookmark
+     * @throws DatastoreNotInitializedException File does not exists
      */
     public function read()
     {
         if (! file_exists($this->datastore)) {
-            throw new EmptyDataStoreException();
+            throw new DatastoreNotInitializedException();
         }
 
         if (!is_writable($this->datastore)) {
             throw new NotWritableDataStoreException($this->datastore);
         }
 
+        $content = null;
+        $this->mutex->synchronized(function () use (&$content) {
+            $content = file_get_contents($this->datastore);
+        });
+
         // Note that gzinflate is faster than gzuncompress.
         // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
         $links = unserialize(gzinflate(base64_decode(
-            substr(file_get_contents($this->datastore),
-                strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
+            substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
+        )));
 
         if (empty($links)) {
             if (filesize($this->datastore) > 100) {
@@ -84,7 +103,7 @@ class BookmarkIO
     /**
      * Saves the database from memory to disk
      *
-     * @param BookmarkArray $links instance.
+     * @param Bookmark[] $links
      *
      * @throws NotWritableDataStoreException the datastore is not writable
      */
@@ -98,11 +117,13 @@ class BookmarkIO
             throw new NotWritableDataStoreException(dirname($this->datastore));
         }
 
-        file_put_contents(
-            $this->datastore,
-            self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
-        );
+        $data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix;
 
-        invalidateCaches($this->conf->get('resource.page_cache'));
+        $this->mutex->synchronized(function () use ($data) {
+            file_put_contents(
+                $this->datastore,
+                $data
+            );
+        });
     }
 }
index 9eee9a35bad09f3d9840d6bba035115dddf7075f..04b996f3e6500edc13d8e96353ede55ad250323d 100644 (file)
@@ -1,13 +1,14 @@
 <?php
 
+declare(strict_types=1);
+
 namespace Shaarli\Bookmark;
 
 /**
  * Class BookmarkInitializer
  *
  * This class is used to initialized default bookmarks after a fresh install of Shaarli.
- * It is no longer call when the data store is empty,
- * because user might want to delete default bookmarks after the install.
+ * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
  *
  * To prevent data corruption, it does not overwrite existing bookmarks,
  * even though there should not be any.
@@ -24,7 +25,7 @@ class BookmarkInitializer
      *
      * @param BookmarkServiceInterface $bookmarkService
      */
-    public function __construct($bookmarkService)
+    public function __construct(BookmarkServiceInterface $bookmarkService)
     {
         $this->bookmarkService = $bookmarkService;
     }
@@ -32,28 +33,80 @@ class BookmarkInitializer
     /**
      * Initialize the data store with default bookmarks
      */
-    public function initialize()
+    public function initialize(): void
     {
         $bookmark = new Bookmark();
-        $bookmark->setTitle(t('My secret stuff... - Pastebin.com'));
-        $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []);
-        $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'));
-        $bookmark->setTagsString('secretstuff');
+        $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
+        $bookmark->setUrl('https://vimeo.com/153493904');
+        $bookmark->setDescription(t(
+'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
+
+Explore your new Shaarli instance by trying out controls and menus.
+Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
+
+Now you can edit or delete the default shaares.
+'
+        ));
+        $bookmark->setTagsString('shaarli help thumbnail');
+        $bookmark->setPrivate(true);
+        $this->bookmarkService->add($bookmark, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setTitle(t('Note: Shaare descriptions'));
+        $bookmark->setDescription(t(
+'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
+This note is private, so you are the only one able to see it while logged in.
+
+You can use this to keep notes, post articles, code snippets, and much more.
+
+The Markdown formatting setting allows you to format your notes and bookmark description:
+
+### Title headings
+
+#### Multiple headings levels
+  * bullet lists
+  * _italic_ text
+  * **bold** text
+  * ~~strike through~~ text
+  * `code` blocks
+  * images
+  * [links](https://en.wikipedia.org/wiki/Markdown)
+
+Markdown also supports tables:
+
+| Name    | Type      | Color  | Qty   |
+| ------- | --------- | ------ | ----- |
+| Orange  | Fruit     | Orange | 126   |
+| Apple   | Fruit     | Any    | 62    |
+| Lemon   | Fruit     | Yellow | 30    |
+| Carrot  | Vegetable | Red    | 14    |
+'
+        ));
+        $bookmark->setTagsString('shaarli help');
         $bookmark->setPrivate(true);
-        $this->bookmarkService->add($bookmark);
+        $this->bookmarkService->add($bookmark, false);
 
         $bookmark = new Bookmark();
-        $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service'));
-        $bookmark->setUrl('https://shaarli.readthedocs.io', []);
+        $bookmark->setTitle(
+            'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
+        );
         $bookmark->setDescription(t(
-            'Welcome to Shaarli! This is your first public bookmark. '
-            . 'To edit or delete me, you must first login.
+'Welcome to Shaarli!
+
+Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
+You can add a description to your bookmarks, such as this one, and tag them.
+
+Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
 
-To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
+You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
+Hashtags such as #shaarli #help are also supported.
+You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
 
-You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
+We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
+Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
+'
         ));
-        $bookmark->setTagsString('opensource software');
-        $this->bookmarkService->add($bookmark);
+        $bookmark->setTagsString('shaarli help');
+        $this->bookmarkService->add($bookmark, false);
     }
 }
index 7b7a4f09e131df45e0e3c92fffe1272f37cc4de1..08cdbb4ed4055cc3f6ef2672b991f9c3b1cfeda7 100644 (file)
@@ -1,73 +1,73 @@
 <?php
 
-namespace Shaarli\Bookmark;
+declare(strict_types=1);
 
+namespace Shaarli\Bookmark;
 
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
-use Shaarli\Config\ConfigManager;
-use Shaarli\Exceptions\IOException;
-use Shaarli\History;
 
 /**
  * Class BookmarksService
  *
  * This is the entry point to manipulate the bookmark DB.
+ *
+ * Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
+ * so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
  */
 interface BookmarkServiceInterface
 {
-    /**
-     * BookmarksService constructor.
-     *
-     * @param ConfigManager $conf       instance
-     * @param History       $history    instance
-     * @param bool          $isLoggedIn true if the current user is logged in
-     */
-    public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
-
     /**
      * Find a bookmark by hash
      *
-     * @param string $hash
+     * @param string      $hash       Bookmark's hash
+     * @param string|null $privateKey Optional key used to access private links while logged out
      *
-     * @return mixed
+     * @return Bookmark
      *
      * @throws \Exception
      */
-    public function findByHash($hash);
+    public function findByHash(string $hash, string $privateKey = null);
 
     /**
      * @param $url
      *
      * @return Bookmark|null
      */
-    public function findByUrl($url);
+    public function findByUrl(string $url): ?Bookmark;
 
     /**
      * Search bookmarks
      *
-     * @param mixed  $request
-     * @param string $visibility
-     * @param bool   $caseSensitive
-     * @param bool   $untaggedOnly
+     * @param array   $request
+     * @param ?string $visibility
+     * @param bool    $caseSensitive
+     * @param bool    $untaggedOnly
+     * @param bool    $ignoreSticky
      *
      * @return Bookmark[]
      */
-    public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false);
+    public function search(
+        array $request = [],
+        string $visibility = null,
+        bool $caseSensitive = false,
+        bool $untaggedOnly = false,
+        bool $ignoreSticky = false
+    );
 
     /**
      * Get a single bookmark by its ID.
      *
-     * @param int    $id         Bookmark ID
-     * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
-     *                           exception
+     * @param int    $id          Bookmark ID
+     * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+     *                            exception
      *
      * @return Bookmark
      *
      * @throws BookmarkNotFoundException
      * @throws \Exception
      */
-    public function get($id, $visibility = null);
+    public function get(int $id, string $visibility = null);
 
     /**
      * Updates an existing bookmark (depending on its ID).
@@ -80,7 +80,7 @@ interface BookmarkServiceInterface
      * @throws BookmarkNotFoundException
      * @throws \Exception
      */
-    public function set($bookmark, $save = true);
+    public function set(Bookmark $bookmark, bool $save = true): Bookmark;
 
     /**
      * Adds a new bookmark (the ID must be empty).
@@ -92,7 +92,7 @@ interface BookmarkServiceInterface
      *
      * @throws \Exception
      */
-    public function add($bookmark, $save = true);
+    public function add(Bookmark $bookmark, bool $save = true): Bookmark;
 
     /**
      * Adds or updates a bookmark depending on its ID:
@@ -106,7 +106,7 @@ interface BookmarkServiceInterface
      *
      * @throws \Exception
      */
-    public function addOrSet($bookmark, $save = true);
+    public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
 
     /**
      * Deletes a bookmark.
@@ -116,65 +116,72 @@ interface BookmarkServiceInterface
      *
      * @throws \Exception
      */
-    public function remove($bookmark, $save = true);
+    public function remove(Bookmark $bookmark, bool $save = true): void;
 
     /**
      * Get a single bookmark by its ID.
      *
-     * @param int    $id         Bookmark ID
-     * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
-     *                           exception
+     * @param int     $id         Bookmark ID
+     * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+     *                            exception
      *
      * @return bool
      */
-    public function exists($id, $visibility = null);
+    public function exists(int $id, string $visibility = null): bool;
 
     /**
      * Return the number of available bookmarks for given visibility.
      *
-     * @param string $visibility public|private|all
+     * @param ?string $visibility public|private|all
      *
      * @return int Number of bookmarks
      */
-    public function count($visibility = null);
+    public function count(string $visibility = null): int;
 
     /**
      * Write the datastore.
      *
      * @throws NotWritableDataStoreException
      */
-    public function save();
+    public function save(): void;
 
     /**
      * Returns the list tags appearing in the bookmarks with the given tags
      *
-     * @param array  $filteringTags tags selecting the bookmarks to consider
-     * @param string $visibility    process only all/private/public bookmarks
+     * @param array|null  $filteringTags tags selecting the bookmarks to consider
+     * @param string|null $visibility    process only all/private/public bookmarks
      *
      * @return array tag => bookmarksCount
      */
-    public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all');
+    public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
 
     /**
-     * Returns the list of days containing articles (oldest first)
+     * Return a list of bookmark matching provided period of time.
+     * It also update directly previous and next date outside of given period found in the datastore.
+     *
+     * @param \DateTimeInterface      $from     Starting date.
+     * @param \DateTimeInterface      $to       Ending date.
+     * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
+     * @param \DateTimeInterface|null $next     (by reference) updated with first created date found after $to.
      *
-     * @return array containing days (in format YYYYMMDD).
+     * @return array List of bookmarks matching provided period of time.
      */
-    public function days();
+    public function findByDate(
+        \DateTimeInterface $from,
+        \DateTimeInterface $to,
+        ?\DateTimeInterface &$previous,
+        ?\DateTimeInterface &$next
+    ): array;
 
     /**
-     * Returns the list of articles for a given day.
+     * Returns the latest bookmark by creation date.
      *
-     * @param string $request day to filter. Format: YYYYMMDD.
-     *
-     * @return Bookmark[] list of shaare found.
-     *
-     * @throws BookmarkNotFoundException
+     * @return Bookmark|null Found Bookmark or null if the datastore is empty.
      */
-    public function filterDay($request);
+    public function getLatest(): ?Bookmark;
 
     /**
      * Creates the default database after a fresh install.
      */
-    public function initialize();
+    public function initialize(): void;
 }
index 8837943037dd52468ff6e73bbbc39f8e669b1b04..faf5dbfd4fe24906bf980d8f4cc72e0472b7e008 100644 (file)
@@ -2,112 +2,6 @@
 
 use Shaarli\Bookmark\Bookmark;
 
-/**
- * Get cURL callback function for CURLOPT_WRITEFUNCTION
- *
- * @param string $charset     to extract from the downloaded page (reference)
- * @param string $title       to extract from the downloaded page (reference)
- * @param string $description to extract from the downloaded page (reference)
- * @param string $keywords    to extract from the downloaded page (reference)
- * @param bool   $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
- * @param string $curlGetInfo Optionally overrides curl_getinfo function
- *
- * @return Closure
- */
-function get_curl_download_callback(
-    &$charset,
-    &$title,
-    &$description,
-    &$keywords,
-    $retrieveDescription,
-    $curlGetInfo = 'curl_getinfo'
-) {
-    $isRedirected = false;
-    $currentChunk = 0;
-    $foundChunk = null;
-
-    /**
-     * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
-     *
-     * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
-     * Then we extract the title and the charset and stop the download when it's done.
-     *
-     * @param resource $ch   cURL resource
-     * @param string   $data chunk of data being downloaded
-     *
-     * @return int|bool length of $data or false if we need to stop the download
-     */
-    return function (&$ch, $data) use (
-        $retrieveDescription,
-        $curlGetInfo,
-        &$charset,
-        &$title,
-        &$description,
-        &$keywords,
-        &$isRedirected,
-        &$currentChunk,
-        &$foundChunk
-    ) {
-        $currentChunk++;
-        $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
-        if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
-            $isRedirected = true;
-            return strlen($data);
-        }
-        if (!empty($responseCode) && $responseCode !== 200) {
-            return false;
-        }
-        // After a redirection, the content type will keep the previous request value
-        // until it finds the next content-type header.
-        if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
-            $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
-        }
-        if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
-            return false;
-        }
-        if (!empty($contentType) && empty($charset)) {
-            $charset = header_extract_charset($contentType);
-        }
-        if (empty($charset)) {
-            $charset = html_extract_charset($data);
-        }
-        if (empty($title)) {
-            $title = html_extract_title($data);
-            $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
-        }
-        if ($retrieveDescription && empty($description)) {
-            $description = html_extract_tag('description', $data);
-            $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
-        }
-        if ($retrieveDescription && empty($keywords)) {
-            $keywords = html_extract_tag('keywords', $data);
-            if (! empty($keywords)) {
-                $foundChunk = $currentChunk;
-                // Keywords use the format tag1, tag2 multiple words, tag
-                // So we format them to match Shaarli's separator and glue multiple words with '-'
-                $keywords = implode(' ', array_map(function($keyword) {
-                    return implode('-', preg_split('/\s+/', trim($keyword)));
-                }, explode(',', $keywords)));
-            }
-        }
-
-        // We got everything we want, stop the download.
-        // If we already found either the title, description or keywords,
-        // it's highly unlikely that we'll found the other metas further than
-        // in the same chunk of data or the next one. So we also stop the download after that.
-        if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
-            && (! $retrieveDescription
-                || $foundChunk < $currentChunk
-                || (!empty($title) && !empty($description) && !empty($keywords))
-            )
-        ) {
-            return false;
-        }
-
-        return strlen($data);
-    };
-}
-
 /**
  * Extract title from an HTML document.
  *
@@ -132,7 +26,7 @@ function html_extract_title($html)
  */
 function header_extract_charset($header)
 {
-    preg_match('/charset="?([^; ]+)/i', $header, $match);
+    preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
     if (! empty($match[1])) {
         return strtolower(trim($match[1]));
     }
@@ -172,11 +66,13 @@ function html_extract_tag($tag, $html)
 {
     $propertiesKey = ['property', 'name', 'itemprop'];
     $properties = implode('|', $propertiesKey);
+    // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
+    $orCondition  = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
     // Try to retrieve OpenGraph image.
-    $ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#';
+    $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#';
     // If the attributes are not in the order property => content (e.g. Github)
     // New regex to keep this readable... more or less.
-    $ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#';
+    $ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#';
 
     if (preg_match($ogRegex, $html, $matches) > 0
         || preg_match($ogRegexReverse, $html, $matches) > 0
@@ -220,7 +116,7 @@ function hashtag_autolink($description, $indexUrl = '')
      * \p{Mn} - any non marking space (accents, umlauts, etc)
      */
     $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
-    $replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>';
+    $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
     return preg_replace($regex, $replacement, $description);
 }
 
diff --git a/application/bookmark/exception/DatastoreNotInitializedException.php b/application/bookmark/exception/DatastoreNotInitializedException.php
new file mode 100644 (file)
index 0000000..f495049
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Bookmark\Exception;
+
+class DatastoreNotInitializedException extends \Exception
+{
+
+}
index 4509357ce887ea8a28334a68c50f999294780243..23b22269540d46f3a03770ea50865738e31c1209 100644 (file)
@@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
         $data = file_get_contents($filepath);
         $data = str_replace(self::getPhpHeaders(), '', $data);
         $data = str_replace(self::getPhpSuffix(), '', $data);
-        $data = json_decode($data, true);
+        $data = json_decode(trim($data), true);
         if ($data === null) {
             $errorCode = json_last_error();
             $error  = sprintf(
@@ -46,7 +46,7 @@ class ConfigJson implements ConfigIO
         // JSON_PRETTY_PRINT is available from PHP 5.4.
         $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0;
         $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix();
-        if (!file_put_contents($filepath, $data)) {
+        if (empty($filepath) || !file_put_contents($filepath, $data)) {
             throw new \Shaarli\Exceptions\IOException(
                 $filepath,
                 t('Shaarli could not create the config file. '.
@@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
      */
     public static function getPhpHeaders()
     {
-        return '<?php /*'. PHP_EOL;
+        return '<?php /*';
     }
 
     /**
@@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
      */
     public static function getPhpSuffix()
     {
-        return PHP_EOL . '*/ ?>';
+        return '*/ ?>';
     }
 }
index e45bb4c391a21d1c5d14ec00007bf830788ba144..fb0850235fb78da19b7ea686ebcad0a07f082d49 100644 (file)
@@ -3,6 +3,7 @@ namespace Shaarli\Config;
 
 use Shaarli\Config\Exception\MissingFieldConfigException;
 use Shaarli\Config\Exception\UnauthorizedConfigException;
+use Shaarli\Thumbnailer;
 
 /**
  * Class ConfigManager
@@ -361,11 +362,12 @@ class ConfigManager
         $this->setEmpty('security.open_shaarli', false);
         $this->setEmpty('security.allowed_protocols', ['ftp', 'ftps', 'magnet']);
 
-        $this->setEmpty('general.header_link', '?');
+        $this->setEmpty('general.header_link', '/');
         $this->setEmpty('general.links_per_page', 20);
         $this->setEmpty('general.enabled_plugins', self::$DEFAULT_PLUGINS);
         $this->setEmpty('general.default_note_title', 'Note: ');
-        $this->setEmpty('general.retrieve_description', false);
+        $this->setEmpty('general.retrieve_description', true);
+        $this->setEmpty('general.enable_async_metadata', true);
 
         $this->setEmpty('updates.check_updates', false);
         $this->setEmpty('updates.check_updates_branch', 'stable');
@@ -381,6 +383,7 @@ class ConfigManager
         // default state of the 'remember me' checkbox of the login form
         $this->setEmpty('privacy.remember_user_default', true);
 
+        $this->setEmpty('thumbnails.mode', Thumbnailer::MODE_ALL);
         $this->setEmpty('thumbnails.width', '125');
         $this->setEmpty('thumbnails.height', '90');
 
index dbb249374a7262053b5e03000d38b746c9772563..ea8dfbdade4f0f0776eff517545e1b3f96f536f3 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use Shaarli\Config\Exception\PluginConfigOrderException;
+use Shaarli\Plugin\PluginManager;
 
 /**
  * Plugin configuration helper functions.
@@ -19,6 +20,20 @@ use Shaarli\Config\Exception\PluginConfigOrderException;
  */
 function save_plugin_config($formData)
 {
+    // We can only save existing plugins
+    $directories = str_replace(
+        PluginManager::$PLUGINS_PATH . '/',
+        '',
+        glob(PluginManager::$PLUGINS_PATH . '/*')
+    );
+    $formData = array_filter(
+        $formData,
+        function ($value, string $key) use ($directories) {
+            return startsWith($key, 'order') || in_array($key, $directories);
+        },
+        ARRAY_FILTER_USE_BOTH
+    );
+
     // Make sure there are no duplicates in orders.
     if (!validate_plugin_order($formData)) {
         throw new PluginConfigOrderException();
@@ -69,7 +84,7 @@ function validate_plugin_order($formData)
     $orders = array();
     foreach ($formData as $key => $value) {
         // No duplicate order allowed.
-        if (in_array($value, $orders)) {
+        if (in_array($value, $orders, true)) {
             return false;
         }
 
index e2c78ccc44f4c47a412a4ba10a1e95c0c2f93fdb..d84418ad6f7828b99ff1fb622013451825cd8e49 100644 (file)
@@ -4,14 +4,28 @@ declare(strict_types=1);
 
 namespace Shaarli\Container;
 
+use malkusch\lock\mutex\FlockMutex;
+use Psr\Log\LoggerInterface;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\Feed\FeedBuilder;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Front\Controller\Visitor\ErrorController;
+use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
 use Shaarli\History;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
+use Shaarli\Netscape\NetscapeBookmarkUtils;
 use Shaarli\Plugin\PluginManager;
 use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\CookieManager;
 use Shaarli\Security\LoginManager;
 use Shaarli\Security\SessionManager;
+use Shaarli\Thumbnailer;
+use Shaarli\Updater\Updater;
+use Shaarli\Updater\UpdaterUtils;
 
 /**
  * Class ContainerBuilder
@@ -30,22 +44,43 @@ class ContainerBuilder
     /** @var SessionManager */
     protected $session;
 
+    /** @var CookieManager */
+    protected $cookieManager;
+
     /** @var LoginManager */
     protected $login;
 
-    public function __construct(ConfigManager $conf, SessionManager $session, LoginManager $login)
-    {
+    /** @var LoggerInterface */
+    protected $logger;
+
+    /** @var string|null */
+    protected $basePath = null;
+
+    public function __construct(
+        ConfigManager $conf,
+        SessionManager $session,
+        CookieManager $cookieManager,
+        LoginManager $login,
+        LoggerInterface $logger
+    ) {
         $this->conf = $conf;
         $this->session = $session;
         $this->login = $login;
+        $this->cookieManager = $cookieManager;
+        $this->logger = $logger;
     }
 
     public function build(): ShaarliContainer
     {
         $container = new ShaarliContainer();
+
         $container['conf'] = $this->conf;
         $container['sessionManager'] = $this->session;
+        $container['cookieManager'] = $this->cookieManager;
         $container['loginManager'] = $this->login;
+        $container['logger'] = $this->logger;
+        $container['basePath'] = $this->basePath;
+
         $container['plugins'] = function (ShaarliContainer $container): PluginManager {
             return new PluginManager($container->conf);
         };
@@ -58,14 +93,20 @@ class ContainerBuilder
             return new BookmarkFileService(
                 $container->conf,
                 $container->history,
+                new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
                 $container->loginManager->isLoggedIn()
             );
         };
 
+        $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever {
+            return new MetadataRetriever($container->conf, $container->httpAccess);
+        };
+
         $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder {
             return new PageBuilder(
                 $container->conf,
                 $container->sessionManager->getSession(),
+                $container->logger,
                 $container->bookmarkService,
                 $container->sessionManager->generateToken(),
                 $container->loginManager->isLoggedIn()
@@ -73,7 +114,65 @@ class ContainerBuilder
         };
 
         $container['pluginManager'] = function (ShaarliContainer $container): PluginManager {
-            return new PluginManager($container->conf);
+            $pluginManager = new PluginManager($container->conf);
+
+            $pluginManager->load($container->conf->get('general.enabled_plugins'));
+
+            return $pluginManager;
+        };
+
+        $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory {
+            return new FormatterFactory(
+                $container->conf,
+                $container->loginManager->isLoggedIn()
+            );
+        };
+
+        $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager {
+            return new PageCacheManager(
+                $container->conf->get('resource.page_cache'),
+                $container->loginManager->isLoggedIn()
+            );
+        };
+
+        $container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder {
+            return new FeedBuilder(
+                $container->bookmarkService,
+                $container->formatterFactory->getFormatter(),
+                $container->environment,
+                $container->loginManager->isLoggedIn()
+            );
+        };
+
+        $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer {
+            return new Thumbnailer($container->conf);
+        };
+
+        $container['httpAccess'] = function (): HttpAccess {
+            return new HttpAccess();
+        };
+
+        $container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils {
+            return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history);
+        };
+
+        $container['updater'] = function (ShaarliContainer $container): Updater {
+            return new Updater(
+                UpdaterUtils::read_updates_file($container->conf->get('resource.updates')),
+                $container->bookmarkService,
+                $container->conf,
+                $container->loginManager->isLoggedIn()
+            );
+        };
+
+        $container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController {
+            return new ErrorNotFoundController($container);
+        };
+        $container['errorHandler'] = function (ShaarliContainer $container): ErrorController {
+            return new ErrorController($container);
+        };
+        $container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController {
+            return new ErrorController($container);
         };
 
         return $container;
index 3fa9116e3544ee2e6b73f8b552a9bf30b6b92aa7..3e5bd25269e9ca24964c5f45adc0be16fa23e027 100644 (file)
@@ -4,25 +4,50 @@ declare(strict_types=1);
 
 namespace Shaarli\Container;
 
+use Psr\Log\LoggerInterface;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\Feed\FeedBuilder;
+use Shaarli\Formatter\FormatterFactory;
 use Shaarli\History;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
+use Shaarli\Netscape\NetscapeBookmarkUtils;
 use Shaarli\Plugin\PluginManager;
 use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\CookieManager;
 use Shaarli\Security\LoginManager;
 use Shaarli\Security\SessionManager;
+use Shaarli\Thumbnailer;
+use Shaarli\Updater\Updater;
 use Slim\Container;
 
 /**
  * Extension of Slim container to document the injected objects.
  *
+ * @property string                   $basePath              Shaarli's instance base path (e.g. `/shaarli/`)
+ * @property BookmarkServiceInterface $bookmarkService
+ * @property CookieManager            $cookieManager
  * @property ConfigManager            $conf
- * @property SessionManager           $sessionManager
- * @property LoginManager             $loginManager
+ * @property mixed[]                  $environment           $_SERVER automatically injected by Slim
+ * @property callable                 $errorHandler          Overrides default Slim exception display
+ * @property FeedBuilder              $feedBuilder
+ * @property FormatterFactory         $formatterFactory
  * @property History                  $history
- * @property BookmarkServiceInterface $bookmarkService
+ * @property HttpAccess               $httpAccess
+ * @property LoginManager             $loginManager
+ * @property LoggerInterface          $logger
+ * @property MetadataRetriever        $metadataRetriever
+ * @property NetscapeBookmarkUtils    $netscapeBookmarkUtils
+ * @property callable                 $notFoundHandler       Overrides default Slim exception display
  * @property PageBuilder              $pageBuilder
+ * @property PageCacheManager         $pageCacheManager
+ * @property callable                 $phpErrorHandler       Overrides default Slim PHP error display
  * @property PluginManager            $pluginManager
+ * @property SessionManager           $sessionManager
+ * @property Thumbnailer              $thumbnailer
+ * @property Updater                  $updater
  */
 class ShaarliContainer extends Container
 {
diff --git a/application/feed/Cache.php b/application/feed/Cache.php
deleted file mode 100644 (file)
index e5d43e6..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-<?php
-/**
- * Cache utilities
- */
-
-/**
- * Purges all cached pages
- *
- * @param string $pageCacheDir page cache directory
- *
- * @return mixed an error string if the directory is missing
- */
-function purgeCachedPages($pageCacheDir)
-{
-    if (! is_dir($pageCacheDir)) {
-        $error = sprintf(t('Cannot purge %s: no directory'), $pageCacheDir);
-        error_log($error);
-        return $error;
-    }
-
-    array_map('unlink', glob($pageCacheDir.'/*.cache'));
-}
-
-/**
- * Invalidates caches when the database is changed or the user logs out.
- *
- * @param string $pageCacheDir page cache directory
- */
-function invalidateCaches($pageCacheDir)
-{
-    // Purge cache attached to session.
-    if (isset($_SESSION['tags'])) {
-        unset($_SESSION['tags']);
-    }
-
-    // Purge page cache shared by sessions.
-    purgeCachedPages($pageCacheDir);
-}
index 40bd4f153393553bfda8803a3b81e739bb9e155b..f70fce4fb7fa0589269256c1907ffa5654051d01 100644 (file)
@@ -43,21 +43,9 @@ class FeedBuilder
      */
     protected $formatter;
 
-    /**
-     * @var string RSS or ATOM feed.
-     */
-    protected $feedType;
-
-    /**
-     * @var array $_SERVER
-     */
+    /** @var mixed[] $_SERVER */
     protected $serverInfo;
 
-    /**
-     * @var array $_GET
-     */
-    protected $userInput;
-
     /**
      * @var boolean True if the user is currently logged in, false otherwise.
      */
@@ -77,7 +65,6 @@ class FeedBuilder
      * @var string server locale.
      */
     protected $locale;
-
     /**
      * @var DateTime Latest item date.
      */
@@ -88,37 +75,36 @@ class FeedBuilder
      *
      * @param BookmarkServiceInterface $linkDB     LinkDB instance.
      * @param BookmarkFormatter        $formatter  instance.
-     * @param string                   $feedType   Type of feed.
      * @param array                    $serverInfo $_SERVER.
-     * @param array                    $userInput  $_GET.
      * @param boolean                  $isLoggedIn True if the user is currently logged in, false otherwise.
      */
-    public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn)
+    public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn)
     {
         $this->linkDB = $linkDB;
         $this->formatter = $formatter;
-        $this->feedType = $feedType;
         $this->serverInfo = $serverInfo;
-        $this->userInput = $userInput;
         $this->isLoggedIn = $isLoggedIn;
     }
 
     /**
      * Build data for feed templates.
      *
+     * @param string $feedType   Type of feed (RSS/ATOM).
+     * @param array  $userInput  $_GET.
+     *
      * @return array Formatted data for feeds templates.
      */
-    public function buildData()
+    public function buildData(string $feedType, ?array $userInput)
     {
         // Search for untagged bookmarks
-        if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) {
-            $this->userInput['searchtags'] = false;
+        if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) {
+            $userInput['searchtags'] = false;
         }
 
         // Optionally filter the results:
-        $linksToDisplay = $this->linkDB->search($this->userInput);
+        $linksToDisplay = $this->linkDB->search($userInput ?? [], null, false, false, true);
 
-        $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay));
+        $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay), $userInput);
 
         // Can't use array_keys() because $link is a LinkDB instance and not a real array.
         $keys = array();
@@ -130,15 +116,15 @@ class FeedBuilder
         $this->formatter->addContextData('index_url', $pageaddr);
         $linkDisplayed = array();
         for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) {
-            $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr);
+            $linkDisplayed[$keys[$i]] = $this->buildItem($feedType, $linksToDisplay[$keys[$i]], $pageaddr);
         }
 
-        $data['language'] = $this->getTypeLanguage();
-        $data['last_update'] = $this->getLatestDateFormatted();
+        $data['language'] = $this->getTypeLanguage($feedType);
+        $data['last_update'] = $this->getLatestDateFormatted($feedType);
         $data['show_dates'] = !$this->hideDates || $this->isLoggedIn;
-        // Remove leading slash from REQUEST_URI.
-        $data['self_link'] = escape(server_url($this->serverInfo))
-            . escape($this->serverInfo['REQUEST_URI']);
+        // Remove leading path from REQUEST_URI (already contained in $pageaddr).
+        $requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI']));
+        $data['self_link'] = $pageaddr . $requestUri;
         $data['index_url'] = $pageaddr;
         $data['usepermalinks'] = $this->usePermalinks === true;
         $data['links'] = $linkDisplayed;
@@ -146,18 +132,49 @@ class FeedBuilder
         return $data;
     }
 
+    /**
+     * Set this to true to use permalinks instead of direct bookmarks.
+     *
+     * @param boolean $usePermalinks true to force permalinks.
+     */
+    public function setUsePermalinks($usePermalinks)
+    {
+        $this->usePermalinks = $usePermalinks;
+    }
+
+    /**
+     * Set this to true to hide timestamps in feeds.
+     *
+     * @param boolean $hideDates true to enable.
+     */
+    public function setHideDates($hideDates)
+    {
+        $this->hideDates = $hideDates;
+    }
+
+    /**
+     * Set the locale. Used to show feed language.
+     *
+     * @param string $locale The locale (eg. 'fr_FR.UTF8').
+     */
+    public function setLocale($locale)
+    {
+        $this->locale = strtolower($locale);
+    }
+
     /**
      * Build a feed item (one per shaare).
      *
+     * @param string $feedType Type of feed (RSS/ATOM).
      * @param Bookmark $link     Single link array extracted from LinkDB.
      * @param string   $pageaddr Index URL.
      *
      * @return array Link array with feed attributes.
      */
-    protected function buildItem($link, $pageaddr)
+    protected function buildItem(string $feedType, $link, $pageaddr)
     {
         $data = $this->formatter->format($link);
-        $data['guid'] = $pageaddr . '?' . $data['shorturl'];
+        $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl'];
         if ($this->usePermalinks === true) {
             $permalink = '<a href="'. $data['url'] .'" title="'. t('Direct link') .'">'. t('Direct link') .'</a>';
         } else {
@@ -165,13 +182,13 @@ class FeedBuilder
         }
         $data['description'] .= PHP_EOL . PHP_EOL . '<br>&#8212; ' . $permalink;
 
-        $data['pub_iso_date'] = $this->getIsoDate($data['created']);
+        $data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']);
 
         // atom:entry elements MUST contain exactly one atom:updated element.
         if (!empty($link->getUpdated())) {
-            $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM);
+            $data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM);
         } else {
-            $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM);
+            $data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM);
         }
 
         // Save the more recent item.
@@ -185,52 +202,24 @@ class FeedBuilder
         return $data;
     }
 
-    /**
-     * Set this to true to use permalinks instead of direct bookmarks.
-     *
-     * @param boolean $usePermalinks true to force permalinks.
-     */
-    public function setUsePermalinks($usePermalinks)
-    {
-        $this->usePermalinks = $usePermalinks;
-    }
-
-    /**
-     * Set this to true to hide timestamps in feeds.
-     *
-     * @param boolean $hideDates true to enable.
-     */
-    public function setHideDates($hideDates)
-    {
-        $this->hideDates = $hideDates;
-    }
-
-    /**
-     * Set the locale. Used to show feed language.
-     *
-     * @param string $locale The locale (eg. 'fr_FR.UTF8').
-     */
-    public function setLocale($locale)
-    {
-        $this->locale = strtolower($locale);
-    }
-
     /**
      * Get the language according to the feed type, based on the locale:
      *
      *   - RSS format: en-us (default: 'en-en').
      *   - ATOM format: fr (default: 'en').
      *
+     * @param string $feedType Type of feed (RSS/ATOM).
+     *
      * @return string The language.
      */
-    public function getTypeLanguage()
+    protected function getTypeLanguage(string $feedType)
     {
         // Use the locale do define the language, if available.
         if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) {
-            $length = ($this->feedType === self::$FEED_RSS) ? 5 : 2;
+            $length = ($feedType === self::$FEED_RSS) ? 5 : 2;
             return str_replace('_', '-', substr($this->locale, 0, $length));
         }
-        return ($this->feedType === self::$FEED_RSS) ? 'en-en' : 'en';
+        return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en';
     }
 
     /**
@@ -238,32 +227,35 @@ class FeedBuilder
      *
      * Return an empty string if invalid DateTime is passed.
      *
+     * @param string $feedType Type of feed (RSS/ATOM).
+     *
      * @return string Formatted date.
      */
-    protected function getLatestDateFormatted()
+    protected function getLatestDateFormatted(string $feedType)
     {
         if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) {
             return '';
         }
 
-        $type = ($this->feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
+        $type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM;
         return $this->latestDate->format($type);
     }
 
     /**
      * Get ISO date from DateTime according to feed type.
      *
+     * @param string      $feedType Type of feed (RSS/ATOM).
      * @param DateTime    $date   Date to format.
      * @param string|bool $format Force format.
      *
      * @return string Formatted date.
      */
-    protected function getIsoDate(DateTime $date, $format = false)
+    protected function getIsoDate(string $feedType, DateTime $date, $format = false)
     {
         if ($format !== false) {
             return $date->format($format);
         }
-        if ($this->feedType == self::$FEED_RSS) {
+        if ($feedType == self::$FEED_RSS) {
             return $date->format(DateTime::RSS);
         }
         return $date->format(DateTime::ATOM);
@@ -275,21 +267,22 @@ class FeedBuilder
      * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS.
      * If 'nb' is set to 'all', display all filtered bookmarks (max parameter).
      *
-     * @param int $max maximum number of bookmarks to display.
+     * @param int   $max       maximum number of bookmarks to display.
+     * @param array $userInput $_GET.
      *
      * @return int number of bookmarks to display.
      */
-    public function getNbLinks($max)
+    protected function getNbLinks($max, ?array $userInput)
     {
-        if (empty($this->userInput['nb'])) {
+        if (empty($userInput['nb'])) {
             return self::$DEFAULT_NB_LINKS;
         }
 
-        if ($this->userInput['nb'] == 'all') {
+        if ($userInput['nb'] == 'all') {
             return $max;
         }
 
-        $intNb = intval($this->userInput['nb']);
+        $intNb = intval($userInput['nb']);
         if (!is_int($intNb) || $intNb == 0) {
             return self::$DEFAULT_NB_LINKS;
         }
index c6c590647550e50c6ee688ec497d2a6c7167d497..d58a5e39dde46ca5f5f0c71ac80e1f60fde8e55b 100644 (file)
@@ -12,10 +12,13 @@ namespace Shaarli\Formatter;
  */
 class BookmarkDefaultFormatter extends BookmarkFormatter
 {
+    const SEARCH_HIGHLIGHT_OPEN = '|@@HIGHLIGHT';
+    const SEARCH_HIGHLIGHT_CLOSE = 'HIGHLIGHT@@|';
+
     /**
      * @inheritdoc
      */
-    public function formatTitle($bookmark)
+    protected function formatTitle($bookmark)
     {
         return escape($bookmark->getTitle());
     }
@@ -23,10 +26,28 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
     /**
      * @inheritdoc
      */
-    public function formatDescription($bookmark)
+    protected function formatTitleHtml($bookmark)
+    {
+        $title = $this->tokenizeSearchHighlightField(
+            $bookmark->getTitle() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? []
+        );
+
+        return $this->replaceTokens(escape($title));
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function formatDescription($bookmark)
     {
         $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : '';
-        return format_description(escape($bookmark->getDescription()), $indexUrl);
+        $description = $this->tokenizeSearchHighlightField(
+            $bookmark->getDescription() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
+        );
+
+        return $this->replaceTokens(format_description(escape($description), $indexUrl));
     }
 
     /**
@@ -40,7 +61,27 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
     /**
      * @inheritdoc
      */
-    public function formatTagString($bookmark)
+    protected function formatTagListHtml($bookmark)
+    {
+        if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
+            return $this->formatTagList($bookmark);
+        }
+
+        $tags = $this->tokenizeSearchHighlightField(
+            $bookmark->getTagsString(),
+            $bookmark->getAdditionalContentEntry('search_highlight')['tags']
+        );
+        $tags = $this->filterTagList(explode(' ', $tags));
+        $tags = escape($tags);
+        $tags = $this->replaceTokensArray($tags);
+
+        return $tags;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    protected function formatTagString($bookmark)
     {
         return implode(' ', $this->formatTagList($bookmark));
     }
@@ -48,13 +89,12 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
     /**
      * @inheritdoc
      */
-    public function formatUrl($bookmark)
+    protected function formatUrl($bookmark)
     {
-        if (! empty($this->contextData['index_url']) && (
-            startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/')
-        )) {
-            return $this->contextData['index_url'] . escape($bookmark->getUrl());
+        if ($bookmark->isNote() && isset($this->contextData['index_url'])) {
+            return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/'));
         }
+
         return escape($bookmark->getUrl());
     }
 
@@ -63,14 +103,34 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
      */
     protected function formatRealUrl($bookmark)
     {
-        if (! empty($this->contextData['index_url']) && (
-                startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/')
-            )) {
-            return $this->contextData['index_url'] . escape($bookmark->getUrl());
+        if ($bookmark->isNote()) {
+            if (isset($this->contextData['index_url'])) {
+                $prefix = rtrim($this->contextData['index_url'], '/') . '/';
+            }
+
+            if (isset($this->contextData['base_path'])) {
+                $prefix = rtrim($this->contextData['base_path'], '/') . '/';
+            }
+
+            return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl(), '/'));
         }
+
         return escape($bookmark->getUrl());
     }
 
+    /**
+     * @inheritdoc
+     */
+    protected function formatUrlHtml($bookmark)
+    {
+        $url = $this->tokenizeSearchHighlightField(
+            $bookmark->getUrl() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? []
+        );
+
+        return $this->replaceTokens(escape($url));
+    }
+
     /**
      * @inheritdoc
      */
@@ -78,4 +138,72 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
     {
         return escape($bookmark->getThumbnail());
     }
+
+    /**
+     * Insert search highlight token in provided field content based on a list of search result positions
+     *
+     * @param string     $fieldContent
+     * @param array|null $positions    List of of search results with 'start' and 'end' positions.
+     *
+     * @return string Updated $fieldContent.
+     */
+    protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string
+    {
+        if (empty($positions)) {
+            return $fieldContent;
+        }
+
+        $insertedTokens = 0;
+        $tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN);
+        foreach ($positions as $position) {
+            $position = [
+                'start' => $position['start'] + ($insertedTokens * $tokenLength),
+                'end' => $position['end'] + ($insertedTokens * $tokenLength),
+            ];
+
+            $content = mb_substr($fieldContent, 0, $position['start']);
+            $content .= static::SEARCH_HIGHLIGHT_OPEN;
+            $content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']);
+            $content .= static::SEARCH_HIGHLIGHT_CLOSE;
+            $content .= mb_substr($fieldContent, $position['end']);
+
+            $fieldContent = $content;
+
+            $insertedTokens += 2;
+        }
+
+        return $fieldContent;
+    }
+
+    /**
+     * Replace search highlight tokens with HTML highlighted span.
+     *
+     * @param string $fieldContent
+     *
+     * @return string updated content.
+     */
+    protected function replaceTokens(string $fieldContent): string
+    {
+        return str_replace(
+            [static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE],
+            ['<span class="search-highlight">', '</span>'],
+            $fieldContent
+        );
+    }
+
+    /**
+     * Apply replaceTokens to an array of content strings.
+     *
+     * @param string[] $fieldContents
+     *
+     * @return array
+     */
+    protected function replaceTokensArray(array $fieldContents): array
+    {
+        foreach ($fieldContents as &$entry) {
+            $entry = $this->replaceTokens($entry);
+        }
+
+        return $fieldContents;
+    }
 }
index a80d83fc1639006ebd915f90c8588213383f3b9c..e1b7f705e29b0e87ee8841c9b2e298a807c4cc2c 100644 (file)
@@ -2,15 +2,38 @@
 
 namespace Shaarli\Formatter;
 
-use DateTime;
-use Shaarli\Config\ConfigManager;
+use DateTimeInterface;
 use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
 
 /**
  * Class BookmarkFormatter
  *
  * Abstract class processing all bookmark attributes through methods designed to be overridden.
  *
+ * List of available formatted fields:
+ *   - id                 ID
+ *   - shorturl           Unique identifier, used in permalinks
+ *   - url                URL, can be altered in some way, e.g. passing through an HTTP reverse proxy
+ *   - real_url           (legacy) same as `url`
+ *   - url_html           URL to be displayed in HTML content (it can contain HTML tags)
+ *   - title              Title
+ *   - title_html         Title to be displayed in HTML content (it can contain HTML tags)
+ *   - description        Description content. It most likely contains HTML tags
+ *   - thumbnail          Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved
+ *   - taglist            List of tags (array)
+ *   - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag
+ *   - taglist_html       List of tags (array) to be displayed in HTML content (it can contain HTML tags)
+ *   - tags               Tags separated by a single whitespace
+ *   - tags_urlencoded    Tags separated by a single whitespace, URL encoded: must be used to create a link
+ *   - sticky             Is sticky (bool)
+ *   - private            Is private (bool)
+ *   - class              Additional CSS class
+ *   - created            Creation DateTime
+ *   - updated            Last edit DateTime
+ *   - timestamp          Creation timestamp
+ *   - updated_timestamp  Last edit timestamp
+ *
  * @package Shaarli\Formatter
  */
 abstract class BookmarkFormatter
@@ -55,11 +78,16 @@ abstract class BookmarkFormatter
         $out['shorturl'] = $this->formatShortUrl($bookmark);
         $out['url'] = $this->formatUrl($bookmark);
         $out['real_url'] = $this->formatRealUrl($bookmark);
+        $out['url_html'] = $this->formatUrlHtml($bookmark);
         $out['title'] = $this->formatTitle($bookmark);
+        $out['title_html'] = $this->formatTitleHtml($bookmark);
         $out['description'] = $this->formatDescription($bookmark);
         $out['thumbnail'] = $this->formatThumbnail($bookmark);
         $out['taglist'] = $this->formatTagList($bookmark);
+        $out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark);
+        $out['taglist_html'] = $this->formatTagListHtml($bookmark);
         $out['tags'] = $this->formatTagString($bookmark);
+        $out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($bookmark);
         $out['sticky'] = $bookmark->isSticky();
         $out['private'] = $bookmark->isPrivate();
         $out['class'] = $this->formatClass($bookmark);
@@ -67,6 +95,7 @@ abstract class BookmarkFormatter
         $out['updated'] = $this->formatUpdated($bookmark);
         $out['timestamp'] = $this->formatCreatedTimestamp($bookmark);
         $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark);
+
         return $out;
     }
 
@@ -80,6 +109,8 @@ abstract class BookmarkFormatter
     public function addContextData($key, $value)
     {
         $this->contextData[$key] = $value;
+
+        return $this;
     }
 
     /**
@@ -128,7 +159,19 @@ abstract class BookmarkFormatter
      */
     protected function formatRealUrl($bookmark)
     {
-        return $bookmark->getUrl();
+        return $this->formatUrl($bookmark);
+    }
+
+    /**
+     * Format Url Html: to be displayed in HTML content, it can contains HTML tags.
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return string formatted Url HTML
+     */
+    protected function formatUrlHtml($bookmark)
+    {
+        return $this->formatUrl($bookmark);
     }
 
     /**
@@ -143,6 +186,18 @@ abstract class BookmarkFormatter
         return $bookmark->getTitle();
     }
 
+    /**
+     * Format Title HTML: to be displayed in HTML content, it can contains HTML tags.
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return string formatted Title
+     */
+    protected function formatTitleHtml($bookmark)
+    {
+        return $bookmark->getTitle();
+    }
+
     /**
      * Format Description
      *
@@ -179,6 +234,30 @@ abstract class BookmarkFormatter
         return $this->filterTagList($bookmark->getTags());
     }
 
+    /**
+     * Format Url Encoded Tags
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return array formatted Tags
+     */
+    protected function formatTagListUrlEncoded($bookmark)
+    {
+        return array_map('urlencode', $this->filterTagList($bookmark->getTags()));
+    }
+
+    /**
+     * Format Tags HTML: to be displayed in HTML content, it can contains HTML tags.
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return array formatted Tags
+     */
+    protected function formatTagListHtml($bookmark)
+    {
+        return $this->formatTagList($bookmark);
+    }
+
     /**
      * Format TagString
      *
@@ -191,6 +270,18 @@ abstract class BookmarkFormatter
         return implode(' ', $this->formatTagList($bookmark));
     }
 
+    /**
+     * Format TagString
+     *
+     * @param Bookmark $bookmark instance
+     *
+     * @return string formatted TagString
+     */
+    protected function formatTagStringUrlEncoded($bookmark)
+    {
+        return implode(' ', $this->formatTagListUrlEncoded($bookmark));
+    }
+
     /**
      * Format Class
      * Used to add specific CSS class for a link
@@ -209,7 +300,7 @@ abstract class BookmarkFormatter
      *
      * @param Bookmark $bookmark instance
      *
-     * @return DateTime instance
+     * @return DateTimeInterface instance
      */
     protected function formatCreated(Bookmark $bookmark)
     {
@@ -221,7 +312,7 @@ abstract class BookmarkFormatter
      *
      * @param Bookmark $bookmark instance
      *
-     * @return DateTime instance
+     * @return DateTimeInterface instance
      */
     protected function formatUpdated(Bookmark $bookmark)
     {
diff --git a/application/formatter/BookmarkMarkdownExtraFormatter.php b/application/formatter/BookmarkMarkdownExtraFormatter.php
new file mode 100644 (file)
index 0000000..0694b23
--- /dev/null
@@ -0,0 +1,24 @@
+<?php
+
+namespace Shaarli\Formatter;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkMarkdownExtraFormatter
+ *
+ * Format bookmark description into MarkdownExtra format.
+ *
+ * @see https://michelf.ca/projects/php-markdown/extra/
+ *
+ * @package Shaarli\Formatter
+ */
+class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter
+{
+    public function __construct(ConfigManager $conf, bool $isLoggedIn)
+    {
+        parent::__construct($conf, $isLoggedIn);
+
+        $this->parsedown = new \ParsedownExtra();
+    }
+}
index 077e5312b75b620fb2e2ace73cf353edb7cea99a..f7714be9ed34df27a70f971aa28aeef8e9c33b48 100644 (file)
@@ -56,7 +56,10 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
             return parent::formatDescription($bookmark);
         }
 
-        $processedDescription = $bookmark->getDescription();
+        $processedDescription = $this->tokenizeSearchHighlightField(
+            $bookmark->getDescription() ?? '',
+            $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? []
+        );
         $processedDescription = $this->filterProtocols($processedDescription);
         $processedDescription = $this->formatHashTags($processedDescription);
         $processedDescription = $this->reverseEscapedHtml($processedDescription);
@@ -65,6 +68,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
             ->setBreaksEnabled(true)
             ->text($processedDescription);
         $processedDescription = $this->sanitizeHtml($processedDescription);
+        $processedDescription = $this->replaceTokens($processedDescription);
 
         if (!empty($processedDescription)) {
             $processedDescription = '<div class="markdown">'. $processedDescription . '</div>';
@@ -114,7 +118,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
 
     /**
      * Replace hashtag in Markdown links format
-     * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)`
+     * E.g. `#hashtag` becomes `[#hashtag](./add-tag/hashtag)`
      * It includes the index URL if specified.
      *
      * @param string $description
@@ -133,7 +137,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
          * \p{Mn} - any non marking space (accents, umlauts, etc)
          */
         $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
-        $replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)';
+        $replacement = '$1[#$2]('. $indexUrl .'./add-tag/$2)';
 
         $descriptionLines = explode(PHP_EOL, $description);
         $descriptionOut = '';
index 5f282f686b95ace2439c6db273be4ce78637f876..a029579f6908f5452056d0db8db16a87c57ee5d4 100644 (file)
@@ -38,7 +38,7 @@ class FormatterFactory
      *
      * @return BookmarkFormatter instance.
      */
-    public function getFormatter(string $type = null)
+    public function getFormatter(string $type = null): BookmarkFormatter
     {
         $type = $type ? $type : $this->conf->get('formatter', 'default');
         $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter';
diff --git a/application/front/ShaarliAdminMiddleware.php b/application/front/ShaarliAdminMiddleware.php
new file mode 100644 (file)
index 0000000..35ce4a3
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace Shaarli\Front;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Middleware used for controller requiring to be authenticated.
+ * It extends ShaarliMiddleware, and just make sure that the user is authenticated.
+ * Otherwise, it redirects to the login page.
+ */
+class ShaarliAdminMiddleware extends ShaarliMiddleware
+{
+    public function __invoke(Request $request, Response $response, callable $next): Response
+    {
+        $this->initBasePath($request);
+
+        if (true !== $this->container->loginManager->isLoggedIn()) {
+            $returnUrl = urlencode($this->container->environment['REQUEST_URI']);
+
+            return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
+        }
+
+        return parent::__invoke($request, $response, $next);
+    }
+}
index fa6c64671d56b9db1ee43cd4c13f71ef15f30958..d1aa139989e2689ee61df29f47c2cd08bd2d9999 100644 (file)
@@ -3,7 +3,7 @@
 namespace Shaarli\Front;
 
 use Shaarli\Container\ShaarliContainer;
-use Shaarli\Front\Exception\ShaarliException;
+use Shaarli\Front\Exception\UnauthorizedException;
 use Slim\Http\Request;
 use Slim\Http\Response;
 
@@ -24,6 +24,8 @@ class ShaarliMiddleware
 
     /**
      * Middleware execution:
+     *   - run updates
+     *   - if not logged in open shaarli, redirect to login
      *   - execute the controller
      *   - return the response
      *
@@ -35,23 +37,78 @@ class ShaarliMiddleware
      *
      * @return Response response.
      */
-    public function __invoke(Request $request, Response $response, callable $next)
+    public function __invoke(Request $request, Response $response, callable $next): Response
     {
+        $this->initBasePath($request);
+
         try {
-            $response = $next($request, $response);
-        } catch (ShaarliException $e) {
-            $this->container->pageBuilder->assign('message', $e->getMessage());
-            if ($this->container->conf->get('dev.debug', false)) {
-                $this->container->pageBuilder->assign(
-                    'stacktrace',
-                    nl2br(get_class($this) .': '. $e->getTraceAsString())
-                );
+            if (!is_file($this->container->conf->getConfigFileExt())
+                && !in_array($next->getName(), ['displayInstall', 'saveInstall'], true)
+            ) {
+                return $response->withRedirect($this->container->basePath . '/install');
             }
 
-            $response = $response->withStatus($e->getCode());
-            $response = $response->write($this->container->pageBuilder->render('error'));
+            $this->runUpdates();
+            $this->checkOpenShaarli($request, $response, $next);
+
+            return $next($request, $response);
+        } catch (UnauthorizedException $e) {
+            $returnUrl = urlencode($this->container->environment['REQUEST_URI']);
+
+            return $response->withRedirect($this->container->basePath . '/login?returnurl=' . $returnUrl);
+        }
+        // Other exceptions are handled by ErrorController
+    }
+
+    /**
+     * Run the updater for every requests processed while logged in.
+     */
+    protected function runUpdates(): void
+    {
+        if ($this->container->loginManager->isLoggedIn() !== true) {
+            return;
+        }
+
+        $this->container->updater->setBasePath($this->container->basePath);
+        $newUpdates = $this->container->updater->update();
+        if (!empty($newUpdates)) {
+            $this->container->updater->writeUpdates(
+                $this->container->conf->get('resource.updates'),
+                $this->container->updater->getDoneUpdates()
+            );
+
+            $this->container->pageCacheManager->invalidateCaches();
+        }
+    }
+
+    /**
+     * Access is denied to most pages with `hide_public_links` + `force_login` settings.
+     */
+    protected function checkOpenShaarli(Request $request, Response $response, callable $next): bool
+    {
+        if (// if the user isn't logged in
+            !$this->container->loginManager->isLoggedIn()
+            // and Shaarli doesn't have public content...
+            && $this->container->conf->get('privacy.hide_public_links')
+            // and is configured to enforce the login
+            && $this->container->conf->get('privacy.force_login')
+            // and the current page isn't already the login page
+            // and the user is not requesting a feed (which would lead to a different content-type as expected)
+            && !in_array($next->getName(), ['login', 'processLogin', 'atom', 'rss'], true)
+        ) {
+            throw new UnauthorizedException();
         }
 
-        return $response;
+        return true;
+    }
+
+    /**
+     * Initialize the URL base path if it hasn't been defined yet.
+     */
+    protected function initBasePath(Request $request): void
+    {
+        if (null === $this->container->basePath) {
+            $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
+        }
     }
 }
diff --git a/application/front/controller/admin/ConfigureController.php b/application/front/controller/admin/ConfigureController.php
new file mode 100644 (file)
index 0000000..0ed7ad8
--- /dev/null
@@ -0,0 +1,126 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Languages;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Render\ThemeUtils;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+use Throwable;
+
+/**
+ * Class ConfigureController
+ *
+ * Slim controller used to handle Shaarli configuration page (display + save new config).
+ */
+class ConfigureController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/configure - Displays the configuration page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
+        $this->assignView('theme', $this->container->conf->get('resource.theme'));
+        $this->assignView(
+            'theme_available',
+            ThemeUtils::getThemes($this->container->conf->get('resource.raintpl_tpl'))
+        );
+        $this->assignView('formatter_available', ['default', 'markdown', 'markdownExtra']);
+        list($continents, $cities) = generateTimeZoneData(
+            timezone_identifiers_list(),
+            $this->container->conf->get('general.timezone')
+        );
+        $this->assignView('continents', $continents);
+        $this->assignView('cities', $cities);
+        $this->assignView('retrieve_description', $this->container->conf->get('general.retrieve_description', false));
+        $this->assignView('private_links_default', $this->container->conf->get('privacy.default_private_links', false));
+        $this->assignView(
+            'session_protection_disabled',
+            $this->container->conf->get('security.session_protection_disabled', false)
+        );
+        $this->assignView('enable_rss_permalinks', $this->container->conf->get('feed.rss_permalinks', false));
+        $this->assignView('enable_update_check', $this->container->conf->get('updates.check_updates', true));
+        $this->assignView('hide_public_links', $this->container->conf->get('privacy.hide_public_links', false));
+        $this->assignView('api_enabled', $this->container->conf->get('api.enabled', true));
+        $this->assignView('api_secret', $this->container->conf->get('api.secret'));
+        $this->assignView('languages', Languages::getAvailableLanguages());
+        $this->assignView('gd_enabled', extension_loaded('gd'));
+        $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
+        $this->assignView('pagetitle', t('Configure') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+
+        return $response->write($this->render(TemplatePage::CONFIGURE));
+    }
+
+    /**
+     * POST /admin/configure - Update Shaarli's configuration
+     */
+    public function save(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $continent = $request->getParam('continent');
+        $city = $request->getParam('city');
+        $tz = 'UTC';
+        if (null !== $continent && null !== $city && isTimeZoneValid($continent, $city)) {
+            $tz = $continent . '/' . $city;
+        }
+
+        $this->container->conf->set('general.timezone', $tz);
+        $this->container->conf->set('general.title', escape($request->getParam('title')));
+        $this->container->conf->set('general.header_link', escape($request->getParam('titleLink')));
+        $this->container->conf->set('general.retrieve_description', !empty($request->getParam('retrieveDescription')));
+        $this->container->conf->set('resource.theme', escape($request->getParam('theme')));
+        $this->container->conf->set(
+            'security.session_protection_disabled',
+            !empty($request->getParam('disablesessionprotection'))
+        );
+        $this->container->conf->set(
+            'privacy.default_private_links',
+            !empty($request->getParam('privateLinkByDefault'))
+        );
+        $this->container->conf->set('feed.rss_permalinks', !empty($request->getParam('enableRssPermalinks')));
+        $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
+        $this->container->conf->set('privacy.hide_public_links', !empty($request->getParam('hidePublicLinks')));
+        $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
+        $this->container->conf->set('api.secret', escape($request->getParam('apiSecret')));
+        $this->container->conf->set('formatter', escape($request->getParam('formatter')));
+
+        if (!empty($request->getParam('language'))) {
+            $this->container->conf->set('translation.language', escape($request->getParam('language')));
+        }
+
+        $thumbnailsMode = extension_loaded('gd') ? $request->getParam('enableThumbnails') : Thumbnailer::MODE_NONE;
+        if ($thumbnailsMode !== Thumbnailer::MODE_NONE
+            && $thumbnailsMode !== $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
+        ) {
+            $this->saveWarningMessage(
+                t('You have enabled or changed thumbnails mode.') .
+                '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>'
+            );
+        }
+        $this->container->conf->set('thumbnails.mode', $thumbnailsMode);
+
+        try {
+            $this->container->conf->write($this->container->loginManager->isLoggedIn());
+            $this->container->history->updateSettings();
+            $this->container->pageCacheManager->invalidateCaches();
+        } catch (Throwable $e) {
+            $this->assignView('message', t('Error while writing config file after configuration update.'));
+
+            if ($this->container->conf->get('dev.debug', false)) {
+                $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
+            }
+
+            return $response->write($this->render('error'));
+        }
+
+        $this->saveSuccessMessage(t('Configuration was saved.'));
+
+        return $this->redirect($response, '/admin/configure');
+    }
+}
diff --git a/application/front/controller/admin/ExportController.php b/application/front/controller/admin/ExportController.php
new file mode 100644 (file)
index 0000000..2be957f
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use DateTime;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ExportController
+ *
+ * Slim controller used to display Shaarli data export page,
+ * and process the bookmarks export as a Netscape Bookmarks file.
+ */
+class ExportController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/export - Display export page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $this->assignView('pagetitle', t('Export') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+
+        return $response->write($this->render(TemplatePage::EXPORT));
+    }
+
+    /**
+     * POST /admin/export - Process export, and serve download file named
+     *                      bookmarks_(all|private|public)_datetime.html
+     */
+    public function export(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $selection = $request->getParam('selection');
+
+        if (empty($selection)) {
+            $this->saveErrorMessage(t('Please select an export mode.'));
+
+            return $this->redirect($response, '/admin/export');
+        }
+
+        $prependNoteUrl = filter_var($request->getParam('prepend_note_url') ?? false, FILTER_VALIDATE_BOOLEAN);
+
+        try {
+            $formatter = $this->container->formatterFactory->getFormatter('raw');
+
+            $this->assignView(
+                'links',
+                $this->container->netscapeBookmarkUtils->filterAndFormat(
+                    $formatter,
+                    $selection,
+                    $prependNoteUrl,
+                    index_url($this->container->environment)
+                )
+            );
+        } catch (\Exception $exc) {
+            $this->saveErrorMessage($exc->getMessage());
+
+            return $this->redirect($response, '/admin/export');
+        }
+
+        $now = new DateTime();
+        $response = $response->withHeader('Content-Type', 'text/html; charset=utf-8');
+        $response = $response->withHeader(
+            'Content-disposition',
+            'attachment; filename=bookmarks_'.$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html'
+        );
+
+        $this->assignView('date', $now->format(DateTime::RFC822));
+        $this->assignView('eol', PHP_EOL);
+        $this->assignView('selection', $selection);
+
+        return $response->write($this->render(TemplatePage::NETSCAPE_EXPORT_BOOKMARKS));
+    }
+}
diff --git a/application/front/controller/admin/ImportController.php b/application/front/controller/admin/ImportController.php
new file mode 100644 (file)
index 0000000..758d5ef
--- /dev/null
@@ -0,0 +1,82 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Psr\Http\Message\UploadedFileInterface;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ImportController
+ *
+ * Slim controller used to display Shaarli data import page,
+ * and import bookmarks from Netscape Bookmarks file.
+ */
+class ImportController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/import - Display import page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $this->assignView(
+            'maxfilesize',
+            get_max_upload_size(
+                ini_get('post_max_size'),
+                ini_get('upload_max_filesize'),
+                false
+            )
+        );
+        $this->assignView(
+            'maxfilesizeHuman',
+            get_max_upload_size(
+                ini_get('post_max_size'),
+                ini_get('upload_max_filesize'),
+                true
+            )
+        );
+        $this->assignView('pagetitle', t('Import') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+
+        return $response->write($this->render(TemplatePage::IMPORT));
+    }
+
+    /**
+     * POST /admin/import - Process import file provided and create bookmarks
+     */
+    public function import(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $file = ($request->getUploadedFiles() ?? [])['filetoupload'] ?? null;
+        if (!$file instanceof UploadedFileInterface) {
+            $this->saveErrorMessage(t('No import file provided.'));
+
+            return $this->redirect($response, '/admin/import');
+        }
+
+
+        // Import bookmarks from an uploaded file
+        if (0 === $file->getSize()) {
+            // The file is too big or some form field may be missing.
+            $msg = sprintf(
+                t(
+                    'The file you are trying to upload is probably bigger than what this webserver can accept'
+                    .' (%s). Please upload in smaller chunks.'
+                ),
+                get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
+            );
+            $this->saveErrorMessage($msg);
+
+            return $this->redirect($response, '/admin/import');
+        }
+
+        $status = $this->container->netscapeBookmarkUtils->import($request->getParams(), $file);
+
+        $this->saveSuccessMessage($status);
+
+        return $this->redirect($response, '/admin/import');
+    }
+}
diff --git a/application/front/controller/admin/LogoutController.php b/application/front/controller/admin/LogoutController.php
new file mode 100644 (file)
index 0000000..2816512
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Security\CookieManager;
+use Shaarli\Security\LoginManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class LogoutController
+ *
+ * Slim controller used to logout the user.
+ * It invalidates page cache and terminate the user session. Then it redirects to the homepage.
+ */
+class LogoutController extends ShaarliAdminController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        $this->container->pageCacheManager->invalidateCaches();
+        $this->container->sessionManager->logout();
+        $this->container->cookieManager->setCookieParameter(
+            CookieManager::STAY_SIGNED_IN,
+            'false',
+            0,
+            $this->container->basePath . '/'
+        );
+
+        return $this->redirect($response, '/');
+    }
+}
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
new file mode 100644 (file)
index 0000000..2065c3e
--- /dev/null
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ManageTagController
+ *
+ * Slim controller used to handle Shaarli manage tags page (rename and delete tags).
+ */
+class ManageTagController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/tags - Displays the manage tags page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $fromTag = $request->getParam('fromtag') ?? '';
+
+        $this->assignView('fromtag', escape($fromTag));
+        $this->assignView(
+            'pagetitle',
+            t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render(TemplatePage::CHANGE_TAG));
+    }
+
+    /**
+     * POST /admin/tags - Update or delete provided tag
+     */
+    public function save(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $isDelete = null !== $request->getParam('deletetag') && null === $request->getParam('renametag');
+
+        $fromTag = trim($request->getParam('fromtag') ?? '');
+        $toTag = trim($request->getParam('totag') ?? '');
+
+        if (0 === strlen($fromTag) || false === $isDelete && 0 === strlen($toTag)) {
+            $this->saveWarningMessage(t('Invalid tags provided.'));
+
+            return $this->redirect($response, '/admin/tags');
+        }
+
+        // TODO: move this to bookmark service
+        $count = 0;
+        $bookmarks = $this->container->bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true);
+        foreach ($bookmarks as $bookmark) {
+            if (false === $isDelete) {
+                $bookmark->renameTag($fromTag, $toTag);
+            } else {
+                $bookmark->deleteTag($fromTag);
+            }
+
+            $this->container->bookmarkService->set($bookmark, false);
+            $this->container->history->updateLink($bookmark);
+            $count++;
+        }
+
+        $this->container->bookmarkService->save();
+
+        if (true === $isDelete) {
+            $alert = sprintf(
+                t('The tag was removed from %d bookmark.', 'The tag was removed from %d bookmarks.', $count),
+                $count
+            );
+        } else {
+            $alert = sprintf(
+                t('The tag was renamed in %d bookmark.', 'The tag was renamed in %d bookmarks.', $count),
+                $count
+            );
+        }
+
+        $this->saveSuccessMessage($alert);
+
+        $redirect = true === $isDelete ? '/admin/tags' : '/?searchtags='. urlencode($toTag);
+
+        return $this->redirect($response, $redirect);
+    }
+}
diff --git a/application/front/controller/admin/MetadataController.php b/application/front/controller/admin/MetadataController.php
new file mode 100644 (file)
index 0000000..ff84594
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Controller used to retrieve/update bookmark's metadata.
+ */
+class MetadataController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/metadata/{url} - Attempt to retrieve the bookmark title from provided URL.
+     */
+    public function ajaxRetrieveTitle(Request $request, Response $response): Response
+    {
+        $url = $request->getParam('url');
+
+        // Only try to extract metadata from URL with HTTP(s) scheme
+        if (!empty($url) && strpos(get_url_scheme($url) ?: '', 'http') !== false) {
+            return $response->withJson($this->container->metadataRetriever->retrieve($url));
+        }
+
+        return $response->withJson([]);
+    }
+}
diff --git a/application/front/controller/admin/PasswordController.php b/application/front/controller/admin/PasswordController.php
new file mode 100644 (file)
index 0000000..5ec0d24
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Container\ShaarliContainer;
+use Shaarli\Front\Exception\OpenShaarliPasswordException;
+use Shaarli\Front\Exception\ShaarliFrontException;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+use Throwable;
+
+/**
+ * Class PasswordController
+ *
+ * Slim controller used to handle passwords update.
+ */
+class PasswordController extends ShaarliAdminController
+{
+    public function __construct(ShaarliContainer $container)
+    {
+        parent::__construct($container);
+
+        $this->assignView(
+            'pagetitle',
+            t('Change password') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+    }
+
+    /**
+     * GET /admin/password - Displays the change password template
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
+    }
+
+    /**
+     * POST /admin/password - Change admin password - existing and new passwords need to be provided.
+     */
+    public function change(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        if ($this->container->conf->get('security.open_shaarli', false)) {
+            throw new OpenShaarliPasswordException();
+        }
+
+        $oldPassword = $request->getParam('oldpassword');
+        $newPassword = $request->getParam('setpassword');
+
+        if (empty($newPassword) || empty($oldPassword)) {
+            $this->saveErrorMessage(t('You must provide the current and new password to change it.'));
+
+            return $response
+                ->withStatus(400)
+                ->write($this->render(TemplatePage::CHANGE_PASSWORD))
+            ;
+        }
+
+        // Make sure old password is correct.
+        $oldHash = sha1(
+            $oldPassword .
+            $this->container->conf->get('credentials.login') .
+            $this->container->conf->get('credentials.salt')
+        );
+
+        if ($oldHash !== $this->container->conf->get('credentials.hash')) {
+            $this->saveErrorMessage(t('The old password is not correct.'));
+
+            return $response
+                ->withStatus(400)
+                ->write($this->render(TemplatePage::CHANGE_PASSWORD))
+            ;
+        }
+
+        // Save new password
+        // Salt renders rainbow-tables attacks useless.
+        $this->container->conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
+        $this->container->conf->set(
+            'credentials.hash',
+            sha1(
+                $newPassword
+                . $this->container->conf->get('credentials.login')
+                . $this->container->conf->get('credentials.salt')
+            )
+        );
+
+        try {
+            $this->container->conf->write($this->container->loginManager->isLoggedIn());
+        } catch (Throwable $e) {
+            throw new ShaarliFrontException($e->getMessage(), 500, $e);
+        }
+
+        $this->saveSuccessMessage(t('Your password has been changed'));
+
+        return $response->write($this->render(TemplatePage::CHANGE_PASSWORD));
+    }
+}
diff --git a/application/front/controller/admin/PluginsController.php b/application/front/controller/admin/PluginsController.php
new file mode 100644 (file)
index 0000000..8e05968
--- /dev/null
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Exception;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class PluginsController
+ *
+ * Slim controller used to handle Shaarli plugins configuration page (display + save new config).
+ */
+class PluginsController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/plugins - Displays the configuration page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $pluginMeta = $this->container->pluginManager->getPluginsMeta();
+
+        // Split plugins into 2 arrays: ordered enabled plugins and disabled.
+        $enabledPlugins = array_filter($pluginMeta, function ($v) {
+            return ($v['order'] ?? false) !== false;
+        });
+        $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $this->container->conf->get('plugins', []));
+        uasort(
+            $enabledPlugins,
+            function ($a, $b) {
+                return $a['order'] - $b['order'];
+            }
+        );
+        $disabledPlugins = array_filter($pluginMeta, function ($v) {
+            return ($v['order'] ?? false) === false;
+        });
+
+        $this->assignView('enabledPlugins', $enabledPlugins);
+        $this->assignView('disabledPlugins', $disabledPlugins);
+        $this->assignView(
+            'pagetitle',
+            t('Plugin Administration') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render(TemplatePage::PLUGINS_ADMIN));
+    }
+
+    /**
+     * POST /admin/plugins - Update Shaarli's configuration
+     */
+    public function save(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        try {
+            $parameters = $request->getParams() ?? [];
+
+            $this->executePageHooks('save_plugin_parameters', $parameters);
+
+            if (isset($parameters['parameters_form'])) {
+                unset($parameters['parameters_form']);
+                unset($parameters['token']);
+                foreach ($parameters as $param => $value) {
+                    $this->container->conf->set('plugins.'. $param, escape($value));
+                }
+            } else {
+                $this->container->conf->set('general.enabled_plugins', save_plugin_config($parameters));
+            }
+
+            $this->container->conf->write($this->container->loginManager->isLoggedIn());
+            $this->container->history->updateSettings();
+
+            $this->saveSuccessMessage(t('Setting successfully saved.'));
+        } catch (Exception $e) {
+            $this->saveErrorMessage(
+                t('Error while saving plugin configuration: ') . PHP_EOL . $e->getMessage()
+            );
+        }
+
+        return $this->redirect($response, '/admin/plugins');
+    }
+}
diff --git a/application/front/controller/admin/ServerController.php b/application/front/controller/admin/ServerController.php
new file mode 100644 (file)
index 0000000..bfc9942
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Helper\ApplicationUtils;
+use Shaarli\Helper\FileUtils;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Slim controller used to handle Server administration page, and actions.
+ */
+class ServerController extends ShaarliAdminController
+{
+    /** @var string Cache type - main - by default pagecache/ and tmp/ */
+    protected const CACHE_MAIN = 'main';
+
+    /** @var string Cache type - thumbnails - by default cache/ */
+    protected const CACHE_THUMB = 'thumbnails';
+
+    /**
+     * GET /admin/server - Display page Server administration
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $latestVersion = 'v' . ApplicationUtils::getVersion(
+            ApplicationUtils::$GIT_RAW_URL . '/latest/' . ApplicationUtils::$VERSION_FILE
+        );
+        $currentVersion = ApplicationUtils::getVersion('./shaarli_version.php');
+        $currentVersion = $currentVersion === 'dev' ? $currentVersion : 'v' . $currentVersion;
+        $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
+
+        $this->assignView('php_version', PHP_VERSION);
+        $this->assignView('php_eol', format_date($phpEol, false));
+        $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
+        $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
+        $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
+        $this->assignView('release_url', ApplicationUtils::$GITHUB_URL . '/releases/tag/' . $latestVersion);
+        $this->assignView('latest_version', $latestVersion);
+        $this->assignView('current_version', $currentVersion);
+        $this->assignView('thumbnails_mode', $this->container->conf->get('thumbnails.mode'));
+        $this->assignView('index_url', index_url($this->container->environment));
+        $this->assignView('client_ip', client_ip_id($this->container->environment));
+        $this->assignView('trusted_proxies', $this->container->conf->get('security.trusted_proxies', []));
+
+        $this->assignView(
+            'pagetitle',
+            t('Server administration') . ' - ' . $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render('server'));
+    }
+
+    /**
+     * GET /admin/clear-cache?type={$type} - Action to trigger cache folder clearing (either main or thumbnails).
+     */
+    public function clearCache(Request $request, Response $response): Response
+    {
+        $exclude = ['.htaccess'];
+
+        if ($request->getQueryParam('type') === static::CACHE_THUMB) {
+            $folders = [$this->container->conf->get('resource.thumbnails_cache')];
+
+            $this->saveWarningMessage(
+                t('Thumbnails cache has been cleared.') . ' ' .
+                '<a href="'. $this->container->basePath .'/admin/thumbnails">' . t('Please synchronize them.') .'</a>'
+            );
+        } else {
+            $folders = [
+                $this->container->conf->get('resource.page_cache'),
+                $this->container->conf->get('resource.raintpl_tmp'),
+            ];
+
+            $this->saveSuccessMessage(t('Shaarli\'s cache folder has been cleared!'));
+        }
+
+        // Make sure that we don't delete root cache folder
+        $folders = array_map('realpath', array_values(array_filter(array_map('trim', $folders))));
+        foreach ($folders as $folder) {
+            FileUtils::clearFolder($folder, false, $exclude);
+        }
+
+        return $this->redirect($response, '/admin/server');
+    }
+}
diff --git a/application/front/controller/admin/SessionFilterController.php b/application/front/controller/admin/SessionFilterController.php
new file mode 100644 (file)
index 0000000..d9a7a2e
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class SessionFilterController
+ *
+ * Slim controller used to handle filters stored in the user session, such as visibility, etc.
+ */
+class SessionFilterController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/visibility: allows to display only public or only private bookmarks in linklist
+     */
+    public function visibility(Request $request, Response $response, array $args): Response
+    {
+        if (false === $this->container->loginManager->isLoggedIn()) {
+            return $this->redirectFromReferer($request, $response, ['visibility']);
+        }
+
+        $newVisibility = $args['visibility'] ?? null;
+        if (false === in_array($newVisibility, [BookmarkFilter::$PRIVATE, BookmarkFilter::$PUBLIC], true)) {
+            $newVisibility = null;
+        }
+
+        $currentVisibility = $this->container->sessionManager->getSessionParameter(SessionManager::KEY_VISIBILITY);
+
+        // Visibility not set or not already expected value, set expected value, otherwise reset it
+        if ($newVisibility !== null && (null === $currentVisibility || $currentVisibility !== $newVisibility)) {
+            // See only public bookmarks
+            $this->container->sessionManager->setSessionParameter(
+                SessionManager::KEY_VISIBILITY,
+                $newVisibility
+            );
+        } else {
+            $this->container->sessionManager->deleteSessionParameter(SessionManager::KEY_VISIBILITY);
+        }
+
+        return $this->redirectFromReferer($request, $response, ['visibility']);
+    }
+
+
+}
diff --git a/application/front/controller/admin/ShaareAddController.php b/application/front/controller/admin/ShaareAddController.php
new file mode 100644 (file)
index 0000000..8dc386b
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Formatter\BookmarkMarkdownFormatter;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ShaareAddController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
+     */
+    public function addShaare(Request $request, Response $response): Response
+    {
+        $tags = $this->container->bookmarkService->bookmarksCountPerTag();
+        if ($this->container->conf->get('formatter') === 'markdown') {
+            $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
+        }
+
+        $this->assignView(
+            'pagetitle',
+            t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+        $this->assignView('tags', $tags);
+        $this->assignView('default_private_links', $this->container->conf->get('privacy.default_private_links', false));
+        $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
+
+        return $response->write($this->render(TemplatePage::ADDLINK));
+    }
+}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
new file mode 100644 (file)
index 0000000..7ceb8d8
--- /dev/null
@@ -0,0 +1,202 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class PostBookmarkController
+ *
+ * Slim controller used to handle Shaarli create or edit bookmarks.
+ */
+class ShaareManageController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
+     */
+    public function deleteBookmark(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $ids = escape(trim($request->getParam('id') ?? ''));
+        if (empty($ids) || strpos($ids, ' ') !== false) {
+            // multiple, space-separated ids provided
+            $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
+        } else {
+            $ids = [$ids];
+        }
+
+        // assert at least one id is given
+        if (0 === count($ids)) {
+            $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
+
+            return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
+        }
+
+        $formatter = $this->container->formatterFactory->getFormatter('raw');
+        $count = 0;
+        foreach ($ids as $id) {
+            try {
+                $bookmark = $this->container->bookmarkService->get((int) $id);
+            } catch (BookmarkNotFoundException $e) {
+                $this->saveErrorMessage(sprintf(
+                    t('Bookmark with identifier %s could not be found.'),
+                    $id
+                ));
+
+                continue;
+            }
+
+            $data = $formatter->format($bookmark);
+            $this->executePageHooks('delete_link', $data);
+            $this->container->bookmarkService->remove($bookmark, false);
+            ++ $count;
+        }
+
+        if ($count > 0) {
+            $this->container->bookmarkService->save();
+        }
+
+        // If we are called from the bookmarklet, we must close the popup:
+        if ($request->getParam('source') === 'bookmarklet') {
+            return $response->write('<script>self.close();</script>');
+        }
+
+        // Don't redirect to where we were previously because the datastore has changed.
+        return $this->redirect($response, '/');
+    }
+
+    /**
+     * GET /admin/shaare/visibility
+     *
+     * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
+     */
+    public function changeVisibility(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $ids = trim(escape($request->getParam('id') ?? ''));
+        if (empty($ids) || strpos($ids, ' ') !== false) {
+            // multiple, space-separated ids provided
+            $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
+        } else {
+            // only a single id provided
+            $ids = [$ids];
+        }
+
+        // assert at least one id is given
+        if (0 === count($ids)) {
+            $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
+
+            return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
+        }
+
+        // assert that the visibility is valid
+        $visibility = $request->getParam('newVisibility');
+        if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
+            $this->saveErrorMessage(t('Invalid visibility provided.'));
+
+            return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
+        } else {
+            $isPrivate = $visibility === 'private';
+        }
+
+        $formatter = $this->container->formatterFactory->getFormatter('raw');
+        $count = 0;
+
+        foreach ($ids as $id) {
+            try {
+                $bookmark = $this->container->bookmarkService->get((int) $id);
+            } catch (BookmarkNotFoundException $e) {
+                $this->saveErrorMessage(sprintf(
+                    t('Bookmark with identifier %s could not be found.'),
+                    $id
+                ));
+
+                continue;
+            }
+
+            $bookmark->setPrivate($isPrivate);
+
+            // To preserve backward compatibility with 3rd parties, plugins still use arrays
+            $data = $formatter->format($bookmark);
+            $this->executePageHooks('save_link', $data);
+            $bookmark->fromArray($data);
+
+            $this->container->bookmarkService->set($bookmark, false);
+            ++$count;
+        }
+
+        if ($count > 0) {
+            $this->container->bookmarkService->save();
+        }
+
+        return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
+    }
+
+    /**
+     * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
+     */
+    public function pinBookmark(Request $request, Response $response, array $args): Response
+    {
+        $this->checkToken($request);
+
+        $id = $args['id'] ?? '';
+        try {
+            if (false === ctype_digit($id)) {
+                throw new BookmarkNotFoundException();
+            }
+            $bookmark = $this->container->bookmarkService->get((int) $id);  // Read database
+        } catch (BookmarkNotFoundException $e) {
+            $this->saveErrorMessage(sprintf(
+                t('Bookmark with identifier %s could not be found.'),
+                $id
+            ));
+
+            return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
+        }
+
+        $formatter = $this->container->formatterFactory->getFormatter('raw');
+
+        $bookmark->setSticky(!$bookmark->isSticky());
+
+        // To preserve backward compatibility with 3rd parties, plugins still use arrays
+        $data = $formatter->format($bookmark);
+        $this->executePageHooks('save_link', $data);
+        $bookmark->fromArray($data);
+
+        $this->container->bookmarkService->set($bookmark);
+
+        return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
+    }
+
+    /**
+     * GET /admin/shaare/private/{hash} - Attach a private key to given bookmark, then redirect to the sharing URL.
+     */
+    public function sharePrivate(Request $request, Response $response, array $args): Response
+    {
+        $this->checkToken($request);
+
+        $hash = $args['hash'] ?? '';
+        $bookmark = $this->container->bookmarkService->findByHash($hash);
+
+        if ($bookmark->isPrivate() !== true) {
+            return $this->redirect($response, '/shaare/' . $hash);
+        }
+
+        if (empty($bookmark->getAdditionalContentEntry('private_key'))) {
+            $privateKey = bin2hex(random_bytes(16));
+            $bookmark->addAdditionalContentEntry('private_key', $privateKey);
+            $this->container->bookmarkService->set($bookmark);
+        }
+
+        return $this->redirect(
+            $response,
+            '/shaare/' . $hash . '?key=' . $bookmark->getAdditionalContentEntry('private_key')
+        );
+    }
+}
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
new file mode 100644 (file)
index 0000000..18afc2d
--- /dev/null
@@ -0,0 +1,263 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkMarkdownFormatter;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ShaarePublishController extends ShaarliAdminController
+{
+    /**
+     * @var BookmarkFormatter[] Statically cached instances of formatters
+     */
+    protected $formatters = [];
+
+    /**
+     * @var array Statically cached bookmark's tags counts
+     */
+    protected $tags;
+
+    /**
+     * GET /admin/shaare - Displays the bookmark form for creation.
+     *                     Note that if the URL is found in existing bookmarks, then it will be in edit mode.
+     */
+    public function displayCreateForm(Request $request, Response $response): Response
+    {
+        $url = cleanup_url($request->getParam('post'));
+        $link = $this->buildLinkDataFromUrl($request, $url);
+
+        return $this->displayForm($link, $link['linkIsNew'], $request, $response);
+    }
+
+    /**
+     * POST /admin/shaare-batch - Displays multiple creation/edit forms from bulk add in add-link page.
+     */
+    public function displayCreateBatchForms(Request $request, Response $response): Response
+    {
+        $urls = array_map('cleanup_url', explode(PHP_EOL, $request->getParam('urls')));
+
+        $links = [];
+        foreach ($urls as $url) {
+            if (empty($url)) {
+                continue;
+            }
+            $link = $this->buildLinkDataFromUrl($request, $url);
+            $data = $this->buildFormData($link, $link['linkIsNew'], $request);
+            $data['token'] = $this->container->sessionManager->generateToken();
+            $data['source'] = 'batch';
+
+            $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
+
+            $links[] = $data;
+        }
+
+        $this->assignView('links', $links);
+        $this->assignView('batch_mode', true);
+        $this->assignView('async_metadata', $this->container->conf->get('general.enable_async_metadata', true));
+
+        return $response->write($this->render(TemplatePage::EDIT_LINK_BATCH));
+    }
+
+    /**
+     * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
+     */
+    public function displayEditForm(Request $request, Response $response, array $args): Response
+    {
+        $id = $args['id'] ?? '';
+        try {
+            if (false === ctype_digit($id)) {
+                throw new BookmarkNotFoundException();
+            }
+            $bookmark = $this->container->bookmarkService->get((int) $id);  // Read database
+        } catch (BookmarkNotFoundException $e) {
+            $this->saveErrorMessage(sprintf(
+                t('Bookmark with identifier %s could not be found.'),
+                $id
+            ));
+
+            return $this->redirect($response, '/');
+        }
+
+        $formatter = $this->getFormatter('raw');
+        $link = $formatter->format($bookmark);
+
+        return $this->displayForm($link, false, $request, $response);
+    }
+
+    /**
+     * POST /admin/shaare
+     */
+    public function save(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        // lf_id should only be present if the link exists.
+        $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
+        if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
+            // Edit
+            $bookmark = $this->container->bookmarkService->get($id);
+        } else {
+            // New link
+            $bookmark = new Bookmark();
+        }
+
+        $bookmark->setTitle($request->getParam('lf_title'));
+        $bookmark->setDescription($request->getParam('lf_description'));
+        $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
+        $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
+        $bookmark->setTagsString($request->getParam('lf_tags'));
+
+        if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
+            && true !== $this->container->conf->get('general.enable_async_metadata', true)
+            && $bookmark->shouldUpdateThumbnail()
+        ) {
+            $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
+        }
+        $this->container->bookmarkService->addOrSet($bookmark, false);
+
+        // To preserve backward compatibility with 3rd parties, plugins still use arrays
+        $formatter = $this->getFormatter('raw');
+        $data = $formatter->format($bookmark);
+        $this->executePageHooks('save_link', $data);
+
+        $bookmark->fromArray($data);
+        $this->container->bookmarkService->set($bookmark);
+
+        // If we are called from the bookmarklet, we must close the popup:
+        if ($request->getParam('source') === 'bookmarklet') {
+            return $response->write('<script>self.close();</script>');
+        } elseif ($request->getParam('source') === 'batch') {
+            return $response;
+        }
+
+        if (!empty($request->getParam('returnurl'))) {
+            $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
+        }
+
+        return $this->redirectFromReferer(
+            $request,
+            $response,
+            ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
+            $bookmark->getShortUrl()
+        );
+    }
+
+    /**
+     * Helper function used to display the shaare form whether it's a new or existing bookmark.
+     *
+     * @param array $link data used in template, either from parameters or from the data store
+     */
+    protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
+    {
+        $data = $this->buildFormData($link, $isNew, $request);
+
+        $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
+
+        foreach ($data as $key => $value) {
+            $this->assignView($key, $value);
+        }
+
+        $editLabel = false === $isNew ? t('Edit') .' ' : '';
+        $this->assignView(
+            'pagetitle',
+            $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render(TemplatePage::EDIT_LINK));
+    }
+
+    protected function buildLinkDataFromUrl(Request $request, string $url): array
+    {
+        // Check if URL is not already in database (in this case, we will edit the existing link)
+        $bookmark = $this->container->bookmarkService->findByUrl($url);
+        if (null === $bookmark) {
+            // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
+            $title = $request->getParam('title');
+            $description = $request->getParam('description');
+            $tags = $request->getParam('tags');
+            if ($request->getParam('private') !== null) {
+                $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
+            } else {
+                $private = $this->container->conf->get('privacy.default_private_links', false);
+            }
+
+            // If this is an HTTP(S) link, we try go get the page to extract
+            // the title (otherwise we will to straight to the edit form.)
+            if (true !== $this->container->conf->get('general.enable_async_metadata', true)
+                && empty($title)
+                && strpos(get_url_scheme($url) ?: '', 'http') !== false
+            ) {
+                $metadata = $this->container->metadataRetriever->retrieve($url);
+            }
+
+            if (empty($url)) {
+                $metadata['title'] = $this->container->conf->get('general.default_note_title', t('Note: '));
+            }
+
+            return [
+                'title' => $title ?? $metadata['title'] ?? '',
+                'url' => $url ?? '',
+                'description' => $description ?? $metadata['description'] ?? '',
+                'tags' => $tags ?? $metadata['tags'] ?? '',
+                'private' => $private,
+                'linkIsNew' => true,
+            ];
+        }
+
+        $formatter = $this->getFormatter('raw');
+        $link = $formatter->format($bookmark);
+        $link['linkIsNew'] = false;
+
+        return $link;
+    }
+
+    protected function buildFormData(array $link, bool $isNew, Request $request): array
+    {
+        return escape([
+            'link' => $link,
+            'link_is_new' => $isNew,
+            'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
+            'source' => $request->getParam('source') ?? '',
+            'tags' => $this->getTags(),
+            'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
+            'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
+            'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
+        ]);
+    }
+
+    /**
+     * Memoize formatterFactory->getFormatter() calls.
+     */
+    protected function getFormatter(string $type): BookmarkFormatter
+    {
+        if (!array_key_exists($type, $this->formatters) || $this->formatters[$type] === null) {
+            $this->formatters[$type] = $this->container->formatterFactory->getFormatter($type);
+        }
+
+        return $this->formatters[$type];
+    }
+
+    /**
+     * Memoize bookmarkService->bookmarksCountPerTag() calls.
+     */
+    protected function getTags(): array
+    {
+        if ($this->tags === null) {
+            $this->tags = $this->container->bookmarkService->bookmarksCountPerTag();
+
+            if ($this->container->conf->get('formatter') === 'markdown') {
+                $this->tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
+            }
+        }
+
+        return $this->tags;
+    }
+}
diff --git a/application/front/controller/admin/ShaarliAdminController.php b/application/front/controller/admin/ShaarliAdminController.php
new file mode 100644 (file)
index 0000000..c26c9cb
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+
+/**
+ * Class ShaarliAdminController
+ *
+ * All admin controllers (for logged in users) MUST extend this abstract class.
+ * It makes sure that the user is properly logged in, and otherwise throw an exception
+ * which will redirect to the login page.
+ *
+ * @package Shaarli\Front\Controller\Admin
+ */
+abstract class ShaarliAdminController extends ShaarliVisitorController
+{
+    /**
+     * Any persistent action to the config or data store must check the XSRF token validity.
+     */
+    protected function checkToken(Request $request): bool
+    {
+        if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
+            throw new WrongTokenException();
+        }
+
+        return true;
+    }
+
+    /**
+     * Save a SUCCESS message in user session, which will be displayed on any template page.
+     */
+    protected function saveSuccessMessage(string $message): void
+    {
+        $this->saveMessage(SessionManager::KEY_SUCCESS_MESSAGES, $message);
+    }
+
+    /**
+     * Save a WARNING message in user session, which will be displayed on any template page.
+     */
+    protected function saveWarningMessage(string $message): void
+    {
+        $this->saveMessage(SessionManager::KEY_WARNING_MESSAGES, $message);
+    }
+
+    /**
+     * Save an ERROR message in user session, which will be displayed on any template page.
+     */
+    protected function saveErrorMessage(string $message): void
+    {
+        $this->saveMessage(SessionManager::KEY_ERROR_MESSAGES, $message);
+    }
+
+    /**
+     * Use the sessionManager to save the provided message using the proper type.
+     *
+     * @param string $type successed/warnings/errors
+     */
+    protected function saveMessage(string $type, string $message): void
+    {
+        $messages = $this->container->sessionManager->getSessionParameter($type) ?? [];
+        $messages[] = $message;
+
+        $this->container->sessionManager->setSessionParameter($type, $messages);
+    }
+}
diff --git a/application/front/controller/admin/ThumbnailsController.php b/application/front/controller/admin/ThumbnailsController.php
new file mode 100644 (file)
index 0000000..4dc09d3
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ToolsController
+ *
+ * Slim controller used to handle thumbnails update.
+ */
+class ThumbnailsController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/thumbnails - Display thumbnails update page
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $ids = [];
+        foreach ($this->container->bookmarkService->search() as $bookmark) {
+            // A note or not HTTP(S)
+            if ($bookmark->isNote() || !startsWith(strtolower($bookmark->getUrl()), 'http')) {
+                continue;
+            }
+
+            $ids[] = $bookmark->getId();
+        }
+
+        $this->assignView('ids', $ids);
+        $this->assignView(
+            'pagetitle',
+            t('Thumbnails update') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render(TemplatePage::THUMBNAILS));
+    }
+
+    /**
+     * PATCH /admin/shaare/{id}/thumbnail-update - Route for AJAX calls
+     */
+    public function ajaxUpdate(Request $request, Response $response, array $args): Response
+    {
+        $id = $args['id'] ?? null;
+
+        if (false === ctype_digit($id)) {
+            return $response->withStatus(400);
+        }
+
+        try {
+            $bookmark = $this->container->bookmarkService->get((int) $id);
+        } catch (BookmarkNotFoundException $e) {
+            return $response->withStatus(404);
+        }
+
+        $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
+        $this->container->bookmarkService->set($bookmark);
+
+        return $response->withJson($this->container->formatterFactory->getFormatter('raw')->format($bookmark));
+    }
+}
diff --git a/application/front/controller/admin/TokenController.php b/application/front/controller/admin/TokenController.php
new file mode 100644 (file)
index 0000000..08d68d0
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class TokenController
+ *
+ * Endpoint used to retrieve a XSRF token. Useful for AJAX requests.
+ */
+class TokenController extends ShaarliAdminController
+{
+    /**
+     * GET /admin/token
+     */
+    public function getToken(Request $request, Response $response): Response
+    {
+        $response = $response->withHeader('Content-Type', 'text/plain');
+
+        return $response->write($this->container->sessionManager->generateToken());
+    }
+}
diff --git a/application/front/controller/admin/ToolsController.php b/application/front/controller/admin/ToolsController.php
new file mode 100644 (file)
index 0000000..a87f20d
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ToolsController
+ *
+ * Slim controller used to display the tools page.
+ */
+class ToolsController extends ShaarliAdminController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        $data = [
+            'pageabsaddr' => index_url($this->container->environment),
+            'sslenabled' => is_https($this->container->environment),
+        ];
+
+        $this->executePageHooks('render_tools', $data, TemplatePage::TOOLS);
+
+        foreach ($data as $key => $value) {
+            $this->assignView($key, $value);
+        }
+
+        $this->assignView('pagetitle', t('Tools') .' - '. $this->container->conf->get('general.title', 'Shaarli'));
+
+        return $response->write($this->render(TemplatePage::TOOLS));
+    }
+}
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php
new file mode 100644 (file)
index 0000000..78c474c
--- /dev/null
@@ -0,0 +1,249 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Legacy\LegacyController;
+use Shaarli\Legacy\UnknowLegacyRouteException;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class BookmarkListController
+ *
+ * Slim controller used to render the bookmark list, the home page of Shaarli.
+ * It also displays permalinks, and process legacy routes based on GET parameters.
+ */
+class BookmarkListController extends ShaarliVisitorController
+{
+    /**
+     * GET / - Displays the bookmark list, with optional filter parameters.
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $legacyResponse = $this->processLegacyController($request, $response);
+        if (null !== $legacyResponse) {
+            return $legacyResponse;
+        }
+
+        $formatter = $this->container->formatterFactory->getFormatter();
+        $formatter->addContextData('base_path', $this->container->basePath);
+
+        $searchTags = normalize_spaces($request->getParam('searchtags') ?? '');
+        $searchTerm = escape(normalize_spaces($request->getParam('searchterm') ?? ''));;
+
+        // Filter bookmarks according search parameters.
+        $visibility = $this->container->sessionManager->getSessionParameter('visibility');
+        $search = [
+            'searchtags' => $searchTags,
+            'searchterm' => $searchTerm,
+        ];
+        $linksToDisplay = $this->container->bookmarkService->search(
+            $search,
+            $visibility,
+            false,
+            !!$this->container->sessionManager->getSessionParameter('untaggedonly')
+        ) ?? [];
+
+        // ---- Handle paging.
+        $keys = [];
+        foreach ($linksToDisplay as $key => $value) {
+            $keys[] = $key;
+        }
+
+        $linksPerPage = $this->container->sessionManager->getSessionParameter('LINKS_PER_PAGE', 20) ?: 20;
+
+        // Select articles according to paging.
+        $pageCount = (int) ceil(count($keys) / $linksPerPage) ?: 1;
+        $page = (int) $request->getParam('page') ?? 1;
+        $page = $page < 1 ? 1 : $page;
+        $page = $page > $pageCount ? $pageCount : $page;
+
+        // Start index.
+        $i = ($page - 1) * $linksPerPage;
+        $end = $i + $linksPerPage;
+
+        $linkDisp = [];
+        $save = false;
+        while ($i < $end && $i < count($keys)) {
+            $save = $this->updateThumbnail($linksToDisplay[$keys[$i]], false) || $save;
+            $link = $formatter->format($linksToDisplay[$keys[$i]]);
+
+            $linkDisp[$keys[$i]] = $link;
+            $i++;
+        }
+
+        if ($save) {
+            $this->container->bookmarkService->save();
+        }
+
+        // Compute paging navigation
+        $searchtagsUrl = $searchTags === '' ? '' : '&searchtags=' . urlencode($searchTags);
+        $searchtermUrl = $searchTerm === '' ? '' : '&searchterm=' . urlencode($searchTerm);
+
+        $previous_page_url = '';
+        if ($i !== count($keys)) {
+            $previous_page_url = '?page=' . ($page + 1) . $searchtermUrl . $searchtagsUrl;
+        }
+        $next_page_url = '';
+        if ($page > 1) {
+            $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
+        }
+
+        // Fill all template fields.
+        $data = array_merge(
+            $this->initializeTemplateVars(),
+            [
+                'previous_page_url' => $previous_page_url,
+                'next_page_url' => $next_page_url,
+                'page_current' => $page,
+                'page_max' => $pageCount,
+                'result_count' => count($linksToDisplay),
+                'search_term' => escape($searchTerm),
+                'search_tags' => escape($searchTags),
+                'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)),
+                'visibility' => $visibility,
+                'links' => $linkDisp,
+            ]
+        );
+
+        if (!empty($searchTerm) || !empty($searchTags)) {
+            $data['pagetitle'] = t('Search: ');
+            $data['pagetitle'] .= ! empty($searchTerm) ? $searchTerm . ' ' : '';
+            $bracketWrap = function ($tag) {
+                return '[' . $tag . ']';
+            };
+            $data['pagetitle'] .= ! empty($searchTags)
+                ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' '
+                : '';
+            $data['pagetitle'] .= '- ';
+        }
+
+        $data['pagetitle'] = ($data['pagetitle'] ?? '') . $this->container->conf->get('general.title', 'Shaarli');
+
+        $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
+        $this->assignAllView($data);
+
+        return $response->write($this->render(TemplatePage::LINKLIST));
+    }
+
+    /**
+     * GET /shaare/{hash} - Display a single shaare
+     */
+    public function permalink(Request $request, Response $response, array $args): Response
+    {
+        $privateKey = $request->getParam('key');
+
+        try {
+            $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
+        } catch (BookmarkNotFoundException $e) {
+            $this->assignView('error_message', $e->getMessage());
+
+            return $response->write($this->render(TemplatePage::ERROR_404));
+        }
+
+        $this->updateThumbnail($bookmark);
+
+        $formatter = $this->container->formatterFactory->getFormatter();
+        $formatter->addContextData('base_path', $this->container->basePath);
+
+        $data = array_merge(
+            $this->initializeTemplateVars(),
+            [
+                'pagetitle' => $bookmark->getTitle() .' - '. $this->container->conf->get('general.title', 'Shaarli'),
+                'links' => [$formatter->format($bookmark)],
+            ]
+        );
+
+        $this->executePageHooks('render_linklist', $data, TemplatePage::LINKLIST);
+        $this->assignAllView($data);
+
+        return $response->write($this->render(TemplatePage::LINKLIST));
+    }
+
+    /**
+     * Update the thumbnail of a single bookmark if necessary.
+     */
+    protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
+    {
+        if (false === $this->container->loginManager->isLoggedIn()) {
+            return false;
+        }
+
+        // If thumbnail should be updated, we reset it to null
+        if ($bookmark->shouldUpdateThumbnail()) {
+            $bookmark->setThumbnail(null);
+
+            // Requires an update, not async retrieval, thumbnails enabled
+            if ($bookmark->shouldUpdateThumbnail()
+                && true !== $this->container->conf->get('general.enable_async_metadata', true)
+                && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
+            ) {
+                $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
+                $this->container->bookmarkService->set($bookmark, $writeDatastore);
+
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * @return string[] Default template variables without values.
+     */
+    protected function initializeTemplateVars(): array
+    {
+        return [
+            'previous_page_url' => '',
+            'next_page_url' => '',
+            'page_max' => '',
+            'search_tags' => '',
+            'result_count' => '',
+            'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true)
+        ];
+    }
+
+    /**
+     * Process legacy routes if necessary. They used query parameters.
+     * If no legacy routes is passed, return null.
+     */
+    protected function processLegacyController(Request $request, Response $response): ?Response
+    {
+        // Legacy smallhash filter
+        $queryString = $this->container->environment['QUERY_STRING'] ?? null;
+        if (null !== $queryString && 1 === preg_match('/^([a-zA-Z0-9-_@]{6})($|&|#)/', $queryString, $match)) {
+            return $this->redirect($response, '/shaare/' . $match[1]);
+        }
+
+        // Legacy controllers (mostly used for redirections)
+        if (null !== $request->getQueryParam('do')) {
+            $legacyController = new LegacyController($this->container);
+
+            try {
+                return $legacyController->process($request, $response, $request->getQueryParam('do'));
+            } catch (UnknowLegacyRouteException $e) {
+                // We ignore legacy 404
+                return null;
+            }
+        }
+
+        // Legacy GET admin routes
+        $legacyGetRoutes = array_intersect(
+            LegacyController::LEGACY_GET_ROUTES,
+            array_keys($request->getQueryParams() ?? [])
+        );
+        if (1 === count($legacyGetRoutes)) {
+            $legacyController = new LegacyController($this->container);
+
+            return $legacyController->process($request, $response, $legacyGetRoutes[0]);
+        }
+
+        return null;
+    }
+}
diff --git a/application/front/controller/visitor/DailyController.php b/application/front/controller/visitor/DailyController.php
new file mode 100644 (file)
index 0000000..728bc2d
--- /dev/null
@@ -0,0 +1,205 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use DateTime;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Helper\DailyPageHelper;
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class DailyController
+ *
+ * Slim controller used to render the daily page.
+ */
+class DailyController extends ShaarliVisitorController
+{
+    public static $DAILY_RSS_NB_DAYS = 8;
+
+    /**
+     * Controller displaying all bookmarks published in a single day.
+     * It take a `day` date query parameter (format YYYYMMDD).
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        $type = DailyPageHelper::extractRequestedType($request);
+        $format = DailyPageHelper::getFormatByType($type);
+        $latestBookmark = $this->container->bookmarkService->getLatest();
+        $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
+        $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
+        $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
+        $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
+
+        $linksToDisplay = $this->container->bookmarkService->findByDate(
+            $start,
+            $end,
+            $previousDay,
+            $nextDay
+        );
+
+        $formatter = $this->container->formatterFactory->getFormatter();
+        $formatter->addContextData('base_path', $this->container->basePath);
+        // We pre-format some fields for proper output.
+        foreach ($linksToDisplay as $key => $bookmark) {
+            $linksToDisplay[$key] = $formatter->format($bookmark);
+            // This page is a bit specific, we need raw description to calculate the length
+            $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description'];
+            $linksToDisplay[$key]['description'] = $bookmark->getDescription();
+        }
+
+        $data = [
+            'linksToDisplay' => $linksToDisplay,
+            'dayDate' => $start,
+            'day' => $start->getTimestamp(),
+            'previousday' => $previousDay ? $previousDay->format($format) : '',
+            'nextday' => $nextDay ? $nextDay->format($format) : '',
+            'dayDesc' => $dailyDesc,
+            'type' => $type,
+            'localizedType' => $this->translateType($type),
+        ];
+
+        // Hooks are called before column construction so that plugins don't have to deal with columns.
+        $this->executePageHooks('render_daily', $data, TemplatePage::DAILY);
+
+        $data['cols'] = $this->calculateColumns($data['linksToDisplay']);
+
+        $this->assignAllView($data);
+
+        $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
+        $this->assignView(
+            'pagetitle',
+            $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
+        );
+
+        return $response->write($this->render(TemplatePage::DAILY));
+    }
+
+    /**
+     * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day.
+     * Gives the last 7 days (which have bookmarks).
+     * This RSS feed cannot be filtered and does not trigger plugins yet.
+     */
+    public function rss(Request $request, Response $response): Response
+    {
+        $response = $response->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
+
+        $pageUrl = page_url($this->container->environment);
+        $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
+
+        $cached = $cache->cachedVersion();
+        if (!empty($cached)) {
+            return $response->write($cached);
+        }
+
+        $days = [];
+        $type = DailyPageHelper::extractRequestedType($request);
+        $format = DailyPageHelper::getFormatByType($type);
+        $length = DailyPageHelper::getRssLengthByType($type);
+        foreach ($this->container->bookmarkService->search() as $bookmark) {
+            $day = $bookmark->getCreated()->format($format);
+
+            // Stop iterating after DAILY_RSS_NB_DAYS entries
+            if (count($days) === $length && !isset($days[$day])) {
+                break;
+            }
+
+            $days[$day][] = $bookmark;
+        }
+
+        // Build the RSS feed.
+        $indexUrl = escape(index_url($this->container->environment));
+
+        $formatter = $this->container->formatterFactory->getFormatter();
+        $formatter->addContextData('index_url', $indexUrl);
+
+        $dataPerDay = [];
+
+        /** @var Bookmark[] $bookmarks */
+        foreach ($days as $day => $bookmarks) {
+            $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
+            $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
+
+            // We only want the RSS entry to be published when the period is over.
+            if (new DateTime() < $endDateTime) {
+                continue;
+            }
+
+            $dataPerDay[$day] = [
+                'date' => $endDateTime,
+                'date_rss' => $endDateTime->format(DateTime::RSS),
+                'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime),
+                'absolute_url' => $indexUrl . 'daily?'. $type .'=' . $day,
+                'links' => [],
+            ];
+
+            foreach ($bookmarks as $key => $bookmark) {
+                $dataPerDay[$day]['links'][$key] = $formatter->format($bookmark);
+
+                // Make permalink URL absolute
+                if ($bookmark->isNote()) {
+                    $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
+                }
+            }
+        }
+
+        $this->assignAllView([
+            'title' => $this->container->conf->get('general.title', 'Shaarli'),
+            'index_url' => $indexUrl,
+            'page_url' => $pageUrl,
+            'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
+            'days' => $dataPerDay,
+            'type' => $type,
+            'localizedType' => $this->translateType($type),
+        ]);
+
+        $rssContent = $this->render(TemplatePage::DAILY_RSS);
+
+        $cache->cache($rssContent);
+
+        return $response->write($rssContent);
+    }
+
+    /**
+     * We need to spread the articles on 3 columns.
+     * did not want to use a JavaScript lib like http://masonry.desandro.com/
+     * so I manually spread entries with a simple method: I roughly evaluate the
+     * height of a div according to title and description length.
+     */
+    protected function calculateColumns(array $links): array
+    {
+        // Entries to display, for each column.
+        $columns = [[], [], []];
+        // Rough estimate of columns fill.
+        $fill = [0, 0, 0];
+        foreach ($links as $link) {
+            // Roughly estimate length of entry (by counting characters)
+            // Title: 30 chars = 1 line. 1 line is 30 pixels height.
+            // Description: 836 characters gives roughly 342 pixel height.
+            // This is not perfect, but it's usually OK.
+            $length = strlen($link['title'] ?? '') + (342 * strlen($link['description'] ?? '')) / 836;
+            if (! empty($link['thumbnail'])) {
+                $length += 100; // 1 thumbnails roughly takes 100 pixels height.
+            }
+            // Then put in column which is the less filled:
+            $smallest = min($fill); // find smallest value in array.
+            $index = array_search($smallest, $fill); // find index of this smallest value.
+            array_push($columns[$index], $link); // Put entry in this column.
+            $fill[$index] += $length;
+        }
+
+        return $columns;
+    }
+
+    protected function translateType($type): string
+    {
+        return [
+            t('day') => t('Daily'),
+            t('week') => t('Weekly'),
+            t('month') => t('Monthly'),
+        ][t($type)] ?? t('Daily');
+    }
+}
diff --git a/application/front/controller/visitor/ErrorController.php b/application/front/controller/visitor/ErrorController.php
new file mode 100644 (file)
index 0000000..8da1117
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Front\Exception\ShaarliFrontException;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Controller used to render the error page, with a provided exception.
+ * It is actually used as a Slim error handler.
+ */
+class ErrorController extends ShaarliVisitorController
+{
+    public function __invoke(Request $request, Response $response, \Throwable $throwable): Response
+    {
+        // Unknown error encountered
+        $this->container->pageBuilder->reset();
+
+        if ($throwable instanceof ShaarliFrontException) {
+            // Functional error
+            $this->assignView('message', nl2br($throwable->getMessage()));
+
+            $response = $response->withStatus($throwable->getCode());
+        } else {
+            // Internal error (any other Throwable)
+            if ($this->container->conf->get('dev.debug', false)) {
+                $this->assignView('message', $throwable->getMessage());
+                $this->assignView('stacktrace', exception2text($throwable));
+            } else {
+                $this->assignView('message', t('An unexpected error occurred.'));
+            }
+
+            $response = $response->withStatus(500);
+        }
+
+
+        return $response->write($this->render('error'));
+    }
+}
diff --git a/application/front/controller/visitor/ErrorNotFoundController.php b/application/front/controller/visitor/ErrorNotFoundController.php
new file mode 100644 (file)
index 0000000..758dd83
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Controller used to render the 404 error page.
+ */
+class ErrorNotFoundController extends ShaarliVisitorController
+{
+    public function __invoke(Request $request, Response $response): Response
+    {
+        // Request from the API
+        if (false !== strpos($request->getRequestTarget(), '/api/v1')) {
+            return $response->withStatus(404);
+        }
+
+        // This is required because the middleware is ignored if the route is not found.
+        $this->container->basePath = rtrim($request->getUri()->getBasePath(), '/');
+
+        $this->assignView('error_message', t('Requested page could not be found.'));
+
+        return $response->withStatus(404)->write($this->render('404'));
+    }
+}
diff --git a/application/front/controller/visitor/FeedController.php b/application/front/controller/visitor/FeedController.php
new file mode 100644 (file)
index 0000000..8d8b546
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Feed\FeedBuilder;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class FeedController
+ *
+ * Slim controller handling ATOM and RSS feed.
+ */
+class FeedController extends ShaarliVisitorController
+{
+    public function atom(Request $request, Response $response): Response
+    {
+        return $this->processRequest(FeedBuilder::$FEED_ATOM, $request, $response);
+    }
+
+    public function rss(Request $request, Response $response): Response
+    {
+        return $this->processRequest(FeedBuilder::$FEED_RSS, $request, $response);
+    }
+
+    protected function processRequest(string $feedType, Request $request, Response $response): Response
+    {
+        $response = $response->withHeader('Content-Type', 'application/'. $feedType .'+xml; charset=utf-8');
+
+        $pageUrl = page_url($this->container->environment);
+        $cache = $this->container->pageCacheManager->getCachePage($pageUrl);
+
+        $cached = $cache->cachedVersion();
+        if (!empty($cached)) {
+            return $response->write($cached);
+        }
+
+        // Generate data.
+        $this->container->feedBuilder->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
+        $this->container->feedBuilder->setHideDates($this->container->conf->get('privacy.hide_timestamps', false));
+        $this->container->feedBuilder->setUsePermalinks(
+            null !== $request->getParam('permalinks') || !$this->container->conf->get('feed.rss_permalinks')
+        );
+
+        $data = $this->container->feedBuilder->buildData($feedType, $request->getParams());
+
+        $this->executePageHooks('render_feed', $data, 'feed.' . $feedType);
+        $this->assignAllView($data);
+
+        $content = $this->render('feed.' . $feedType);
+
+        $cache->cache($content);
+
+        return $response->write($content);
+    }
+}
diff --git a/application/front/controller/visitor/InstallController.php b/application/front/controller/visitor/InstallController.php
new file mode 100644 (file)
index 0000000..2232929
--- /dev/null
@@ -0,0 +1,175 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Container\ShaarliContainer;
+use Shaarli\Front\Exception\AlreadyInstalledException;
+use Shaarli\Front\Exception\ResourcePermissionException;
+use Shaarli\Helper\ApplicationUtils;
+use Shaarli\Languages;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Slim controller used to render install page, and create initial configuration file.
+ */
+class InstallController extends ShaarliVisitorController
+{
+    public const SESSION_TEST_KEY = 'session_tested';
+    public const SESSION_TEST_VALUE = 'Working';
+
+    public function __construct(ShaarliContainer $container)
+    {
+        parent::__construct($container);
+
+        if (is_file($this->container->conf->getConfigFileExt())) {
+            throw new AlreadyInstalledException();
+        }
+    }
+
+    /**
+     * Display the install template page.
+     * Also test file permissions and sessions beforehand.
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        // Before installation, we'll make sure that permissions are set properly, and sessions are working.
+        $this->checkPermissions();
+
+        if (static::SESSION_TEST_VALUE
+            !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
+        ) {
+            $this->container->sessionManager->setSessionParameter(static::SESSION_TEST_KEY, static::SESSION_TEST_VALUE);
+
+            return $this->redirect($response, '/install/session-test');
+        }
+
+        [$continents, $cities] = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
+
+        $this->assignView('continents', $continents);
+        $this->assignView('cities', $cities);
+        $this->assignView('languages', Languages::getAvailableLanguages());
+
+        $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
+
+        $this->assignView('php_version', PHP_VERSION);
+        $this->assignView('php_eol', format_date($phpEol, false));
+        $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
+        $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
+        $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
+
+        $this->assignView('pagetitle', t('Install Shaarli'));
+
+        return $response->write($this->render('install'));
+    }
+
+    /**
+     * Route checking that the session parameter has been properly saved between two distinct requests.
+     * If the session parameter is preserved, redirect to install template page, otherwise displays error.
+     */
+    public function sessionTest(Request $request, Response $response): Response
+    {
+        // This part makes sure sessions works correctly.
+        // (Because on some hosts, session.save_path may not be set correctly,
+        // or we may not have write access to it.)
+        if (static::SESSION_TEST_VALUE
+            !== $this->container->sessionManager->getSessionParameter(static::SESSION_TEST_KEY)
+        ) {
+            // Step 2: Check if data in session is correct.
+            $msg = t(
+                '<pre>Sessions do not seem to work correctly on your server.<br>'.
+                'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
+                'and that you have write access to it.<br>'.
+                'It currently points to %s.<br>'.
+                'On some browsers, accessing your server via a hostname like \'localhost\' '.
+                'or any custom hostname without a dot causes cookie storage to fail. '.
+                'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
+            );
+            $msg = sprintf($msg, $this->container->sessionManager->getSavePath());
+
+            $this->assignView('message', $msg);
+
+            return $response->write($this->render('error'));
+        }
+
+        return $this->redirect($response, '/install');
+    }
+
+    /**
+     * Save installation form and initialize config file and datastore if necessary.
+     */
+    public function save(Request $request, Response $response): Response
+    {
+        $timezone = 'UTC';
+        if (!empty($request->getParam('continent'))
+            && !empty($request->getParam('city'))
+            && isTimeZoneValid($request->getParam('continent'), $request->getParam('city'))
+        ) {
+            $timezone = $request->getParam('continent') . '/' . $request->getParam('city');
+        }
+        $this->container->conf->set('general.timezone', $timezone);
+
+        $login = $request->getParam('setlogin');
+        $this->container->conf->set('credentials.login', $login);
+        $salt = sha1(uniqid('', true) .'_'. mt_rand());
+        $this->container->conf->set('credentials.salt', $salt);
+        $this->container->conf->set('credentials.hash', sha1($request->getParam('setpassword') . $login . $salt));
+
+        if (!empty($request->getParam('title'))) {
+            $this->container->conf->set('general.title', escape($request->getParam('title')));
+        } else {
+            $this->container->conf->set(
+                'general.title',
+                'Shared bookmarks on '.escape(index_url($this->container->environment))
+            );
+        }
+
+        $this->container->conf->set('translation.language', escape($request->getParam('language')));
+        $this->container->conf->set('updates.check_updates', !empty($request->getParam('updateCheck')));
+        $this->container->conf->set('api.enabled', !empty($request->getParam('enableApi')));
+        $this->container->conf->set(
+            'api.secret',
+            generate_api_secret(
+                $this->container->conf->get('credentials.login'),
+                $this->container->conf->get('credentials.salt')
+            )
+        );
+        $this->container->conf->set('general.header_link', $this->container->basePath . '/');
+
+        try {
+            // Everything is ok, let's create config file.
+            $this->container->conf->write($this->container->loginManager->isLoggedIn());
+        } catch (\Exception $e) {
+            $this->assignView('message', t('Error while writing config file after configuration update.'));
+            $this->assignView('stacktrace', $e->getMessage() . PHP_EOL . $e->getTraceAsString());
+
+            return $response->write($this->render('error'));
+        }
+
+        $this->container->sessionManager->setSessionParameter(
+            SessionManager::KEY_SUCCESS_MESSAGES,
+            [t('Shaarli is now configured. Please login and start shaaring your bookmarks!')]
+        );
+
+        return $this->redirect($response, '/login');
+    }
+
+    protected function checkPermissions(): bool
+    {
+        // Ensure Shaarli has proper access to its resources
+        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
+        if (empty($errors)) {
+            return true;
+        }
+
+        $message = t('Insufficient permissions:') . PHP_EOL;
+        foreach ($errors as $error) {
+            $message .= PHP_EOL . $error;
+        }
+
+        throw new ResourcePermissionException($message);
+    }
+}
diff --git a/application/front/controller/visitor/LoginController.php b/application/front/controller/visitor/LoginController.php
new file mode 100644 (file)
index 0000000..f5038fe
--- /dev/null
@@ -0,0 +1,153 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Front\Exception\CantLoginException;
+use Shaarli\Front\Exception\LoginBannedException;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Security\CookieManager;
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class LoginController
+ *
+ * Slim controller used to render the login page.
+ *
+ * The login page is not available if the user is banned
+ * or if open shaarli setting is enabled.
+ */
+class LoginController extends ShaarliVisitorController
+{
+    /**
+     * GET /login - Display the login page.
+     */
+    public function index(Request $request, Response $response): Response
+    {
+        try {
+            $this->checkLoginState();
+        } catch (CantLoginException $e) {
+            return $this->redirect($response, '/');
+        }
+
+        if ($request->getParam('login') !== null) {
+            $this->assignView('username', escape($request->getParam('login')));
+        }
+
+        $returnUrl = $request->getParam('returnurl') ?? $this->container->environment['HTTP_REFERER'] ?? null;
+
+        $this
+            ->assignView('returnurl', escape($returnUrl))
+            ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
+            ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli'))
+        ;
+
+        return $response->write($this->render(TemplatePage::LOGIN));
+    }
+
+    /**
+     * POST /login - Process login
+     */
+    public function login(Request $request, Response $response): Response
+    {
+        if (!$this->container->sessionManager->checkToken($request->getParam('token'))) {
+            throw new WrongTokenException();
+        }
+
+        try {
+            $this->checkLoginState();
+        } catch (CantLoginException $e) {
+            return $this->redirect($response, '/');
+        }
+
+        if (!$this->container->loginManager->checkCredentials(
+                client_ip_id($this->container->environment),
+                $request->getParam('login'),
+                $request->getParam('password')
+            )
+        ) {
+            $this->container->loginManager->handleFailedLogin($this->container->environment);
+
+            $this->container->sessionManager->setSessionParameter(
+                SessionManager::KEY_ERROR_MESSAGES,
+                [t('Wrong login/password.')]
+            );
+
+            // Call controller directly instead of unnecessary redirection
+            return $this->index($request, $response);
+        }
+
+        $this->container->loginManager->handleSuccessfulLogin($this->container->environment);
+
+        $cookiePath = $this->container->basePath . '/';
+        $expirationTime = $this->saveLongLastingSession($request, $cookiePath);
+        $this->renewUserSession($cookiePath, $expirationTime);
+
+        // Force referer from given return URL
+        $this->container->environment['HTTP_REFERER'] = $request->getParam('returnurl');
+
+        return $this->redirectFromReferer($request, $response, ['login', 'install']);
+    }
+
+    /**
+     * Make sure that the user is allowed to login and/or displaying the login page:
+     *   - not already logged in
+     *   - not open shaarli
+     *   - not banned
+     */
+    protected function checkLoginState(): bool
+    {
+        if ($this->container->loginManager->isLoggedIn()
+            || $this->container->conf->get('security.open_shaarli', false)
+        ) {
+            throw new CantLoginException();
+        }
+
+        if (true !== $this->container->loginManager->canLogin($this->container->environment)) {
+            throw new LoginBannedException();
+        }
+
+        return true;
+    }
+
+    /**
+     * @return int Session duration in seconds
+     */
+    protected function saveLongLastingSession(Request $request, string $cookiePath): int
+    {
+        if (empty($request->getParam('longlastingsession'))) {
+            // Standard session expiration (=when browser closes)
+            $expirationTime = 0;
+        } else {
+            // Keep the session cookie even after the browser closes
+            $this->container->sessionManager->setStaySignedIn(true);
+            $expirationTime = $this->container->sessionManager->extendSession();
+        }
+
+        $this->container->cookieManager->setCookieParameter(
+            CookieManager::STAY_SIGNED_IN,
+            $this->container->loginManager->getStaySignedInToken(),
+            $expirationTime,
+            $cookiePath
+        );
+
+        return $expirationTime;
+    }
+
+    protected function renewUserSession(string $cookiePath, int $expirationTime): void
+    {
+        // Send cookie with the new expiration date to the browser
+        $this->container->sessionManager->destroy();
+        $this->container->sessionManager->cookieParameters(
+            $expirationTime,
+            $cookiePath,
+            $this->container->environment['SERVER_NAME']
+        );
+        $this->container->sessionManager->start();
+        $this->container->sessionManager->regenerateId(true);
+    }
+}
diff --git a/application/front/controller/visitor/OpenSearchController.php b/application/front/controller/visitor/OpenSearchController.php
new file mode 100644 (file)
index 0000000..36d60ac
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Render\TemplatePage;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class OpenSearchController
+ *
+ * Slim controller used to render open search template.
+ * This allows to add Shaarli as a search engine within the browser.
+ */
+class OpenSearchController extends ShaarliVisitorController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        $response = $response->withHeader('Content-Type', 'application/opensearchdescription+xml; charset=utf-8');
+
+        $this->assignView('serverurl', index_url($this->container->environment));
+
+        return $response->write($this->render(TemplatePage::OPEN_SEARCH));
+    }
+}
diff --git a/application/front/controller/visitor/PictureWallController.php b/application/front/controller/visitor/PictureWallController.php
new file mode 100644 (file)
index 0000000..3c57f8d
--- /dev/null
@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Front\Exception\ThumbnailsDisabledException;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class PicturesWallController
+ *
+ * Slim controller used to render the pictures wall page.
+ * If thumbnails mode is set to NONE, we just render the template without any image.
+ */
+class PictureWallController extends ShaarliVisitorController
+{
+    public function index(Request $request, Response $response): Response
+    {
+        if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) {
+            throw new ThumbnailsDisabledException();
+        }
+
+        $this->assignView(
+            'pagetitle',
+            t('Picture wall') .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        // Optionally filter the results:
+        $links = $this->container->bookmarkService->search($request->getQueryParams());
+        $linksToDisplay = [];
+
+        // Get only bookmarks which have a thumbnail.
+        // Note: we do not retrieve thumbnails here, the request is too heavy.
+        $formatter = $this->container->formatterFactory->getFormatter('raw');
+        foreach ($links as $key => $link) {
+            if (!empty($link->getThumbnail())) {
+                $linksToDisplay[] = $formatter->format($link);
+            }
+        }
+
+        $data = ['linksToDisplay' => $linksToDisplay];
+        $this->executePageHooks('render_picwall', $data, TemplatePage::PICTURE_WALL);
+
+        foreach ($data as $key => $value) {
+            $this->assignView($key, $value);
+        }
+
+        return $response->write($this->render(TemplatePage::PICTURE_WALL));
+    }
+}
diff --git a/application/front/controller/visitor/PublicSessionFilterController.php b/application/front/controller/visitor/PublicSessionFilterController.php
new file mode 100644 (file)
index 0000000..1a66362
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Security\SessionManager;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Slim controller used to handle filters stored in the visitor session, links per page, etc.
+ */
+class PublicSessionFilterController extends ShaarliVisitorController
+{
+    /**
+     * GET /links-per-page: set the number of bookmarks to display per page in homepage
+     */
+    public function linksPerPage(Request $request, Response $response): Response
+    {
+        $linksPerPage = $request->getParam('nb') ?? null;
+        if (null === $linksPerPage || false === is_numeric($linksPerPage)) {
+            $linksPerPage = $this->container->conf->get('general.links_per_page', 20);
+        }
+
+        $this->container->sessionManager->setSessionParameter(
+            SessionManager::KEY_LINKS_PER_PAGE,
+            abs(intval($linksPerPage))
+        );
+
+        return $this->redirectFromReferer($request, $response, ['linksperpage'], ['nb']);
+    }
+
+    /**
+     * GET /untagged-only: allows to display only bookmarks without any tag
+     */
+    public function untaggedOnly(Request $request, Response $response): Response
+    {
+        $this->container->sessionManager->setSessionParameter(
+            SessionManager::KEY_UNTAGGED_ONLY,
+            empty($this->container->sessionManager->getSessionParameter(SessionManager::KEY_UNTAGGED_ONLY))
+        );
+
+        return $this->redirectFromReferer($request, $response, ['untaggedonly', 'untagged-only']);
+    }
+}
diff --git a/application/front/controller/visitor/ShaarliVisitorController.php b/application/front/controller/visitor/ShaarliVisitorController.php
new file mode 100644 (file)
index 0000000..54f9fe0
--- /dev/null
@@ -0,0 +1,181 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Container\ShaarliContainer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ShaarliVisitorController
+ *
+ * All controllers accessible by visitors (non logged in users) should extend this abstract class.
+ * Contains a few helper function for template rendering, plugins, etc.
+ *
+ * @package Shaarli\Front\Controller\Visitor
+ */
+abstract class ShaarliVisitorController
+{
+    /** @var ShaarliContainer */
+    protected $container;
+
+    /** @param ShaarliContainer $container Slim container (extended for attribute completion). */
+    public function __construct(ShaarliContainer $container)
+    {
+        $this->container = $container;
+    }
+
+    /**
+     * Assign variables to RainTPL template through the PageBuilder.
+     *
+     * @param mixed $value Value to assign to the template
+     */
+    protected function assignView(string $name, $value): self
+    {
+        $this->container->pageBuilder->assign($name, $value);
+
+        return $this;
+    }
+
+    /**
+     * Assign variables to RainTPL template through the PageBuilder.
+     *
+     * @param mixed $data Values to assign to the template and their keys
+     */
+    protected function assignAllView(array $data): self
+    {
+        foreach ($data as $key => $value) {
+            $this->assignView($key, $value);
+        }
+
+        return $this;
+    }
+
+    protected function render(string $template): string
+    {
+        $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL));
+        $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE));
+
+        $this->executeDefaultHooks($template);
+
+        $this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
+
+        return $this->container->pageBuilder->render($template, $this->container->basePath);
+    }
+
+    /**
+     * Call plugin hooks for header, footer and includes, specifying which page will be rendered.
+     * Then assign generated data to RainTPL.
+     */
+    protected function executeDefaultHooks(string $template): void
+    {
+        $common_hooks = [
+            'includes',
+            'header',
+            'footer',
+        ];
+
+        $parameters = $this->buildPluginParameters($template);
+
+        foreach ($common_hooks as $name) {
+            $pluginData = [];
+            $this->container->pluginManager->executeHooks(
+                'render_' . $name,
+                $pluginData,
+                $parameters
+            );
+            $this->assignView('plugins_' . $name, $pluginData);
+        }
+    }
+
+    protected function executePageHooks(string $hook, array &$data, string $template = null): void
+    {
+        $this->container->pluginManager->executeHooks(
+            $hook,
+            $data,
+            $this->buildPluginParameters($template)
+        );
+    }
+
+    protected function buildPluginParameters(?string $template): array
+    {
+        return [
+            'target' => $template,
+            'loggedin' => $this->container->loginManager->isLoggedIn(),
+            'basePath' => $this->container->basePath,
+            'rootPath' => preg_replace('#/index\.php$#', '', $this->container->basePath),
+            'bookmarkService' => $this->container->bookmarkService
+        ];
+    }
+
+    /**
+     * Simple helper which prepend the base path to redirect path.
+     *
+     * @param Response $response
+     * @param string $path Absolute path, e.g.: `/`, or `/admin/shaare/123` regardless of install directory
+     *
+     * @return Response updated
+     */
+    protected function redirect(Response $response, string $path): Response
+    {
+        return $response->withRedirect($this->container->basePath . $path);
+    }
+
+    /**
+     * Generates a redirection to the previous page, based on the HTTP_REFERER.
+     * It fails back to the home page.
+     *
+     * @param array $loopTerms   Terms to remove from path and query string to prevent direction loop.
+     * @param array $clearParams List of parameter to remove from the query string of the referrer.
+     */
+    protected function redirectFromReferer(
+        Request $request,
+        Response $response,
+        array $loopTerms = [],
+        array $clearParams = [],
+        string $anchor = null
+    ): Response {
+        $defaultPath = $this->container->basePath . '/';
+        $referer = $this->container->environment['HTTP_REFERER'] ?? null;
+
+        if (null !== $referer) {
+            $currentUrl = parse_url($referer);
+            // If the referer is not related to Shaarli instance, redirect to default
+            if (isset($currentUrl['host'])
+                && strpos(index_url($this->container->environment), $currentUrl['host']) === false
+            ) {
+                return $response->withRedirect($defaultPath);
+            }
+
+            parse_str($currentUrl['query'] ?? '', $params);
+            $path = $currentUrl['path'] ?? $defaultPath;
+        } else {
+            $params = [];
+            $path = $defaultPath;
+        }
+
+        // Prevent redirection loop
+        if (isset($currentUrl)) {
+            foreach ($clearParams as $value) {
+                unset($params[$value]);
+            }
+
+            $checkQuery = implode('', array_keys($params));
+            foreach ($loopTerms as $value) {
+                if (strpos($path . $checkQuery, $value) !== false) {
+                    $params = [];
+                    $path = $defaultPath;
+                    break;
+                }
+            }
+        }
+
+        $queryString = count($params) > 0 ? '?'. http_build_query($params) : '';
+        $anchor = $anchor ? '#' . $anchor : '';
+
+        return $response->withRedirect($path . $queryString . $anchor);
+    }
+}
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php
new file mode 100644 (file)
index 0000000..76ed769
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class TagCloud
+ *
+ * Slim controller used to render the tag cloud and tag list pages.
+ */
+class TagCloudController extends ShaarliVisitorController
+{
+    protected const TYPE_CLOUD = 'cloud';
+    protected const TYPE_LIST = 'list';
+
+    /**
+     * Display the tag cloud through the template engine.
+     * This controller a few filters:
+     *   - Visibility stored in the session for logged in users
+     *   - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
+     */
+    public function cloud(Request $request, Response $response): Response
+    {
+        return $this->processRequest(static::TYPE_CLOUD, $request, $response);
+    }
+
+    /**
+     * Display the tag list through the template engine.
+     * This controller a few filters:
+     *   - Visibility stored in the session for logged in users
+     *   - `searchtags` query parameter: will return tags associated with filter in at least one bookmark
+     *   - `sort` query parameters:
+     *       + `usage` (default): most used tags first
+     *       + `alpha`: alphabetical order
+     */
+    public function list(Request $request, Response $response): Response
+    {
+        return $this->processRequest(static::TYPE_LIST, $request, $response);
+    }
+
+    /**
+     * Process the request for both tag cloud and tag list endpoints.
+     */
+    protected function processRequest(string $type, Request $request, Response $response): Response
+    {
+        if ($this->container->loginManager->isLoggedIn() === true) {
+            $visibility = $this->container->sessionManager->getSessionParameter('visibility');
+        }
+
+        $sort = $request->getQueryParam('sort');
+        $searchTags = $request->getQueryParam('searchtags');
+        $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : [];
+
+        $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
+
+        if (static::TYPE_CLOUD === $type || 'alpha' === $sort) {
+            // TODO: the sorting should be handled by bookmarkService instead of the controller
+            alphabetical_sort($tags, false, true);
+        }
+
+        if (static::TYPE_CLOUD === $type) {
+            $tags = $this->formatTagsForCloud($tags);
+        }
+
+        $tagsUrl = [];
+        foreach ($tags as $tag => $value) {
+            $tagsUrl[escape($tag)] = urlencode((string) $tag);
+        }
+
+        $searchTags = implode(' ', escape($filteringTags));
+        $searchTagsUrl = urlencode(implode(' ', $filteringTags));
+        $data = [
+            'search_tags' => escape($searchTags),
+            'search_tags_url' => $searchTagsUrl,
+            'tags' => escape($tags),
+            'tags_url' => $tagsUrl,
+        ];
+        $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
+        $this->assignAllView($data);
+
+        $searchTags = !empty($searchTags) ? $searchTags .' - ' : '';
+        $this->assignView(
+            'pagetitle',
+            $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli')
+        );
+
+        return $response->write($this->render('tag.' . $type));
+    }
+
+    /**
+     * Format the tags array for the tag cloud template.
+     *
+     * @param array<string, int> $tags List of tags as key with count as value
+     *
+     * @return mixed[] List of tags as key, with count and expected font size in a subarray
+     */
+    protected function formatTagsForCloud(array $tags): array
+    {
+        // We sort tags alphabetically, then choose a font size according to count.
+        // First, find max value.
+        $maxCount = count($tags) > 0 ? max($tags) : 0;
+        $logMaxCount = $maxCount > 1 ? log($maxCount, 30) : 1;
+        $tagList = [];
+        foreach ($tags as $key => $value) {
+            // Tag font size scaling:
+            //   default 15 and 30 logarithm bases affect scaling,
+            //   2.2 and 0.8 are arbitrary font sizes in em.
+            $size = log($value, 15) / $logMaxCount * 2.2 + 0.8;
+            $tagList[$key] = [
+                'count' => $value,
+                'size' => number_format($size, 2, '.', ''),
+            ];
+        }
+
+        return $tagList;
+    }
+}
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php
new file mode 100644 (file)
index 0000000..de4e7ea
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class TagController
+ *
+ * Slim controller handle tags.
+ */
+class TagController extends ShaarliVisitorController
+{
+    /**
+     * Add another tag in the current search through an HTTP redirection.
+     *
+     * @param array $args Should contain `newTag` key as tag to add to current search
+     */
+    public function addTag(Request $request, Response $response, array $args): Response
+    {
+        $newTag = $args['newTag'] ?? null;
+        $referer = $this->container->environment['HTTP_REFERER'] ?? null;
+
+        // In case browser does not send HTTP_REFERER, we search a single tag
+        if (null === $referer) {
+            if (null !== $newTag) {
+                return $this->redirect($response, '/?searchtags='. urlencode($newTag));
+            }
+
+            return $this->redirect($response, '/');
+        }
+
+        $currentUrl = parse_url($referer);
+        parse_str($currentUrl['query'] ?? '', $params);
+
+        if (null === $newTag) {
+            return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
+        }
+
+        // Prevent redirection loop
+        if (isset($params['addtag'])) {
+            unset($params['addtag']);
+        }
+
+        // Check if this tag is already in the search query and ignore it if it is.
+        // Each tag is always separated by a space
+        $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : [];
+
+        $addtag = true;
+        foreach ($currentTags as $value) {
+            if ($value === $newTag) {
+                $addtag = false;
+                break;
+            }
+        }
+
+        // Append the tag if necessary
+        if (true === $addtag) {
+            $currentTags[] = trim($newTag);
+        }
+
+        $params['searchtags'] = trim(implode(' ', $currentTags));
+
+        // We also remove page (keeping the same page has no sense, since the results are different)
+        unset($params['page']);
+
+        return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
+    }
+
+    /**
+     * Remove a tag from the current search through an HTTP redirection.
+     *
+     * @param array $args Should contain `tag` key as tag to remove from current search
+     */
+    public function removeTag(Request $request, Response $response, array $args): Response
+    {
+        $referer = $this->container->environment['HTTP_REFERER'] ?? null;
+
+        // If the referrer is not provided, we can update the search, so we failback on the bookmark list
+        if (empty($referer)) {
+            return $this->redirect($response, '/');
+        }
+
+        $tagToRemove = $args['tag'] ?? null;
+        $currentUrl = parse_url($referer);
+        parse_str($currentUrl['query'] ?? '', $params);
+
+        if (null === $tagToRemove) {
+            return $response->withRedirect(($currentUrl['path'] ?? './') .'?'. http_build_query($params));
+        }
+
+        // Prevent redirection loop
+        if (isset($params['removetag'])) {
+            unset($params['removetag']);
+        }
+
+        if (isset($params['searchtags'])) {
+            $tags = explode(' ', $params['searchtags']);
+            // Remove value from array $tags.
+            $tags = array_diff($tags, [$tagToRemove]);
+            $params['searchtags'] = implode(' ', $tags);
+
+            if (empty($params['searchtags'])) {
+                unset($params['searchtags']);
+            }
+
+            // We also remove page (keeping the same page has no sense, since the results are different)
+            unset($params['page']);
+        }
+
+        $queryParams = count($params) > 0 ? '?' . http_build_query($params) : '';
+
+        return $response->withRedirect(($currentUrl['path'] ?? './') . $queryParams);
+    }
+}
diff --git a/application/front/controllers/LoginController.php b/application/front/controllers/LoginController.php
deleted file mode 100644 (file)
index ae3599e..0000000
+++ /dev/null
@@ -1,48 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use Shaarli\Front\Exception\LoginBannedException;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-/**
- * Class LoginController
- *
- * Slim controller used to render the login page.
- *
- * The login page is not available if the user is banned
- * or if open shaarli setting is enabled.
- *
- * @package Front\Controller
- */
-class LoginController extends ShaarliController
-{
-    public function index(Request $request, Response $response): Response
-    {
-        if ($this->container->loginManager->isLoggedIn()
-            || $this->container->conf->get('security.open_shaarli', false)
-        ) {
-            return $response->withRedirect('./');
-        }
-
-        $userCanLogin = $this->container->loginManager->canLogin($request->getServerParams());
-        if ($userCanLogin !== true) {
-            throw new LoginBannedException();
-        }
-
-        if ($request->getParam('username') !== null) {
-            $this->assignView('username', escape($request->getParam('username')));
-        }
-
-        $this
-            ->assignView('returnurl', escape($request->getServerParam('HTTP_REFERER')))
-            ->assignView('remember_user_default', $this->container->conf->get('privacy.remember_user_default', true))
-            ->assignView('pagetitle', t('Login') .' - '. $this->container->conf->get('general.title', 'Shaarli'))
-        ;
-
-        return $response->write($this->render('loginform'));
-    }
-}
diff --git a/application/front/controllers/ShaarliController.php b/application/front/controllers/ShaarliController.php
deleted file mode 100644 (file)
index 2b82858..0000000
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use Shaarli\Bookmark\BookmarkFilter;
-use Shaarli\Container\ShaarliContainer;
-
-abstract class ShaarliController
-{
-    /** @var ShaarliContainer */
-    protected $container;
-
-    /** @param ShaarliContainer $container Slim container (extended for attribute completion). */
-    public function __construct(ShaarliContainer $container)
-    {
-        $this->container = $container;
-    }
-
-    /**
-     * Assign variables to RainTPL template through the PageBuilder.
-     *
-     * @param mixed $value Value to assign to the template
-     */
-    protected function assignView(string $name, $value): self
-    {
-        $this->container->pageBuilder->assign($name, $value);
-
-        return $this;
-    }
-
-    protected function render(string $template): string
-    {
-        $this->assignView('linkcount', $this->container->bookmarkService->count(BookmarkFilter::$ALL));
-        $this->assignView('privateLinkcount', $this->container->bookmarkService->count(BookmarkFilter::$PRIVATE));
-        $this->assignView('plugin_errors', $this->container->pluginManager->getErrors());
-
-        $this->executeDefaultHooks($template);
-
-        return $this->container->pageBuilder->render($template);
-    }
-
-    /**
-     * Call plugin hooks for header, footer and includes, specifying which page will be rendered.
-     * Then assign generated data to RainTPL.
-     */
-    protected function executeDefaultHooks(string $template): void
-    {
-        $common_hooks = [
-            'includes',
-            'header',
-            'footer',
-        ];
-
-        foreach ($common_hooks as $name) {
-            $plugin_data = [];
-            $this->container->pluginManager->executeHooks(
-                'render_' . $name,
-                $plugin_data,
-                [
-                    'target' => $template,
-                    'loggedin' => $this->container->loginManager->isLoggedIn()
-                ]
-            );
-            $this->assignView('plugins_' . $name, $plugin_data);
-        }
-    }
-}
diff --git a/application/front/exceptions/AlreadyInstalledException.php b/application/front/exceptions/AlreadyInstalledException.php
new file mode 100644 (file)
index 0000000..4add86c
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class AlreadyInstalledException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        $message = t('Shaarli has already been installed. Login to edit the configuration.');
+
+        parent::__construct($message, 401);
+    }
+}
diff --git a/application/front/exceptions/CantLoginException.php b/application/front/exceptions/CantLoginException.php
new file mode 100644 (file)
index 0000000..cd16635
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class CantLoginException extends \Exception
+{
+
+}
index b31a4a14ead0015f49195b11c0e584193abfa24c..79d0ea152ad21428102cc99a9ee2117cac4509cc 100644 (file)
@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 namespace Shaarli\Front\Exception;
 
-class LoginBannedException extends ShaarliException
+class LoginBannedException extends ShaarliFrontException
 {
     public function __construct()
     {
diff --git a/application/front/exceptions/OpenShaarliPasswordException.php b/application/front/exceptions/OpenShaarliPasswordException.php
new file mode 100644 (file)
index 0000000..a6f0b3a
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+/**
+ * Class OpenShaarliPasswordException
+ *
+ * Raised if the user tries to change the admin password on an open shaarli instance.
+ */
+class OpenShaarliPasswordException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        parent::__construct(t('You are not supposed to change a password on an Open Shaarli.'), 403);
+    }
+}
diff --git a/application/front/exceptions/ResourcePermissionException.php b/application/front/exceptions/ResourcePermissionException.php
new file mode 100644 (file)
index 0000000..8fbf03b
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class ResourcePermissionException extends ShaarliFrontException
+{
+    public function __construct(string $message)
+    {
+        parent::__construct($message, 500);
+    }
+}
similarity index 73%
rename from application/front/exceptions/ShaarliException.php
rename to application/front/exceptions/ShaarliFrontException.php
index 800bfbec34c619c09eb7d653aca58303758896d1..73847e6d17bf6ec2a33595766bd5ddce779d7523 100644 (file)
@@ -9,11 +9,11 @@ use Throwable;
 /**
  * Class ShaarliException
  *
- * Abstract exception class used to defined any custom exception thrown during front rendering.
+ * Exception class used to defined any custom exception thrown during front rendering.
  *
  * @package Front\Exception
  */
-abstract class ShaarliException extends \Exception
+class ShaarliFrontException extends \Exception
 {
     /** Override parent constructor to force $message and $httpCode parameters to be set. */
     public function __construct(string $message, int $httpCode, Throwable $previous = null)
diff --git a/application/front/exceptions/ThumbnailsDisabledException.php b/application/front/exceptions/ThumbnailsDisabledException.php
new file mode 100644 (file)
index 0000000..0ed337f
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+class ThumbnailsDisabledException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        $message = t('Picture wall unavailable (thumbnails are disabled).');
+
+        parent::__construct($message, 400);
+    }
+}
diff --git a/application/front/exceptions/UnauthorizedException.php b/application/front/exceptions/UnauthorizedException.php
new file mode 100644 (file)
index 0000000..4231094
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+/**
+ * Class UnauthorizedException
+ *
+ * Exception raised if the user tries to access a ShaarliAdminController while logged out.
+ */
+class UnauthorizedException extends \Exception
+{
+
+}
diff --git a/application/front/exceptions/WrongTokenException.php b/application/front/exceptions/WrongTokenException.php
new file mode 100644 (file)
index 0000000..4200272
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Exception;
+
+/**
+ * Class OpenShaarliPasswordException
+ *
+ * Raised if the user tries to perform an action with an invalid XSRF token.
+ */
+class WrongTokenException extends ShaarliFrontException
+{
+    public function __construct()
+    {
+        parent::__construct(t('Wrong token.'), 403);
+    }
+}
similarity index 69%
rename from application/ApplicationUtils.php
rename to application/helper/ApplicationUtils.php
index 3aa218295c634e3d0d02b3e5e9fa00ff534d7804..4b34e114caf380d7c6cf28512c908c200f299b88 100644 (file)
@@ -1,5 +1,5 @@
 <?php
-namespace Shaarli;
+namespace Shaarli\Helper;
 
 use Exception;
 use Shaarli\Config\ConfigManager;
@@ -14,8 +14,9 @@ class ApplicationUtils
      */
     public static $VERSION_FILE = 'shaarli_version.php';
 
-    private static $GIT_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
-    private static $GIT_BRANCHES = array('latest', 'stable');
+    public static $GITHUB_URL = 'https://github.com/shaarli/Shaarli';
+    public static $GIT_RAW_URL = 'https://raw.githubusercontent.com/shaarli/Shaarli';
+    public static $GIT_BRANCHES = array('latest', 'stable');
     private static $VERSION_START_TAG = '<?php /* ';
     private static $VERSION_END_TAG = ' */ ?>';
 
@@ -125,7 +126,7 @@ class ApplicationUtils
         // Late Static Binding allows overriding within tests
         // See http://php.net/manual/en/language.oop5.late-static-bindings.php
         $latestVersion = static::getVersion(
-            self::$GIT_URL . '/' . $branch . '/' . self::$VERSION_FILE
+            self::$GIT_RAW_URL . '/' . $branch . '/' . self::$VERSION_FILE
         );
 
         if (!$latestVersion) {
@@ -171,35 +172,45 @@ class ApplicationUtils
     /**
      * Checks Shaarli has the proper access permissions to its resources
      *
-     * @param ConfigManager $conf Configuration Manager instance.
+     * @param ConfigManager $conf        Configuration Manager instance.
+     * @param bool          $minimalMode In minimal mode we only check permissions to be able to display a template.
+     *                                   Currently we only need to be able to read the theme and write in raintpl cache.
      *
      * @return array A list of the detected configuration issues
      */
-    public static function checkResourcePermissions($conf)
+    public static function checkResourcePermissions(ConfigManager $conf, bool $minimalMode = false): array
     {
-        $errors = array();
+        $errors = [];
         $rainTplDir = rtrim($conf->get('resource.raintpl_tpl'), '/');
 
         // Check script and template directories are readable
-        foreach (array(
+        foreach ([
                      'application',
                      'inc',
                      'plugins',
                      $rainTplDir,
                      $rainTplDir . '/' . $conf->get('resource.theme'),
-                 ) as $path) {
+                 ] as $path) {
             if (!is_readable(realpath($path))) {
                 $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
         }
 
         // Check cache and data directories are readable and writable
-        foreach (array(
-                     $conf->get('resource.thumbnails_cache'),
-                     $conf->get('resource.data_dir'),
-                     $conf->get('resource.page_cache'),
-                     $conf->get('resource.raintpl_tmp'),
-                 ) as $path) {
+        if ($minimalMode) {
+            $folders = [
+                $conf->get('resource.raintpl_tmp'),
+            ];
+        } else {
+            $folders = [
+                $conf->get('resource.thumbnails_cache'),
+                $conf->get('resource.data_dir'),
+                $conf->get('resource.page_cache'),
+                $conf->get('resource.raintpl_tmp'),
+            ];
+        }
+
+        foreach ($folders as $path) {
             if (!is_readable(realpath($path))) {
                 $errors[] = '"' . $path . '" ' . t('directory is not readable');
             }
@@ -208,6 +219,10 @@ class ApplicationUtils
             }
         }
 
+        if ($minimalMode) {
+            return $errors;
+        }
+
         // Check configuration files are readable and writable
         foreach (array(
                      $conf->getConfigFileExt(),
@@ -246,4 +261,54 @@ class ApplicationUtils
     {
         return hash_hmac('sha256', $currentVersion, $salt);
     }
+
+    /**
+     * Get a list of PHP extensions used by Shaarli.
+     *
+     * @return array[] List of extension with following keys:
+     *                   - name: extension name
+     *                   - required: whether the extension is required to use Shaarli
+     *                   - desc: short description of extension usage in Shaarli
+     *                   - loaded: whether the extension is properly loaded or not
+     */
+    public static function getPhpExtensionsRequirement(): array
+    {
+        $extensions = [
+            ['name' => 'json', 'required' => true, 'desc' => t('Configuration parsing')],
+            ['name' => 'simplexml', 'required' => true, 'desc' => t('Slim Framework (routing, etc.)')],
+            ['name' => 'mbstring', 'required' => true, 'desc' => t('Multibyte (Unicode) string support')],
+            ['name' => 'gd', 'required' => false, 'desc' => t('Required to use thumbnails')],
+            ['name' => 'intl', 'required' => false, 'desc' => t('Localized text sorting (e.g. e->è->f)')],
+            ['name' => 'curl', 'required' => false, 'desc' => t('Better retrieval of bookmark metadata and thumbnail')],
+            ['name' => 'gettext', 'required' => false, 'desc' => t('Use the translation system in gettext mode')],
+            ['name' => 'ldap', 'required' => false, 'desc' => t('Login using LDAP server')],
+        ];
+
+        foreach ($extensions as &$extension) {
+            $extension['loaded'] = extension_loaded($extension['name']);
+        }
+
+        return $extensions;
+    }
+
+    /**
+     * Return the EOL date of given PHP version. If the version is unknown,
+     * we return today + 2 years.
+     *
+     * @param string $fullVersion PHP version, e.g. 7.4.7
+     *
+     * @return string Date format: YYYY-MM-DD
+     */
+    public static function getPhpEol(string $fullVersion): string
+    {
+        preg_match('/(\d+\.\d+)\.\d+/', $fullVersion, $matches);
+
+        return [
+            '7.1' => '2019-12-01',
+            '7.2' => '2020-11-30',
+            '7.3' => '2021-12-06',
+            '7.4' => '2022-11-28',
+            '8.0' => '2023-12-01',
+        ][$matches[1]] ?? (new \DateTime('+2 year'))->format('Y-m-d');
+    }
 }
diff --git a/application/helper/DailyPageHelper.php b/application/helper/DailyPageHelper.php
new file mode 100644 (file)
index 0000000..5fabc90
--- /dev/null
@@ -0,0 +1,208 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Helper;
+
+use Shaarli\Bookmark\Bookmark;
+use Slim\Http\Request;
+
+class DailyPageHelper
+{
+    public const MONTH = 'month';
+    public const WEEK = 'week';
+    public const DAY = 'day';
+
+    /**
+     * Extracts the type of the daily to display from the HTTP request parameters
+     *
+     * @param Request $request HTTP request
+     *
+     * @return string month/week/day
+     */
+    public static function extractRequestedType(Request $request): string
+    {
+        if ($request->getQueryParam(static::MONTH) !== null) {
+            return static::MONTH;
+        } elseif ($request->getQueryParam(static::WEEK) !== null) {
+            return static::WEEK;
+        }
+
+        return static::DAY;
+    }
+
+    /**
+     * Extracts a DateTimeImmutable from provided HTTP request.
+     * If no parameter is provided, we rely on the creation date of the latest provided created bookmark.
+     * If the datastore is empty or no bookmark is provided, we use the current date.
+     *
+     * @param string        $type           month/week/day
+     * @param string|null   $requestedDate  Input string extracted from the request
+     * @param Bookmark|null $latestBookmark Latest bookmark found in the datastore (by date)
+     *
+     * @return \DateTimeImmutable from input or latest bookmark.
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function extractRequestedDateTime(
+        string $type,
+        ?string $requestedDate,
+        Bookmark $latestBookmark = null
+    ): \DateTimeImmutable {
+        $format = static::getFormatByType($type);
+        if (empty($requestedDate)) {
+            return $latestBookmark instanceof Bookmark
+                ? new \DateTimeImmutable($latestBookmark->getCreated()->format(\DateTime::ATOM))
+                : new \DateTimeImmutable()
+            ;
+        }
+
+        // W is not supported by createFromFormat...
+        if ($type === static::WEEK) {
+            return (new \DateTimeImmutable())
+                ->setISODate((int) substr($requestedDate, 0, 4), (int) substr($requestedDate, 4, 2))
+            ;
+        }
+
+        return \DateTimeImmutable::createFromFormat($format, $requestedDate);
+    }
+
+    /**
+     * Get the DateTime format used by provided type
+     * Examples:
+     *   - day: 20201016 (<year><month><day>)
+     *   - week: 202041 (<year><week number>)
+     *   - month: 202010 (<year><month>)
+     *
+     * @param string $type month/week/day
+     *
+     * @return string DateTime compatible format
+     *
+     * @see https://www.php.net/manual/en/datetime.format.php
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getFormatByType(string $type): string
+    {
+        switch ($type) {
+            case static::MONTH:
+                return 'Ym';
+            case static::WEEK:
+                return 'YW';
+            case static::DAY:
+                return 'Ymd';
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get the first DateTime of the time period depending on given datetime and type.
+     * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
+     *       and we don't want to alter original datetime.
+     *
+     * @param string             $type      month/week/day
+     * @param \DateTimeImmutable $requested DateTime extracted from request input
+     *                                      (should come from extractRequestedDateTime)
+     *
+     * @return \DateTimeInterface First DateTime of the time period
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getStartDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
+    {
+        switch ($type) {
+            case static::MONTH:
+                return $requested->modify('first day of this month midnight');
+            case static::WEEK:
+                return $requested->modify('Monday this week midnight');
+            case static::DAY:
+                return $requested->modify('Today midnight');
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get the last DateTime of the time period depending on given datetime and type.
+     * Note: DateTimeImmutable is required because we rely heavily on DateTime->modify() syntax
+     *       and we don't want to alter original datetime.
+     *
+     * @param string             $type      month/week/day
+     * @param \DateTimeImmutable $requested DateTime extracted from request input
+     *                                      (should come from extractRequestedDateTime)
+     *
+     * @return \DateTimeInterface Last DateTime of the time period
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getEndDateTimeByType(string $type, \DateTimeImmutable $requested): \DateTimeInterface
+    {
+        switch ($type) {
+            case static::MONTH:
+                return $requested->modify('last day of this month 23:59:59');
+            case static::WEEK:
+                return $requested->modify('Sunday this week 23:59:59');
+            case static::DAY:
+                return $requested->modify('Today 23:59:59');
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get localized description of the time period depending on given datetime and type.
+     * Example: for a month period, it returns `October, 2020`.
+     *
+     * @param string             $type      month/week/day
+     * @param \DateTimeImmutable $requested DateTime extracted from request input
+     *                                      (should come from extractRequestedDateTime)
+     *
+     * @return string Localized time period description
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getDescriptionByType(string $type, \DateTimeImmutable $requested): string
+    {
+        switch ($type) {
+            case static::MONTH:
+                return $requested->format('F') . ', ' . $requested->format('Y');
+            case static::WEEK:
+                $requested = $requested->modify('Monday this week');
+                return t('Week') . ' ' . $requested->format('W') . ' (' . format_date($requested, false) . ')';
+            case static::DAY:
+                $out = '';
+                if ($requested->format('Ymd') === date('Ymd')) {
+                    $out = t('Today') . ' - ';
+                } elseif ($requested->format('Ymd') === date('Ymd', strtotime('-1 days'))) {
+                    $out = t('Yesterday') . ' - ';
+                }
+                return $out . format_date($requested, false);
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+
+    /**
+     * Get the number of items to display in the RSS feed depending on the given type.
+     *
+     * @param string $type month/week/day
+     *
+     * @return int number of elements
+     *
+     * @throws \Exception Type not supported.
+     */
+    public static function getRssLengthByType(string $type): int
+    {
+        switch ($type) {
+            case static::MONTH:
+                return 12; // 1 year
+            case static::WEEK:
+                return 26; // ~6 months
+            case static::DAY:
+                return 30; // ~1 month
+            default:
+                throw new \Exception('Unsupported daily format type');
+        }
+    }
+}
similarity index 57%
rename from application/FileUtils.php
rename to application/helper/FileUtils.php
index 30560bfc3a929a272a7932c893a7212f5d598da1..2eac079306dfbf6c99f1bee2e666b8a866f467dd 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Shaarli;
+namespace Shaarli\Helper;
 
 use Shaarli\Exceptions\IOException;
 
@@ -81,4 +81,60 @@ class FileUtils
             )
         );
     }
+
+    /**
+     * Recursively deletes a folder content, and deletes itself optionally.
+     * If an excluded file is found, folders won't be deleted.
+     *
+     * Additional security: raise an exception if it tries to delete a folder outside of Shaarli directory.
+     *
+     * @param string $path
+     * @param bool $selfDelete Delete the provided folder if true, only its content if false.
+     * @param array $exclude
+     */
+    public static function clearFolder(string $path, bool $selfDelete, array $exclude = []): bool
+    {
+        $skipped = false;
+
+        if (!is_dir($path)) {
+            throw new IOException(t('Provided path is not a directory.'));
+        }
+
+        if (!static::isPathInShaarliFolder($path)) {
+            throw new IOException(t('Trying to delete a folder outside of Shaarli path.'));
+        }
+
+        foreach (new \DirectoryIterator($path) as $file) {
+            if($file->isDot()) {
+                continue;
+            }
+
+            if (in_array($file->getBasename(), $exclude, true)) {
+                $skipped = true;
+                continue;
+            }
+
+            if ($file->isFile()) {
+                unlink($file->getPathname());
+            } elseif($file->isDir()) {
+                $skipped = static::clearFolder($file->getRealPath(), true, $exclude) || $skipped;
+            }
+        }
+
+        if ($selfDelete && !$skipped) {
+            rmdir($path);
+        }
+
+        return $skipped;
+    }
+
+    /**
+     * Checks that the given path is inside Shaarli directory.
+     */
+    public static function isPathInShaarliFolder(string $path): bool
+    {
+        $rootDirectory = dirname(dirname(dirname(__FILE__)));
+
+        return strpos(realpath($path), $rootDirectory) !== false;
+    }
 }
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
new file mode 100644 (file)
index 0000000..646a526
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+/**
+ * Class HttpAccess
+ *
+ * This is mostly an OOP wrapper for HTTP functions defined in `HttpUtils`.
+ * It is used as dependency injection in Shaarli's container.
+ *
+ * @package Shaarli\Http
+ */
+class HttpAccess
+{
+    public function getHttpResponse(
+        $url,
+        $timeout = 30,
+        $maxBytes = 4194304,
+        $curlHeaderFunction = null,
+        $curlWriteFunction = null
+    ) {
+        return get_http_response($url, $timeout, $maxBytes, $curlHeaderFunction, $curlWriteFunction);
+    }
+
+    public function getCurlDownloadCallback(
+        &$charset,
+        &$title,
+        &$description,
+        &$keywords,
+        $retrieveDescription
+    ) {
+        return get_curl_download_callback(
+            $charset,
+            $title,
+            $description,
+            $keywords,
+            $retrieveDescription
+        );
+    }
+
+    public function getCurlHeaderCallback(&$charset, $curlGetInfo = 'curl_getinfo')
+    {
+        return get_curl_header_callback($charset, $curlGetInfo);
+    }
+}
index 2ea9195d3550bfd4bfd912ef35cb4b669a5c83c2..28c129696b45b303c2cb21d29227f600551540d4 100644 (file)
@@ -6,12 +6,14 @@ use Shaarli\Http\Url;
  * GET an HTTP URL to retrieve its content
  * Uses the cURL library or a fallback method
  *
- * @param string          $url               URL to get (http://...)
- * @param int             $timeout           network timeout (in seconds)
- * @param int             $maxBytes          maximum downloaded bytes (default: 4 MiB)
- * @param callable|string $curlWriteFunction Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
- *                                           Can be used to add download conditions on the
- *                                           headers (response code, content type, etc.).
+ * @param string          $url                URL to get (http://...)
+ * @param int             $timeout            network timeout (in seconds)
+ * @param int             $maxBytes           maximum downloaded bytes (default: 4 MiB)
+ * @param callable|string $curlHeaderFunction Optional callback called during the download of headers
+ *                                            (CURLOPT_HEADERFUNCTION)
+ * @param callable|string $curlWriteFunction  Optional callback called during the download (cURL CURLOPT_WRITEFUNCTION).
+ *                                            Can be used to add download conditions on the
+ *                                            headers (response code, content type, etc.).
  *
  * @return array HTTP response headers, downloaded content
  *
@@ -35,8 +37,13 @@ use Shaarli\Http\Url;
  * @see http://stackoverflow.com/q/9183178
  * @see http://stackoverflow.com/q/1462720
  */
-function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteFunction = null)
-{
+function get_http_response(
+    $url,
+    $timeout = 30,
+    $maxBytes = 4194304,
+    $curlHeaderFunction = null,
+    $curlWriteFunction = null
+) {
     $urlObj = new Url($url);
     $cleanUrl = $urlObj->idnToAscii();
 
@@ -70,7 +77,8 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
     // General cURL settings
     curl_setopt($ch, CURLOPT_AUTOREFERER, true);
     curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
-    curl_setopt($ch, CURLOPT_HEADER, true);
+    // Default header download if the $curlHeaderFunction is not defined
+    curl_setopt($ch, CURLOPT_HEADER, !is_callable($curlHeaderFunction));
     curl_setopt(
         $ch,
         CURLOPT_HTTPHEADER,
@@ -81,25 +89,21 @@ function get_http_response($url, $timeout = 30, $maxBytes = 4194304, $curlWriteF
     curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
     curl_setopt($ch, CURLOPT_USERAGENT, $userAgent);
 
-    if (is_callable($curlWriteFunction)) {
-        curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
-    }
-
     // Max download size management
     curl_setopt($ch, CURLOPT_BUFFERSIZE, 1024*16);
     curl_setopt($ch, CURLOPT_NOPROGRESS, false);
+    if (is_callable($curlHeaderFunction)) {
+        curl_setopt($ch, CURLOPT_HEADERFUNCTION, $curlHeaderFunction);
+    }
+    if (is_callable($curlWriteFunction)) {
+        curl_setopt($ch, CURLOPT_WRITEFUNCTION, $curlWriteFunction);
+    }
     curl_setopt(
         $ch,
         CURLOPT_PROGRESSFUNCTION,
-        function ($arg0, $arg1, $arg2, $arg3, $arg4 = 0) use ($maxBytes) {
-            if (version_compare(phpversion(), '5.5', '<')) {
-                // PHP version lower than 5.5
-                // Callback has 4 arguments
-                $downloaded = $arg1;
-            } else {
-                // Callback has 5 arguments
-                $downloaded = $arg2;
-            }
+        function ($arg0, $arg1, $arg2, $arg3, $arg4) use ($maxBytes) {
+            $downloaded = $arg2;
+
             // Non-zero return stops downloading
             return ($downloaded > $maxBytes) ? 1 : 0;
         }
@@ -369,7 +373,11 @@ function server_url($server)
  */
 function index_url($server)
 {
-    $scriptname = $server['SCRIPT_NAME'];
+    if (defined('SHAARLI_ROOT_URL') && null !== SHAARLI_ROOT_URL) {
+        return rtrim(SHAARLI_ROOT_URL, '/') . '/';
+    }
+
+    $scriptname = !empty($server['SCRIPT_NAME']) ? $server['SCRIPT_NAME'] : '/';
     if (endsWith($scriptname, 'index.php')) {
         $scriptname = substr($scriptname, 0, -9);
     }
@@ -377,7 +385,7 @@ function index_url($server)
 }
 
 /**
- * Returns the absolute URL of the current script, with the query
+ * Returns the absolute URL of the current script, with current route and query
  *
  * If the resource is "index.php", then it is removed (for better-looking URLs)
  *
@@ -387,10 +395,17 @@ function index_url($server)
  */
 function page_url($server)
 {
+    $scriptname = $server['SCRIPT_NAME'] ?? '';
+    if (endsWith($scriptname, 'index.php')) {
+        $scriptname = substr($scriptname, 0, -9);
+    }
+
+    $route = preg_replace('@^' . $scriptname . '@', '', $server['REQUEST_URI'] ?? '');
     if (! empty($server['QUERY_STRING'])) {
-        return index_url($server).'?'.$server['QUERY_STRING'];
+        return index_url($server) . $route . '?' . $server['QUERY_STRING'];
     }
-    return index_url($server);
+
+    return index_url($server) . $route;
 }
 
 /**
@@ -477,3 +492,132 @@ function is_https($server)
 
     return ! empty($server['HTTPS']);
 }
+
+/**
+ * Get cURL callback function for CURLOPT_WRITEFUNCTION
+ *
+ * @param string $charset     to extract from the downloaded page (reference)
+ * @param string $curlGetInfo Optionally overrides curl_getinfo function
+ *
+ * @return Closure
+ */
+function get_curl_header_callback(
+    &$charset,
+    $curlGetInfo = 'curl_getinfo'
+) {
+    $isRedirected = false;
+
+    return function ($ch, $data) use ($curlGetInfo, &$charset, &$isRedirected) {
+        $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
+        $chunkLength = strlen($data);
+        if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
+            $isRedirected = true;
+            return $chunkLength;
+        }
+        if (!empty($responseCode) && $responseCode !== 200) {
+            return false;
+        }
+        // After a redirection, the content type will keep the previous request value
+        // until it finds the next content-type header.
+        if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
+            $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
+        }
+        if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
+            return false;
+        }
+        if (!empty($contentType) && empty($charset)) {
+            $charset = header_extract_charset($contentType);
+        }
+
+        return $chunkLength;
+    };
+}
+
+/**
+ * Get cURL callback function for CURLOPT_WRITEFUNCTION
+ *
+ * @param string $charset     to extract from the downloaded page (reference)
+ * @param string $title       to extract from the downloaded page (reference)
+ * @param string $description to extract from the downloaded page (reference)
+ * @param string $keywords    to extract from the downloaded page (reference)
+ * @param bool   $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
+ * @param string $curlGetInfo Optionally overrides curl_getinfo function
+ *
+ * @return Closure
+ */
+function get_curl_download_callback(
+    &$charset,
+    &$title,
+    &$description,
+    &$keywords,
+    $retrieveDescription
+) {
+    $currentChunk = 0;
+    $foundChunk = null;
+
+    /**
+     * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
+     *
+     * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
+     * Then we extract the title and the charset and stop the download when it's done.
+     *
+     * @param resource $ch   cURL resource
+     * @param string   $data chunk of data being downloaded
+     *
+     * @return int|bool length of $data or false if we need to stop the download
+     */
+    return function ($ch, $data) use (
+        $retrieveDescription,
+        &$charset,
+        &$title,
+        &$description,
+        &$keywords,
+        &$currentChunk,
+        &$foundChunk
+    ) {
+        $chunkLength = strlen($data);
+        $currentChunk++;
+
+        if (empty($charset)) {
+            $charset = html_extract_charset($data);
+        }
+        if (empty($title)) {
+            $title = html_extract_title($data);
+            $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
+        }
+        if (empty($title)) {
+            $title = html_extract_tag('title', $data);
+            $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
+        }
+        if ($retrieveDescription && empty($description)) {
+            $description = html_extract_tag('description', $data);
+            $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
+        }
+        if ($retrieveDescription && empty($keywords)) {
+            $keywords = html_extract_tag('keywords', $data);
+            if (! empty($keywords)) {
+                $foundChunk = $currentChunk;
+                // Keywords use the format tag1, tag2 multiple words, tag
+                // So we format them to match Shaarli's separator and glue multiple words with '-'
+                $keywords = implode(' ', array_map(function($keyword) {
+                    return implode('-', preg_split('/\s+/', trim($keyword)));
+                }, explode(',', $keywords)));
+            }
+        }
+
+        // We got everything we want, stop the download.
+        // If we already found either the title, description or keywords,
+        // it's highly unlikely that we'll found the other metas further than
+        // in the same chunk of data or the next one. So we also stop the download after that.
+        if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
+            && (! $retrieveDescription
+                || $foundChunk < $currentChunk
+                || (!empty($title) && !empty($description) && !empty($keywords))
+            )
+        ) {
+            return false;
+        }
+
+        return $chunkLength;
+    };
+}
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
new file mode 100644 (file)
index 0000000..ba9bd40
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * HTTP Tool used to extract metadata from external URL (title, description, etc.).
+ */
+class MetadataRetriever
+{
+    /** @var ConfigManager */
+    protected $conf;
+
+    /** @var HttpAccess */
+    protected $httpAccess;
+
+    public function __construct(ConfigManager $conf, HttpAccess $httpAccess)
+    {
+        $this->conf = $conf;
+        $this->httpAccess = $httpAccess;
+    }
+
+    /**
+     * Retrieve metadata for given URL.
+     *
+     * @return array [
+     *                  'title' => <remote title>,
+     *                  'description' => <remote description>,
+     *                  'tags' => <remote keywords>,
+     *               ]
+     */
+    public function retrieve(string $url): array
+    {
+        $charset = null;
+        $title = null;
+        $description = null;
+        $tags = null;
+        $retrieveDescription = $this->conf->get('general.retrieve_description');
+
+        // Short timeout to keep the application responsive
+        // The callback will fill $charset and $title with data from the downloaded page.
+        $this->httpAccess->getHttpResponse(
+            $url,
+            $this->conf->get('general.download_timeout', 30),
+            $this->conf->get('general.download_max_size', 4194304),
+            $this->httpAccess->getCurlHeaderCallback($charset),
+            $this->httpAccess->getCurlDownloadCallback(
+                $charset,
+                $title,
+                $description,
+                $tags,
+                $retrieveDescription
+            )
+        );
+
+        if (!empty($title) && strtolower($charset) !== 'utf-8') {
+            $title = mb_convert_encoding($title, 'utf-8', $charset);
+        }
+
+        return [
+            'title' => $title,
+            'description' => $description,
+            'tags' => $tags,
+        ];
+    }
+}
diff --git a/application/legacy/LegacyController.php b/application/legacy/LegacyController.php
new file mode 100644 (file)
index 0000000..826604e
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Legacy;
+
+use Shaarli\Feed\FeedBuilder;
+use Shaarli\Front\Controller\Visitor\ShaarliVisitorController;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * We use this to maintain legacy routes, and redirect requests to the corresponding Slim route.
+ * Only public routes, and both `?addlink` and `?post` were kept here.
+ * Other routes will just display the linklist.
+ *
+ * @deprecated
+ */
+class LegacyController extends ShaarliVisitorController
+{
+    /** @var string[] Both `?post` and `?addlink` do not use `?do=` format. */
+    public const LEGACY_GET_ROUTES = [
+        'post',
+        'addlink',
+    ];
+
+    /**
+     * This method will call `$action` method, which will redirect to corresponding Slim route.
+     */
+    public function process(Request $request, Response $response, string $action): Response
+    {
+        if (!method_exists($this, $action)) {
+            throw new UnknowLegacyRouteException();
+        }
+
+        return $this->{$action}($request, $response);
+    }
+
+    /** Legacy route: ?post= */
+    public function post(Request $request, Response $response): Response
+    {
+        $route = '/admin/shaare';
+        $buildParameters = function (?array $parameters, bool $encode) {
+            if ($encode) {
+                $parameters = array_map('urlencode', $parameters);
+            }
+
+            return count($parameters) > 0 ? '?' . http_build_query($parameters) : '';
+        };
+
+
+        if (!$this->container->loginManager->isLoggedIn()) {
+            $parameters = $buildParameters($request->getQueryParams(), true);
+            return $this->redirect($response, '/login?returnurl='. $this->getBasePath() . $route . $parameters);
+        }
+
+        $parameters = $buildParameters($request->getQueryParams(), false);
+
+        return $this->redirect($response, $route . $parameters);
+    }
+
+    /** Legacy route: ?addlink= */
+    protected function addlink(Request $request, Response $response): Response
+    {
+        $route = '/admin/add-shaare';
+
+        if (!$this->container->loginManager->isLoggedIn()) {
+            return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route);
+        }
+
+        return $this->redirect($response, $route);
+    }
+
+    /** Legacy route: ?do=login */
+    protected function login(Request $request, Response $response): Response
+    {
+        $returnUrl = $request->getQueryParam('returnurl');
+
+        return $this->redirect($response, '/login' . ($returnUrl ? '?returnurl=' . $returnUrl : ''));
+    }
+
+    /** Legacy route: ?do=logout */
+    protected function logout(Request $request, Response $response): Response
+    {
+        return $this->redirect($response, '/admin/logout');
+    }
+
+    /** Legacy route: ?do=picwall */
+    protected function picwall(Request $request, Response $response): Response
+    {
+        return $this->redirect($response, '/picture-wall');
+    }
+
+    /** Legacy route: ?do=tagcloud */
+    protected function tagcloud(Request $request, Response $response): Response
+    {
+        return $this->redirect($response, '/tags/cloud');
+    }
+
+    /** Legacy route: ?do=taglist */
+    protected function taglist(Request $request, Response $response): Response
+    {
+        return $this->redirect($response, '/tags/list');
+    }
+
+    /** Legacy route: ?do=daily */
+    protected function daily(Request $request, Response $response): Response
+    {
+        $dayParam = !empty($request->getParam('day')) ? '?day=' . escape($request->getParam('day')) : '';
+
+        return $this->redirect($response, '/daily' . $dayParam);
+    }
+
+    /** Legacy route: ?do=rss */
+    protected function rss(Request $request, Response $response): Response
+    {
+        return $this->feed($request, $response, FeedBuilder::$FEED_RSS);
+    }
+
+    /** Legacy route: ?do=atom */
+    protected function atom(Request $request, Response $response): Response
+    {
+        return $this->feed($request, $response, FeedBuilder::$FEED_ATOM);
+    }
+
+    /** Legacy route: ?do=opensearch */
+    protected function opensearch(Request $request, Response $response): Response
+    {
+        return $this->redirect($response, '/open-search');
+    }
+
+    /** Legacy route: ?do=dailyrss */
+    protected function dailyrss(Request $request, Response $response): Response
+    {
+        return $this->redirect($response, '/daily-rss');
+    }
+
+    /** Legacy route: ?do=feed */
+    protected function feed(Request $request, Response $response, string $feedType): Response
+    {
+        $parameters = count($request->getQueryParams()) > 0 ? '?' . http_build_query($request->getQueryParams()) : '';
+
+        return $this->redirect($response, '/feed/' . $feedType . $parameters);
+    }
+
+    /** Legacy route: ?do=configure */
+    protected function configure(Request $request, Response $response): Response
+    {
+        $route = '/admin/configure';
+
+        if (!$this->container->loginManager->isLoggedIn()) {
+            return $this->redirect($response, '/login?returnurl=' . $this->getBasePath() . $route);
+        }
+
+        return $this->redirect($response, $route);
+    }
+
+    protected function getBasePath(): string
+    {
+        return $this->container->basePath ?: '';
+    }
+}
index 7ccf5e54a9c4cb9dfb70974a99197a545ef2c92a..5c02a21b48a69222bee6a3543f36f918b95b402e 100644 (file)
@@ -8,7 +8,8 @@ use DateTime;
 use Iterator;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Exceptions\IOException;
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
+use Shaarli\Render\PageCacheManager;
 
 /**
  * Data storage for bookmarks.
@@ -352,7 +353,8 @@ You use the community supported version of the original Shaarli project, by Seba
 
         $this->write();
 
-        invalidateCaches($pageCacheDir);
+        $pageCacheManager = new PageCacheManager($pageCacheDir, $this->loggedIn);
+        $pageCacheManager->invalidateCaches();
     }
 
     /**
diff --git a/application/legacy/LegacyRouter.php b/application/legacy/LegacyRouter.php
new file mode 100644 (file)
index 0000000..0449c7e
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+namespace Shaarli\Legacy;
+
+/**
+ * Class Router
+ *
+ * (only displayable pages here)
+ *
+ * @deprecated
+ */
+class LegacyRouter
+{
+    public static $AJAX_THUMB_UPDATE = 'ajax_thumb_update';
+
+    public static $PAGE_LOGIN = 'login';
+
+    public static $PAGE_PICWALL = 'picwall';
+
+    public static $PAGE_TAGCLOUD = 'tag.cloud';
+
+    public static $PAGE_TAGLIST = 'tag.list';
+
+    public static $PAGE_DAILY = 'daily';
+
+    public static $PAGE_FEED_ATOM = 'feed.atom';
+
+    public static $PAGE_FEED_RSS = 'feed.rss';
+
+    public static $PAGE_TOOLS = 'tools';
+
+    public static $PAGE_CHANGEPASSWORD = 'changepasswd';
+
+    public static $PAGE_CONFIGURE = 'configure';
+
+    public static $PAGE_CHANGETAG = 'changetag';
+
+    public static $PAGE_ADDLINK = 'addlink';
+
+    public static $PAGE_EDITLINK = 'editlink';
+
+    public static $PAGE_DELETELINK = 'delete_link';
+
+    public static $PAGE_CHANGE_VISIBILITY = 'change_visibility';
+
+    public static $PAGE_PINLINK = 'pin';
+
+    public static $PAGE_EXPORT = 'export';
+
+    public static $PAGE_IMPORT = 'import';
+
+    public static $PAGE_OPENSEARCH = 'opensearch';
+
+    public static $PAGE_LINKLIST = 'linklist';
+
+    public static $PAGE_PLUGINSADMIN = 'pluginadmin';
+
+    public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
+
+    public static $PAGE_THUMBS_UPDATE = 'thumbs_update';
+
+    public static $GET_TOKEN = 'token';
+}
index 3a5de79f871c0159a8beaca22d82a6593f02a08d..fe1a286fdb02bbcc0d559210b867f7b9602a3d0a 100644 (file)
@@ -7,16 +7,16 @@ use RainTPL;
 use ReflectionClass;
 use ReflectionException;
 use ReflectionMethod;
-use Shaarli\ApplicationUtils;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkArray;
-use Shaarli\Bookmark\LinkDB;
 use Shaarli\Bookmark\BookmarkFilter;
 use Shaarli\Bookmark\BookmarkIO;
+use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigJson;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Config\ConfigPhp;
 use Shaarli\Exceptions\IOException;
+use Shaarli\Helper\ApplicationUtils;
 use Shaarli\Thumbnailer;
 use Shaarli\Updater\Exception\UpdaterException;
 
@@ -534,7 +534,8 @@ class LegacyUpdater
 
         if ($thumbnailsEnabled) {
             $this->session['warnings'][] = t(
-                'You have enabled or changed thumbnails mode. <a href="?do=thumbs_update">Please synchronize them</a>.'
+                t('You have enabled or changed thumbnails mode.') .
+                '<a href="./admin/thumbnails">' . t('Please synchronize them.') . '</a>'
             );
         }
 
diff --git a/application/legacy/UnknowLegacyRouteException.php b/application/legacy/UnknowLegacyRouteException.php
new file mode 100644 (file)
index 0000000..ae1518a
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Legacy;
+
+class UnknowLegacyRouteException extends \Exception
+{
+}
index d64eef7f802d05a3685a7a9f294287a58a0298b6..b83f16f8eb8e49895bddaac648d1046c25a083c7 100644 (file)
@@ -6,6 +6,7 @@ use DateTime;
 use DateTimeZone;
 use Exception;
 use Katzgrau\KLogger\Logger;
+use Psr\Http\Message\UploadedFileInterface;
 use Psr\Log\LogLevel;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkServiceInterface;
@@ -16,10 +17,24 @@ use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser;
 
 /**
  * Utilities to import and export bookmarks using the Netscape format
- * TODO: Not static, use a container.
  */
 class NetscapeBookmarkUtils
 {
+    /** @var BookmarkServiceInterface */
+    protected $bookmarkService;
+
+    /** @var ConfigManager */
+    protected $conf;
+
+    /** @var History */
+    protected $history;
+
+    public function __construct(BookmarkServiceInterface $bookmarkService, ConfigManager $conf, History $history)
+    {
+        $this->bookmarkService = $bookmarkService;
+        $this->conf = $conf;
+        $this->history = $history;
+    }
 
     /**
      * Filters bookmarks and adds Netscape-formatted fields
@@ -28,18 +43,16 @@ class NetscapeBookmarkUtils
      * - timestamp  link addition date, using the Unix epoch format
      * - taglist    comma-separated tag list
      *
-     * @param BookmarkServiceInterface $bookmarkService Link datastore
      * @param BookmarkFormatter        $formatter       instance
      * @param string                   $selection       Which bookmarks to export: (all|private|public)
      * @param bool                     $prependNoteUrl  Prepend note permalinks with the server's URL
      * @param string                   $indexUrl        Absolute URL of the Shaarli index page
      *
      * @return array The bookmarks to be exported, with additional fields
-     *@throws Exception Invalid export selection
      *
+     * @throws Exception Invalid export selection
      */
-    public static function filterAndFormat(
-        $bookmarkService,
+    public function filterAndFormat(
         $formatter,
         $selection,
         $prependNoteUrl,
@@ -51,11 +64,11 @@ class NetscapeBookmarkUtils
         }
 
         $bookmarkLinks = array();
-        foreach ($bookmarkService->search([], $selection) as $bookmark) {
+        foreach ($this->bookmarkService->search([], $selection) as $bookmark) {
             $link = $formatter->format($bookmark);
             $link['taglist'] = implode(',', $bookmark->getTags());
             if ($bookmark->isNote() && $prependNoteUrl) {
-                $link['url'] = $indexUrl . $link['url'];
+                $link['url'] = rtrim($indexUrl, '/') . '/' . ltrim($link['url'], '/');
             }
 
             $bookmarkLinks[] = $link;
@@ -64,61 +77,23 @@ class NetscapeBookmarkUtils
         return $bookmarkLinks;
     }
 
-    /**
-     * Generates an import status summary
-     *
-     * @param string $filename       name of the file to import
-     * @param int    $filesize       size of the file to import
-     * @param int    $importCount    how many bookmarks were imported
-     * @param int    $overwriteCount how many bookmarks were overwritten
-     * @param int    $skipCount      how many bookmarks were skipped
-     * @param int    $duration       how many seconds did the import take
-     *
-     * @return string Summary of the bookmark import status
-     */
-    private static function importStatus(
-        $filename,
-        $filesize,
-        $importCount = 0,
-        $overwriteCount = 0,
-        $skipCount = 0,
-        $duration = 0
-    ) {
-        $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
-        if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
-            $status .= t('has an unknown file format. Nothing was imported.');
-        } else {
-            $status .= vsprintf(
-                t(
-                    'was successfully processed in %d seconds: '
-                    . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
-                ),
-                [$duration, $importCount, $overwriteCount, $skipCount]
-            );
-        }
-        return $status;
-    }
-
     /**
      * Imports Web bookmarks from an uploaded Netscape bookmark dump
      *
-     * @param array                    $post            Server $_POST parameters
-     * @param array                    $files           Server $_FILES parameters
-     * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance
-     * @param ConfigManager            $conf            instance
-     * @param History                  $history         History instance
+     * @param array                 $post Server $_POST parameters
+     * @param UploadedFileInterface $file File in PSR-7 object format
      *
      * @return string Summary of the bookmark import status
      */
-    public static function import($post, $files, $bookmarkService, $conf, $history)
+    public function import($post, UploadedFileInterface $file)
     {
         $start = time();
-        $filename = $files['filetoupload']['name'];
-        $filesize = $files['filetoupload']['size'];
-        $data = file_get_contents($files['filetoupload']['tmp_name']);
+        $filename = $file->getClientFilename();
+        $filesize = $file->getSize();
+        $data = (string) $file->getStream();
 
         if (preg_match('/<!DOCTYPE NETSCAPE-Bookmark-file-1>/i', $data) === 0) {
-            return self::importStatus($filename, $filesize);
+            return $this->importStatus($filename, $filesize);
         }
 
         // Overwrite existing bookmarks?
@@ -141,11 +116,11 @@ class NetscapeBookmarkUtils
             true,                           // nested tag support
             $defaultTags,                   // additional user-specified tags
             strval(1 - $defaultPrivacy),    // defaultPub = 1 - defaultPrivacy
-            $conf->get('resource.data_dir') // log path, will be overridden
+            $this->conf->get('resource.data_dir') // log path, will be overridden
         );
         $logger = new Logger(
-            $conf->get('resource.data_dir'),
-            !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
+            $this->conf->get('resource.data_dir'),
+            !$this->conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
             [
                 'prefix' => 'import.',
                 'extension' => 'log',
@@ -171,7 +146,7 @@ class NetscapeBookmarkUtils
                 $private = 0;
             }
 
-            $link = $bookmarkService->findByUrl($bkm['uri']);
+            $link = $this->bookmarkService->findByUrl($bkm['uri']);
             $existingLink = $link !== null;
             if (! $existingLink) {
                 $link = new Bookmark();
@@ -193,20 +168,21 @@ class NetscapeBookmarkUtils
             }
 
             $link->setTitle($bkm['title']);
-            $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols'));
+            $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
             $link->setDescription($bkm['note']);
             $link->setPrivate($private);
             $link->setTagsString($bkm['tags']);
 
-            $bookmarkService->addOrSet($link, false);
+            $this->bookmarkService->addOrSet($link, false);
             $importCount++;
         }
 
-        $bookmarkService->save();
-        $history->importLinks();
+        $this->bookmarkService->save();
+        $this->history->importLinks();
 
         $duration = time() - $start;
-        return self::importStatus(
+
+        return $this->importStatus(
             $filename,
             $filesize,
             $importCount,
@@ -215,4 +191,39 @@ class NetscapeBookmarkUtils
             $duration
         );
     }
+
+    /**
+     * Generates an import status summary
+     *
+     * @param string $filename       name of the file to import
+     * @param int    $filesize       size of the file to import
+     * @param int    $importCount    how many bookmarks were imported
+     * @param int    $overwriteCount how many bookmarks were overwritten
+     * @param int    $skipCount      how many bookmarks were skipped
+     * @param int    $duration       how many seconds did the import take
+     *
+     * @return string Summary of the bookmark import status
+     */
+    protected function importStatus(
+        $filename,
+        $filesize,
+        $importCount = 0,
+        $overwriteCount = 0,
+        $skipCount = 0,
+        $duration = 0
+    ) {
+        $status = sprintf(t('File %s (%d bytes) '), $filename, $filesize);
+        if ($importCount == 0 && $overwriteCount == 0 && $skipCount == 0) {
+            $status .= t('has an unknown file format. Nothing was imported.');
+        } else {
+            $status .= vsprintf(
+                t(
+                    'was successfully processed in %d seconds: '
+                    . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.'
+                ),
+                [$duration, $importCount, $overwriteCount, $skipCount]
+            );
+        }
+        return $status;
+    }
 }
index f7b24a8e88c979031873ee1a8a59fe88503c4bf5..da66dea3952ad3cb0086a4594858f392f3270ba0 100644 (file)
@@ -16,7 +16,7 @@ class PluginManager
      *
      * @var array $authorizedPlugins
      */
-    private $authorizedPlugins;
+    private $authorizedPlugins = [];
 
     /**
      * List of loaded plugins.
@@ -100,21 +100,36 @@ class PluginManager
      */
     public function executeHooks($hook, &$data, $params = array())
     {
-        if (!empty($params['target'])) {
-            $data['_PAGE_'] = $params['target'];
-        }
-
-        if (isset($params['loggedin'])) {
-            $data['_LOGGEDIN_'] = $params['loggedin'];
+        $metadataParameters = [
+            'target' => '_PAGE_',
+            'loggedin' => '_LOGGEDIN_',
+            'basePath' => '_BASE_PATH_',
+            'rootPath' => '_ROOT_PATH_',
+            'bookmarkService' => '_BOOKMARK_SERVICE_',
+        ];
+
+        foreach ($metadataParameters as $parameter => $metaKey) {
+            if (array_key_exists($parameter, $params)) {
+                $data[$metaKey] = $params[$parameter];
+            }
         }
 
         foreach ($this->loadedPlugins as $plugin) {
             $hookFunction = $this->buildHookName($hook, $plugin);
 
             if (function_exists($hookFunction)) {
-                $data = call_user_func($hookFunction, $data, $this->conf);
+                try {
+                    $data = call_user_func($hookFunction, $data, $this->conf);
+                } catch (\Throwable $e) {
+                    $error = $plugin . t(' [plugin incompatibility]: ') . $e->getMessage();
+                    $this->errors = array_unique(array_merge($this->errors, [$error]));
+                }
             }
         }
+
+        foreach ($metadataParameters as $metaKey) {
+            unset($data[$metaKey]);
+        }
     }
 
     /**
index f4fefda84f448297374fc1b5e47ad159f44c1bae..c2fae7052f71116579b136433f7eeb110989db55 100644 (file)
@@ -3,10 +3,12 @@
 namespace Shaarli\Render;
 
 use Exception;
+use Psr\Log\LoggerInterface;
 use RainTPL;
-use Shaarli\ApplicationUtils;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\Helper\ApplicationUtils;
+use Shaarli\Security\SessionManager;
 use Shaarli\Thumbnailer;
 
 /**
@@ -33,6 +35,9 @@ class PageBuilder
      */
     protected $session;
 
+    /** @var LoggerInterface */
+    protected $logger;
+
     /**
      * @var BookmarkServiceInterface $bookmarkService instance.
      */
@@ -52,22 +57,39 @@ class PageBuilder
      * PageBuilder constructor.
      * $tpl is initialized at false for lazy loading.
      *
-     * @param ConfigManager            $conf    Configuration Manager instance (reference).
-     * @param array                    $session $_SESSION array
-     * @param BookmarkServiceInterface $linkDB  instance.
-     * @param string                   $token   Session token
-     * @param bool                     $isLoggedIn
+     * @param ConfigManager $conf Configuration Manager instance (reference).
+     * @param array $session $_SESSION array
+     * @param LoggerInterface $logger
+     * @param null $linkDB instance.
+     * @param null $token Session token
+     * @param bool $isLoggedIn
      */
-    public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false)
-    {
+    public function __construct(
+        ConfigManager &$conf,
+        array $session,
+        LoggerInterface $logger,
+        $linkDB = null,
+        $token = null,
+        $isLoggedIn = false
+    ) {
         $this->tpl = false;
         $this->conf = $conf;
         $this->session = $session;
+        $this->logger = $logger;
         $this->bookmarkService = $linkDB;
         $this->token = $token;
         $this->isLoggedIn = $isLoggedIn;
     }
 
+    /**
+     * Reset current state of template rendering.
+     * Mostly useful for error handling. We remove everything, and display the error template.
+     */
+    public function reset(): void
+    {
+        $this->tpl = false;
+    }
+
     /**
      * Initialize all default tpl tags.
      */
@@ -87,7 +109,7 @@ class PageBuilder
             $this->tpl->assign('newVersion', escape($version));
             $this->tpl->assign('versionError', '');
         } catch (Exception $exc) {
-            logm($this->conf->get('resource.log'), $_SERVER['REMOTE_ADDR'], $exc->getMessage());
+            $this->logger->error(format_log('Error: ' . $exc->getMessage(), client_ip_id($_SERVER)));
             $this->tpl->assign('newVersion', '');
             $this->tpl->assign('versionError', escape($exc->getMessage()));
         }
@@ -126,7 +148,7 @@ class PageBuilder
         $this->tpl->assign('language', $this->conf->get('translation.language'));
 
         if ($this->bookmarkService !== null) {
-            $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag());
+            $this->tpl->assign('tags', escape($this->bookmarkService->bookmarksCountPerTag()));
         }
 
         $this->tpl->assign(
@@ -136,17 +158,44 @@ class PageBuilder
         $this->tpl->assign('thumbnails_width', $this->conf->get('thumbnails.width'));
         $this->tpl->assign('thumbnails_height', $this->conf->get('thumbnails.height'));
 
-        if (!empty($_SESSION['warnings'])) {
-            $this->tpl->assign('global_warnings', $_SESSION['warnings']);
-            unset($_SESSION['warnings']);
-        }
-
         $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
 
+        $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
+
         // To be removed with a proper theme configuration.
         $this->tpl->assign('conf', $this->conf);
     }
 
+    /**
+     * Affect variable after controller processing.
+     * Used for alert messages.
+     */
+    protected function finalize(string $basePath): void
+    {
+        // TODO: use the SessionManager
+        $messageKeys = [
+            SessionManager::KEY_SUCCESS_MESSAGES,
+            SessionManager::KEY_WARNING_MESSAGES,
+            SessionManager::KEY_ERROR_MESSAGES
+        ];
+        foreach ($messageKeys as $messageKey) {
+            if (!empty($_SESSION[$messageKey])) {
+                $this->tpl->assign('global_' . $messageKey, $_SESSION[$messageKey]);
+                unset($_SESSION[$messageKey]);
+            }
+        }
+
+        $rootPath = preg_replace('#/index\.php$#', '', $basePath);
+        $this->assign('base_path', $basePath);
+        $this->assign('root_path', $rootPath);
+        $this->assign(
+            'asset_path',
+            $rootPath . '/' .
+            rtrim($this->conf->get('resource.raintpl_tpl', 'tpl'), '/') . '/' .
+            $this->conf->get('resource.theme', 'default')
+        );
+    }
+
     /**
      * The following assign() method is basically the same as RainTPL (except lazy loading)
      *
@@ -184,21 +233,6 @@ class PageBuilder
         return true;
     }
 
-    /**
-     * Render a specific page (using a template file).
-     * e.g. $pb->renderPage('picwall');
-     *
-     * @param string $page Template filename (without extension).
-     */
-    public function renderPage($page)
-    {
-        if ($this->tpl === false) {
-            $this->initialize();
-        }
-
-        $this->tpl->draw($page);
-    }
-
     /**
      * Render a specific page as string (using a template file).
      * e.g. $pb->render('picwall');
@@ -207,28 +241,14 @@ class PageBuilder
      *
      * @return string Processed template content
      */
-    public function render(string $page): string
+    public function render(string $page, string $basePath): string
     {
         if ($this->tpl === false) {
             $this->initialize();
         }
 
-        return $this->tpl->draw($page, true);
-    }
+        $this->finalize($basePath);
 
-    /**
-     * Render a 404 page (uses the template : tpl/404.tpl)
-     * usage: $PAGE->render404('The link was deleted')
-     *
-     * @param string $message A message to display what is not found
-     */
-    public function render404($message = '')
-    {
-        if (empty($message)) {
-            $message = t('The page you are trying to reach does not exist or has been deleted.');
-        }
-        header($_SERVER['SERVER_PROTOCOL'] . ' ' . t('404 Not Found'));
-        $this->tpl->assign('error_message', $message);
-        $this->renderPage('404');
+        return $this->tpl->draw($page, true);
     }
 }
diff --git a/application/render/PageCacheManager.php b/application/render/PageCacheManager.php
new file mode 100644 (file)
index 0000000..97805c3
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace Shaarli\Render;
+
+use Shaarli\Feed\CachedPage;
+
+/**
+ * Cache utilities
+ */
+class PageCacheManager
+{
+    /** @var string Cache directory */
+    protected $pageCacheDir;
+
+    /** @var bool */
+    protected $isLoggedIn;
+
+    public function __construct(string $pageCacheDir, bool $isLoggedIn)
+    {
+        $this->pageCacheDir = $pageCacheDir;
+        $this->isLoggedIn = $isLoggedIn;
+    }
+
+    /**
+     * Purges all cached pages
+     *
+     * @return string|null an error string if the directory is missing
+     */
+    public function purgeCachedPages(): ?string
+    {
+        if (!is_dir($this->pageCacheDir)) {
+            $error = sprintf(t('Cannot purge %s: no directory'), $this->pageCacheDir);
+            error_log($error);
+
+            return $error;
+        }
+
+        array_map('unlink', glob($this->pageCacheDir . '/*.cache'));
+
+        return null;
+    }
+
+    /**
+     * Invalidates caches when the database is changed or the user logs out.
+     */
+    public function invalidateCaches(): void
+    {
+        // Purge page cache shared by sessions.
+        $this->purgeCachedPages();
+    }
+
+    public function getCachePage(string $pageUrl): CachedPage
+    {
+        return new CachedPage(
+            $this->pageCacheDir,
+            $pageUrl,
+            false === $this->isLoggedIn
+        );
+    }
+}
diff --git a/application/render/TemplatePage.php b/application/render/TemplatePage.php
new file mode 100644 (file)
index 0000000..03b424f
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Render;
+
+interface TemplatePage
+{
+    public const ERROR_404 = '404';
+    public const ADDLINK = 'addlink';
+    public const CHANGE_PASSWORD = 'changepassword';
+    public const CHANGE_TAG = 'changetag';
+    public const CONFIGURE = 'configure';
+    public const DAILY = 'daily';
+    public const DAILY_RSS = 'dailyrss';
+    public const EDIT_LINK = 'editlink';
+    public const EDIT_LINK_BATCH = 'editlink.batch';
+    public const ERROR = 'error';
+    public const EXPORT = 'export';
+    public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
+    public const FEED_ATOM = 'feed.atom';
+    public const FEED_RSS = 'feed.rss';
+    public const IMPORT = 'import';
+    public const INSTALL = 'install';
+    public const LINKLIST = 'linklist';
+    public const LOGIN = 'loginform';
+    public const OPEN_SEARCH = 'opensearch';
+    public const PICTURE_WALL = 'picwall';
+    public const PLUGINS_ADMIN = 'pluginsadmin';
+    public const TAG_CLOUD = 'tag.cloud';
+    public const TAG_LIST = 'tag.list';
+    public const THUMBNAILS = 'thumbnails';
+    public const TOOLS = 'tools';
+}
index 68190c54ffd6da311382b24f5cd78af9229a9f67..288cbde05fd2e77009b6622b8669729d02903ba6 100644 (file)
@@ -3,7 +3,8 @@
 
 namespace Shaarli\Security;
 
-use Shaarli\FileUtils;
+use Psr\Log\LoggerInterface;
+use Shaarli\Helper\FileUtils;
 
 /**
  * Class BanManager
@@ -28,8 +29,8 @@ class BanManager
     /** @var string Path to the file containing IP bans and failures */
     protected $banFile;
 
-    /** @var string Path to the log file, used to log bans */
-    protected $logFile;
+    /** @var LoggerInterface Path to the log file, used to log bans */
+    protected $logger;
 
     /** @var array List of IP with their associated number of failed attempts */
     protected $failures = [];
@@ -40,18 +41,19 @@ class BanManager
     /**
      * BanManager constructor.
      *
-     * @param array  $trustedProxies List of allowed proxies IP
-     * @param int    $nbAttempts     Number of allowed failed attempt before the ban
-     * @param int    $banDuration    Ban duration in seconds
-     * @param string $banFile        Path to the file containing IP bans and failures
-     * @param string $logFile        Path to the log file, used to log bans
+     * @param array           $trustedProxies List of allowed proxies IP
+     * @param int             $nbAttempts     Number of allowed failed attempt before the ban
+     * @param int             $banDuration    Ban duration in seconds
+     * @param string          $banFile        Path to the file containing IP bans and failures
+     * @param LoggerInterface $logger         PSR-3 logger to save login attempts in log directory
      */
-    public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, $logFile) {
+    public function __construct($trustedProxies, $nbAttempts, $banDuration, $banFile, LoggerInterface $logger) {
         $this->trustedProxies = $trustedProxies;
         $this->nbAttempts = $nbAttempts;
         $this->banDuration = $banDuration;
         $this->banFile = $banFile;
-        $this->logFile = $logFile;
+        $this->logger = $logger;
+
         $this->readBanFile();
     }
 
@@ -78,11 +80,7 @@ class BanManager
 
         if ($this->failures[$ip] >= $this->nbAttempts) {
             $this->bans[$ip] = time() + $this->banDuration;
-            logm(
-                $this->logFile,
-                $server['REMOTE_ADDR'],
-                'IP address banned from login: '. $ip
-            );
+            $this->logger->info(format_log('IP address banned from login: '. $ip, $ip));
         }
         $this->writeBanFile();
     }
@@ -138,7 +136,7 @@ class BanManager
             unset($this->failures[$ip]);
         }
         unset($this->bans[$ip]);
-        logm($this->logFile, $server['REMOTE_ADDR'], 'Ban lifted for: '. $ip);
+        $this->logger->info(format_log('Ban lifted for: '. $ip, $ip));
 
         $this->writeBanFile();
         return false;
diff --git a/application/security/CookieManager.php b/application/security/CookieManager.php
new file mode 100644 (file)
index 0000000..cde4746
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Security;
+
+class CookieManager
+{
+    /** @var string Name of the cookie set after logging in **/
+    public const STAY_SIGNED_IN = 'shaarli_staySignedIn';
+
+    /** @var mixed $_COOKIE set by reference */
+    protected $cookies;
+
+    public function __construct(array &$cookies)
+    {
+        $this->cookies = $cookies;
+    }
+
+    public function setCookieParameter(string $key, string $value, int $expires, string $path): self
+    {
+        $this->cookies[$key] = $value;
+
+        setcookie($key, $value, $expires, $path);
+
+        return $this;
+    }
+
+    public function getCookieParameter(string $key, string $default = null): ?string
+    {
+        return $this->cookies[$key] ?? $default;
+    }
+}
index 39ec9b2e7fffa92688ab29dbc3e2a551a9b5967b..426e785e1a5431328d0818d49ec6d00d037eb0cd 100644 (file)
@@ -2,6 +2,7 @@
 namespace Shaarli\Security;
 
 use Exception;
+use Psr\Log\LoggerInterface;
 use Shaarli\Config\ConfigManager;
 
 /**
@@ -9,9 +10,6 @@ use Shaarli\Config\ConfigManager;
  */
 class LoginManager
 {
-    /** @var string Name of the cookie set after logging in **/
-    public static $STAY_SIGNED_IN_COOKIE = 'shaarli_staySignedIn';
-
     /** @var array A reference to the $_GLOBALS array */
     protected $globals = [];
 
@@ -32,24 +30,32 @@ class LoginManager
 
     /** @var string User sign-in token depending on remote IP and credentials */
     protected $staySignedInToken = '';
+    /** @var CookieManager */
+    protected $cookieManager;
+    /** @var LoggerInterface */
+    protected $logger;
 
     /**
      * Constructor
      *
-     * @param ConfigManager  $configManager  Configuration Manager instance
+     * @param ConfigManager $configManager Configuration Manager instance
      * @param SessionManager $sessionManager SessionManager instance
+     * @param CookieManager $cookieManager CookieManager instance
+     * @param BanManager $banManager
+     * @param LoggerInterface $logger Used to log login attempts
      */
-    public function __construct($configManager, $sessionManager)
-    {
+    public function __construct(
+        ConfigManager $configManager,
+        SessionManager $sessionManager,
+        CookieManager $cookieManager,
+        BanManager $banManager,
+        LoggerInterface $logger
+    ) {
         $this->configManager = $configManager;
         $this->sessionManager = $sessionManager;
-        $this->banManager = new BanManager(
-            $this->configManager->get('security.trusted_proxies', []),
-            $this->configManager->get('security.ban_after'),
-            $this->configManager->get('security.ban_duration'),
-            $this->configManager->get('resource.ban_file', 'data/ipbans.php'),
-            $this->configManager->get('resource.log')
-        );
+        $this->cookieManager = $cookieManager;
+        $this->banManager = $banManager;
+        $this->logger = $logger;
 
         if ($this->configManager->get('security.open_shaarli') === true) {
             $this->openShaarli = true;
@@ -86,10 +92,9 @@ class LoginManager
     /**
      * Check user session state and validity (expiration)
      *
-     * @param array  $cookie     The $_COOKIE array
      * @param string $clientIpId Client IP address identifier
      */
-    public function checkLoginState($cookie, $clientIpId)
+    public function checkLoginState($clientIpId)
     {
         if (! $this->configManager->exists('credentials.login')) {
             // Shaarli is not configured yet
@@ -97,9 +102,7 @@ class LoginManager
             return;
         }
 
-        if (isset($cookie[self::$STAY_SIGNED_IN_COOKIE])
-            && $cookie[self::$STAY_SIGNED_IN_COOKIE] === $this->staySignedInToken
-        ) {
+        if ($this->staySignedInToken === $this->cookieManager->getCookieParameter(CookieManager::STAY_SIGNED_IN)) {
             // The user client has a valid stay-signed-in cookie
             // Session information is updated with the current client information
             $this->sessionManager->storeLoginInfo($clientIpId);
@@ -120,7 +123,7 @@ class LoginManager
      *
      * @return true when the user is logged in, false otherwise
      */
-    public function isLoggedIn()
+    public function isLoggedIn(): bool
     {
         if ($this->openShaarli) {
             return true;
@@ -131,48 +134,34 @@ class LoginManager
     /**
      * Check user credentials are valid
      *
-     * @param string $remoteIp   Remote client IP address
      * @param string $clientIpId Client IP address identifier
      * @param string $login      Username
      * @param string $password   Password
      *
      * @return bool true if the provided credentials are valid, false otherwise
      */
-    public function checkCredentials($remoteIp, $clientIpId, $login, $password)
+    public function checkCredentials($clientIpId, $login, $password)
     {
-        // Check login matches config
-        if ($login !== $this->configManager->get('credentials.login')) {
-            return false;
-        }
-
         // Check credentials
         try {
             $useLdapLogin = !empty($this->configManager->get('ldap.host'));
-            if ((false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
-                || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
+            if ($login === $this->configManager->get('credentials.login')
+                && (
+                    (false === $useLdapLogin && $this->checkCredentialsFromLocalConfig($login, $password))
+                    || (true === $useLdapLogin && $this->checkCredentialsFromLdap($login, $password))
+                )
             ) {
-                    $this->sessionManager->storeLoginInfo($clientIpId);
-                    logm(
-                        $this->configManager->get('resource.log'),
-                        $remoteIp,
-                        'Login successful'
-                    );
-                    return true;
+                $this->sessionManager->storeLoginInfo($clientIpId);
+                $this->logger->info(format_log('Login successful', $clientIpId));
+
+                return true;
             }
-        }
-        catch(Exception $exception) {
-            logm(
-                $this->configManager->get('resource.log'),
-                $remoteIp,
-                'Exception while checking credentials: ' . $exception
-            );
+        } catch(Exception $exception) {
+            $this->logger->info(format_log('Exception while checking credentials: ' . $exception, $clientIpId));
         }
 
-        logm(
-            $this->configManager->get('resource.log'),
-            $remoteIp,
-            'Login failed for user ' . $login
-        );
+        $this->logger->info(format_log('Login failed for user ' . $login, $clientIpId));
+
         return false;
     }
 
index 994fcbe52ceb30cd4086369c46c89b9d66832260..96bf193c1040debe1e14003c1f3eaab7deba71bf 100644 (file)
@@ -8,6 +8,14 @@ use Shaarli\Config\ConfigManager;
  */
 class SessionManager
 {
+    public const KEY_LINKS_PER_PAGE = 'LINKS_PER_PAGE';
+    public const KEY_VISIBILITY = 'visibility';
+    public const KEY_UNTAGGED_ONLY = 'untaggedonly';
+
+    public const KEY_SUCCESS_MESSAGES = 'successes';
+    public const KEY_WARNING_MESSAGES = 'warnings';
+    public const KEY_ERROR_MESSAGES = 'errors';
+
     /** @var int Session expiration timeout, in seconds */
     public static $SHORT_TIMEOUT = 3600;    // 1 hour
 
@@ -23,16 +31,35 @@ class SessionManager
     /** @var bool Whether the user should stay signed in (LONG_TIMEOUT) */
     protected $staySignedIn = false;
 
+    /** @var string */
+    protected $savePath;
+
     /**
      * Constructor
      *
-     * @param array         $session The $_SESSION array (reference)
-     * @param ConfigManager $conf    ConfigManager instance
+     * @param array         $session  The $_SESSION array (reference)
+     * @param ConfigManager $conf     ConfigManager instance
+     * @param string        $savePath Session save path returned by builtin function session_save_path()
      */
-    public function __construct(& $session, $conf)
+    public function __construct(&$session, $conf, string $savePath)
     {
         $this->session = &$session;
         $this->conf = $conf;
+        $this->savePath = $savePath;
+    }
+
+    /**
+     * Initialize XSRF token and links per page session variables.
+     */
+    public function initialize(): void
+    {
+        if (!isset($this->session['tokens'])) {
+            $this->session['tokens'] = [];
+        }
+
+        if (!isset($this->session['LINKS_PER_PAGE'])) {
+            $this->session['LINKS_PER_PAGE'] = $this->conf->get('general.links_per_page', 20);
+        }
     }
 
     /**
@@ -156,7 +183,6 @@ class SessionManager
             unset($this->session['expires_on']);
             unset($this->session['username']);
             unset($this->session['visibility']);
-            unset($this->session['untaggedonly']);
         }
     }
 
@@ -202,4 +228,81 @@ class SessionManager
     {
         return $this->session;
     }
+
+    /**
+     * @param mixed $default value which will be returned if the $key is undefined
+     *
+     * @return mixed Content stored in session
+     */
+    public function getSessionParameter(string $key, $default = null)
+    {
+        return $this->session[$key] ?? $default;
+    }
+
+    /**
+     * Store a variable in user session.
+     *
+     * @param string $key   Session key
+     * @param mixed  $value Session value to store
+     *
+     * @return $this
+     */
+    public function setSessionParameter(string $key, $value): self
+    {
+        $this->session[$key] = $value;
+
+        return $this;
+    }
+
+    /**
+     * Store a variable in user session.
+     *
+     * @param string $key   Session key
+     *
+     * @return $this
+     */
+    public function deleteSessionParameter(string $key): self
+    {
+        unset($this->session[$key]);
+
+        return $this;
+    }
+
+    public function getSavePath(): string
+    {
+        return $this->savePath;
+    }
+
+    /*
+     * Next public functions wrapping native PHP session API.
+     */
+
+    public function destroy(): bool
+    {
+        $this->session = [];
+
+        return session_destroy();
+    }
+
+    public function start(): bool
+    {
+        if (session_status() === PHP_SESSION_ACTIVE) {
+            $this->destroy();
+        }
+
+        return session_start();
+    }
+
+    /**
+     * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
+     */
+    public function cookieParameters(int $lifeTime, string $path, string $domain): void
+    {
+        session_set_cookie_params($lifeTime, $path, $domain);
+    }
+
+    public function regenerateId(bool $deleteOldSession = false): bool
+    {
+        return session_regenerate_id($deleteOldSession);
+    }
 }
index 95654d81da96c0d23c2d26424c47983b39c25961..88a7bc7b27337a0c572647f0d2ac1ef3027939db 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace Shaarli\Updater;
 
-use Shaarli\Config\ConfigManager;
 use Shaarli\Bookmark\BookmarkServiceInterface;
+use Shaarli\Config\ConfigManager;
 use Shaarli\Updater\Exception\UpdaterException;
 
 /**
@@ -21,7 +21,7 @@ class Updater
     /**
      * @var BookmarkServiceInterface instance.
      */
-    protected $linkServices;
+    protected $bookmarkService;
 
     /**
      * @var ConfigManager $conf Configuration Manager instance.
@@ -38,6 +38,11 @@ class Updater
      */
     protected $methods;
 
+    /**
+     * @var string $basePath Shaarli root directory (from HTTP Request)
+     */
+    protected $basePath = null;
+
     /**
      * Object constructor.
      *
@@ -49,7 +54,7 @@ class Updater
     public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn)
     {
         $this->doneUpdates = $doneUpdates;
-        $this->linkServices = $linkDB;
+        $this->bookmarkService = $linkDB;
         $this->conf = $conf;
         $this->isLoggedIn = $isLoggedIn;
 
@@ -62,13 +67,15 @@ class Updater
      * Run all new updates.
      * Update methods have to start with 'updateMethod' and return true (on success).
      *
+     * @param string $basePath Shaarli root directory (from HTTP Request)
+     *
      * @return array An array containing ran updates.
      *
      * @throws UpdaterException If something went wrong.
      */
-    public function update()
+    public function update(string $basePath = null)
     {
-        $updatesRan = array();
+        $updatesRan = [];
 
         // If the user isn't logged in, exit without updating.
         if ($this->isLoggedIn !== true) {
@@ -111,4 +118,62 @@ class Updater
     {
         return $this->doneUpdates;
     }
+
+    public function readUpdates(string $updatesFilepath): array
+    {
+        return UpdaterUtils::read_updates_file($updatesFilepath);
+    }
+
+    public function writeUpdates(string $updatesFilepath, array $updates): void
+    {
+        UpdaterUtils::write_updates_file($updatesFilepath, $updates);
+    }
+
+    /**
+     * With the Slim routing system, default header link should be `/subfolder/` instead of `?`.
+     * Otherwise you can not go back to the home page.
+     * Example: `/subfolder/picture-wall` -> `/subfolder/picture-wall?` instead of `/subfolder/`.
+     */
+    public function updateMethodRelativeHomeLink(): bool
+    {
+        if ('?' === trim($this->conf->get('general.header_link'))) {
+            $this->conf->set('general.header_link', $this->basePath . '/', true, true);
+        }
+
+        return true;
+    }
+
+    /**
+     * With the Slim routing system, note bookmarks URL formatted `?abcdef`
+     * should be replaced with `/shaare/abcdef`
+     */
+    public function updateMethodMigrateExistingNotesUrl(): bool
+    {
+        $updated = false;
+
+        foreach ($this->bookmarkService->search() as $bookmark) {
+            if ($bookmark->isNote()
+                && startsWith($bookmark->getUrl(), '?')
+                && 1 === preg_match('/^\?([a-zA-Z0-9-_@]{6})($|&|#)/', $bookmark->getUrl(), $match)
+            ) {
+                $updated = true;
+                $bookmark = $bookmark->setUrl('/shaare/' . $match[1]);
+
+                $this->bookmarkService->set($bookmark, false);
+            }
+        }
+
+        if ($updated) {
+            $this->bookmarkService->save();
+        }
+
+        return true;
+    }
+
+    public function setBasePath(string $basePath): self
+    {
+        $this->basePath = $basePath;
+
+        return $this;
+    }
 }
diff --git a/assets/common/js/metadata.js b/assets/common/js/metadata.js
new file mode 100644 (file)
index 0000000..d5a28a3
--- /dev/null
@@ -0,0 +1,107 @@
+import he from 'he';
+
+/**
+ * This script is used to retrieve bookmarks metadata asynchronously:
+ *    - title, description and keywords while creating a new bookmark
+ *    - thumbnails while visiting the bookmark list
+ *
+ * Note: it should only be included if the user is logged in
+ *       and the setting general.enable_async_metadata is enabled.
+ */
+
+/**
+ * Removes given input loaders - used in edit link template.
+ *
+ * @param {object} loaders List of input DOM element that need to be cleared
+ */
+function clearLoaders(loaders) {
+  if (loaders != null && loaders.length > 0) {
+    [...loaders].forEach((loader) => {
+      loader.classList.remove('loading-input');
+    });
+  }
+}
+
+/**
+ * AJAX request to update the thumbnail of a bookmark with the provided ID.
+ * If a thumbnail is retrieved, it updates the divElement with the image src, and displays it.
+ *
+ * @param {string} basePath   Shaarli subfolder for XHR requests
+ * @param {object} divElement Main <div> DOM element containing the thumbnail placeholder
+ * @param {int}    id         Bookmark ID to update
+ */
+function updateThumb(basePath, divElement, id) {
+  const xhr = new XMLHttpRequest();
+  xhr.open('PATCH', `${basePath}/admin/shaare/${id}/update-thumbnail`);
+  xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+  xhr.responseType = 'json';
+  xhr.onload = () => {
+    if (xhr.status !== 200) {
+      alert(`An error occurred. Return code: ${xhr.status}`);
+    } else {
+      const { response } = xhr;
+
+      if (response.thumbnail !== false) {
+        const imgElement = divElement.querySelector('img');
+
+        imgElement.src = response.thumbnail;
+        imgElement.dataset.src = response.thumbnail;
+        imgElement.style.opacity = '1';
+        divElement.classList.remove('hidden');
+      }
+    }
+  };
+  xhr.send();
+}
+
+(() => {
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
+
+  /*
+   * METADATA FOR EDIT BOOKMARK PAGE
+   */
+  const inputTitles = document.querySelectorAll('input[name="lf_title"]');
+  if (inputTitles != null) {
+    [...inputTitles].forEach((inputTitle) => {
+      const form = inputTitle.closest('form[name="linkform"]');
+      const loaders = form.querySelectorAll('.loading-input');
+
+      if (inputTitle.value.length > 0) {
+        clearLoaders(loaders);
+        return;
+      }
+
+      const url = form.querySelector('input[name="lf_url"]').value;
+
+      const xhr = new XMLHttpRequest();
+      xhr.open('GET', `${basePath}/admin/metadata?url=${encodeURI(url)}`, true);
+      xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+      xhr.onload = () => {
+        const result = JSON.parse(xhr.response);
+        Object.keys(result).forEach((key) => {
+          if (result[key] !== null && result[key].length) {
+            const element = form.querySelector(`input[name="lf_${key}"], textarea[name="lf_${key}"]`);
+            if (element != null && element.value.length === 0) {
+              element.value = he.decode(result[key]);
+            }
+          }
+        });
+        clearLoaders(loaders);
+      };
+
+      xhr.send();
+    });
+  }
+
+  /*
+   * METADATA FOR THUMBNAIL RETRIEVAL
+   */
+  const thumbsToLoad = document.querySelectorAll('div[data-async-thumbnail]');
+  if (thumbsToLoad != null) {
+    [...thumbsToLoad].forEach((divElement) => {
+      const { id } = divElement.closest('[data-id]').dataset;
+
+      updateThumb(basePath, divElement, id);
+    });
+  }
+})();
diff --git a/assets/common/js/shaare-batch.js b/assets/common/js/shaare-batch.js
new file mode 100644 (file)
index 0000000..557325e
--- /dev/null
@@ -0,0 +1,121 @@
+const sendBookmarkForm = (basePath, formElement) => {
+  const inputs = formElement
+    .querySelectorAll('input[type="text"], textarea, input[type="checkbox"], input[type="hidden"]');
+
+  const formData = new FormData();
+  [...inputs].forEach((input) => {
+    formData.append(input.getAttribute('name'), input.value);
+  });
+
+  return new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open('POST', `${basePath}/admin/shaare`);
+    xhr.onload = () => {
+      if (xhr.status !== 200) {
+        alert(`An error occurred. Return code: ${xhr.status}`);
+        reject();
+      } else {
+        formElement.closest('.edit-link-container').remove();
+        resolve();
+      }
+    };
+    xhr.send(formData);
+  });
+};
+
+const sendBookmarkDelete = (buttonElement, formElement) => (
+  new Promise((resolve, reject) => {
+    const xhr = new XMLHttpRequest();
+    xhr.open('GET', buttonElement.href);
+    xhr.onload = () => {
+      if (xhr.status !== 200) {
+        alert(`An error occurred. Return code: ${xhr.status}`);
+        reject();
+      } else {
+        formElement.closest('.edit-link-container').remove();
+        resolve();
+      }
+    };
+    xhr.send();
+  })
+);
+
+const redirectIfEmptyBatch = (basePath, formElements, path) => {
+  if (formElements == null || formElements.length === 0) {
+    window.location.href = `${basePath}${path}`;
+  }
+};
+
+(() => {
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
+  const getForms = () => document.querySelectorAll('form[name="linkform"]');
+
+  const cancelButtons = document.querySelectorAll('[name="cancel-batch-link"]');
+  if (cancelButtons != null) {
+    [...cancelButtons].forEach((cancelButton) => {
+      cancelButton.addEventListener('click', (e) => {
+        e.preventDefault();
+        e.target.closest('form[name="linkform"]').remove();
+        redirectIfEmptyBatch(basePath, getForms(), '/admin/add-shaare');
+      });
+    });
+  }
+
+  const saveButtons = document.querySelectorAll('[name="save_edit"]');
+  if (saveButtons != null) {
+    [...saveButtons].forEach((saveButton) => {
+      saveButton.addEventListener('click', (e) => {
+        e.preventDefault();
+
+        const formElement = e.target.closest('form[name="linkform"]');
+        sendBookmarkForm(basePath, formElement)
+          .then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
+      });
+    });
+  }
+
+  const saveAllButtons = document.querySelectorAll('[name="save_edit_batch"]');
+  if (saveAllButtons != null) {
+    [...saveAllButtons].forEach((saveAllButton) => {
+      saveAllButton.addEventListener('click', (e) => {
+        e.preventDefault();
+
+        const forms = [...getForms()];
+        const nbForm = forms.length;
+        let current = 0;
+        const progressBar = document.querySelector('.progressbar > div');
+        const progressBarCurrent = document.querySelector('.progressbar-current');
+
+        document.querySelector('.dark-layer').style.display = 'block';
+        document.querySelector('.progressbar-max').innerHTML = nbForm;
+        progressBarCurrent.innerHTML = current;
+
+        const promises = [];
+        forms.forEach((formElement) => {
+          promises.push(sendBookmarkForm(basePath, formElement).then(() => {
+            current += 1;
+            progressBar.style.width = `${(current * 100) / nbForm}%`;
+            progressBarCurrent.innerHTML = current;
+          }));
+        });
+
+        Promise.all(promises).then(() => {
+          window.location.href = basePath || '/';
+        });
+      });
+    });
+  }
+
+  const deleteButtons = document.querySelectorAll('[name="delete_link"]');
+  if (deleteButtons != null) {
+    [...deleteButtons].forEach((deleteButton) => {
+      deleteButton.addEventListener('click', (e) => {
+        e.preventDefault();
+
+        const formElement = e.target.closest('form[name="linkform"]');
+        sendBookmarkDelete(e.target, formElement)
+          .then(() => redirectIfEmptyBatch(basePath, getForms(), '/'));
+      });
+    });
+  }
+})();
index b66ca3ae1e5114bc8e3d2a73abb29d8b0d389a6d..3cd4c2a761f778989e682ee181421200abe012cc 100644 (file)
  * It contains a recursive call to retrieve the thumb of the next link when it succeed.
  * It also update the progress bar and other visual feedback elements.
  *
+ * @param {string} basePath Shaarli subfolder for XHR requests
  * @param {array}  ids      List of LinkID to update
  * @param {int}    i        Current index in ids
  * @param {object} elements List of DOM element to avoid retrieving them at each iteration
  */
-function updateThumb(ids, i, elements) {
+function updateThumb(basePath, ids, i, elements) {
   const xhr = new XMLHttpRequest();
-  xhr.open('POST', '?do=ajax_thumb_update');
+  xhr.open('PATCH', `${basePath}/admin/shaare/${ids[i]}/update-thumbnail`);
   xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
   xhr.responseType = 'json';
   xhr.onload = () => {
@@ -29,17 +30,18 @@ function updateThumb(ids, i, elements) {
       elements.current.innerHTML = i;
       elements.title.innerHTML = response.title;
       if (response.thumbnail !== false) {
-        elements.thumbnail.innerHTML = `<img src="${response.thumbnail}">`;
+        elements.thumbnail.innerHTML = `<img src="${basePath}/${response.thumbnail}">`;
       }
       if (i < ids.length) {
-        updateThumb(ids, i, elements);
+        updateThumb(basePath, ids, i, elements);
       }
     }
   };
-  xhr.send(`id=${ids[i]}`);
+  xhr.send();
 }
 
 (() => {
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
   const ids = document.getElementsByName('ids')[0].value.split(',');
   const elements = {
     progressBar: document.querySelector('.progressbar > div'),
@@ -47,5 +49,5 @@ function updateThumb(ids, i, elements) {
     thumbnail: document.querySelector('.thumbnail-placeholder'),
     title: document.querySelector('.thumbnail-link-title'),
   };
-  updateThumb(ids, 0, elements);
+  updateThumb(basePath, ids, 0, elements);
 })();
index d5c29c695295ae574ec27ed5d06cf4b71bdd5f7a..4163577d0da527d3d1d7b91c3c1eb830bfda4cea 100644 (file)
@@ -1,4 +1,5 @@
 import Awesomplete from 'awesomplete';
+import he from 'he';
 
 /**
  * Find a parent element according to its tag and its attributes
@@ -10,7 +11,7 @@ import Awesomplete from 'awesomplete';
  * @returns Found element or null.
  */
 function findParent(element, tagName, attributes) {
-  const parentMatch = key => attributes[key] !== '' && element.getAttribute(key).indexOf(attributes[key]) !== -1;
+  const parentMatch = (key) => attributes[key] !== '' && element.getAttribute(key).indexOf(attributes[key]) !== -1;
   while (element) {
     if (element.tagName.toLowerCase() === tagName) {
       if (Object.keys(attributes).find(parentMatch)) {
@@ -25,12 +26,18 @@ function findParent(element, tagName, attributes) {
 /**
  * Ajax request to refresh the CSRF token.
  */
-function refreshToken() {
+function refreshToken(basePath, callback) {
   const xhr = new XMLHttpRequest();
-  xhr.open('GET', '?do=token');
+  xhr.open('GET', `${basePath}/admin/token`);
   xhr.onload = () => {
-    const token = document.getElementById('token');
-    token.setAttribute('value', xhr.responseText);
+    const elements = document.querySelectorAll('input[name="token"]');
+    [...elements].forEach((element) => {
+      element.setAttribute('value', xhr.responseText);
+    });
+
+    if (callback) {
+      callback(xhr.response);
+    }
   };
   xhr.send();
 }
@@ -89,15 +96,6 @@ function updateAwesompleteList(selector, tags, instances) {
   return instances;
 }
 
-/**
- * html_entities in JS
- *
- * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
- */
-function htmlEntities(str) {
-  return str.replace(/[\u00A0-\u9999<>&]/gim, i => `&#${i.charCodeAt(0)};`);
-}
-
 /**
  * Add the class 'hidden' to city options not attached to the current selected continent.
  *
@@ -188,8 +186,8 @@ function removeClass(element, classname) {
 function init(description) {
   function resize() {
     /* Fix jumpy resizing: https://stackoverflow.com/a/18262927/1484919 */
-    const scrollTop = window.pageYOffset ||
-      (document.documentElement || document.body.parentNode || document.body).scrollTop;
+    const scrollTop = window.pageYOffset
+      || (document.documentElement || document.body.parentNode || document.body).scrollTop;
 
     description.style.height = 'auto';
     description.style.height = `${description.scrollHeight + 10}px`;
@@ -215,6 +213,8 @@ function init(description) {
 }
 
 (() => {
+  const basePath = document.querySelector('input[name="js_base_path"]').value;
+
   /**
    * Handle responsive menu.
    * Source: http://purecss.io/layouts/tucked-menu-vertical/
@@ -294,7 +294,7 @@ function init(description) {
   const deleteLinks = document.querySelectorAll('.confirm-delete');
   [...deleteLinks].forEach((deleteLink) => {
     deleteLink.addEventListener('click', (event) => {
-      if (!confirm(document.getElementById('translation-delete-link').innerHTML)) {
+      if (!confirm(document.getElementById('translation-delete-tag').innerHTML)) {
         event.preventDefault();
       }
     });
@@ -461,7 +461,7 @@ function init(description) {
       });
 
       if (window.confirm(message)) {
-        window.location = `?delete_link&lf_linkdate=${ids.join('+')}&token=${token.value}`;
+        window.location = `${basePath}/admin/shaare/delete?id=${ids.join('+')}&token=${token.value}`;
       }
     });
   }
@@ -482,8 +482,10 @@ function init(description) {
           });
         });
 
-        const ids = links.map(item => item.id);
-        window.location = `?change_visibility&token=${token.value}&newVisibility=${visibility}&ids=${ids.join('+')}`;
+        const ids = links.map((item) => item.id);
+        window.location = (
+          `${basePath}/admin/shaare/visibility?token=${token.value}&newVisibility=${visibility}&id=${ids.join('+')}`
+        );
       });
     });
   }
@@ -545,8 +547,9 @@ function init(description) {
       }
       const refreshedToken = document.getElementById('token').value;
       const fromtag = block.getAttribute('data-tag');
+      const fromtagUrl = block.getAttribute('data-tag-url');
       const xhr = new XMLHttpRequest();
-      xhr.open('POST', '?do=changetag');
+      xhr.open('POST', `${basePath}/admin/tags`);
       xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
       xhr.onload = () => {
         if (xhr.status !== 200) {
@@ -554,20 +557,28 @@ function init(description) {
           location.reload();
         } else {
           block.setAttribute('data-tag', totag);
+          block.setAttribute('data-tag-url', encodeURIComponent(totag));
           input.setAttribute('name', totag);
           input.setAttribute('value', totag);
           findParent(input, 'div', { class: 'rename-tag-form' }).style.display = 'none';
-          block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
-          block.querySelector('a.tag-link').setAttribute('href', `?searchtags=${encodeURIComponent(totag)}`);
-          block.querySelector('a.rename-tag').setAttribute('href', `?do=changetag&fromtag=${encodeURIComponent(totag)}`);
+          block.querySelector('a.tag-link').innerHTML = he.encode(totag);
+          block
+            .querySelector('a.tag-link')
+            .setAttribute('href', `${basePath}/?searchtags=${encodeURIComponent(totag)}`);
+          block
+            .querySelector('a.count')
+            .setAttribute('href', `${basePath}/add-tag/${encodeURIComponent(totag)}`);
+          block
+            .querySelector('a.rename-tag')
+            .setAttribute('href', `${basePath}/admin/tags?fromtag=${encodeURIComponent(totag)}`);
 
           // Refresh awesomplete values
-          existingTags = existingTags.map(tag => (tag === fromtag ? totag : tag));
+          existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
           awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
         }
       };
-      xhr.send(`renametag=1&fromtag=${encodeURIComponent(fromtag)}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
-      refreshToken();
+      xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
+      refreshToken(basePath);
     });
   });
 
@@ -589,19 +600,20 @@ function init(description) {
       event.preventDefault();
       const block = findParent(event.target, 'div', { class: 'tag-list-item' });
       const tag = block.getAttribute('data-tag');
+      const tagUrl = block.getAttribute('data-tag-url');
       const refreshedToken = document.getElementById('token').value;
 
       if (confirm(`Are you sure you want to delete the tag "${tag}"?`)) {
         const xhr = new XMLHttpRequest();
-        xhr.open('POST', '?do=changetag');
+        xhr.open('POST', `${basePath}/admin/tags`);
         xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
         xhr.onload = () => {
           block.remove();
         };
-        xhr.send(encodeURI(`deletetag=1&fromtag=${tag}&token=${refreshedToken}`));
-        refreshToken();
+        xhr.send(`deletetag=1&fromtag=${tagUrl}&token=${refreshedToken}`);
+        refreshToken(basePath);
 
-        existingTags = existingTags.filter(tagItem => tagItem !== tag);
+        existingTags = existingTags.filter((tagItem) => tagItem !== tag);
         awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
       }
     });
@@ -611,4 +623,44 @@ function init(description) {
   [...autocompleteFields].forEach((autocompleteField) => {
     awesomepletes.push(createAwesompleteInstance(autocompleteField));
   });
+
+  const exportForm = document.querySelector('#exportform');
+  if (exportForm != null) {
+    exportForm.addEventListener('submit', (event) => {
+      event.preventDefault();
+
+      refreshToken(basePath, () => {
+        event.target.submit();
+      });
+    });
+  }
+
+  const bulkCreationButton = document.querySelector('.addlink-batch-show-more-block');
+  if (bulkCreationButton != null) {
+    const toggleBulkCreationVisibility = (showMoreBlockElement, formElement) => {
+      if (bulkCreationButton.classList.contains('pure-u-0')) {
+        showMoreBlockElement.classList.remove('pure-u-0');
+        formElement.classList.add('pure-u-0');
+      } else {
+        showMoreBlockElement.classList.add('pure-u-0');
+        formElement.classList.remove('pure-u-0');
+      }
+    };
+
+    const bulkCreationForm = document.querySelector('.addlink-batch-form-block');
+
+    toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    bulkCreationButton.querySelector('a').addEventListener('click', (e) => {
+      e.preventDefault();
+      toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    });
+
+    // Force to send falsy value if the checkbox is not checked.
+    const privateButton = bulkCreationForm.querySelector('input[type="checkbox"][name="private"]');
+    const privateHiddenButton = bulkCreationForm.querySelector('input[type="hidden"][name="private"]');
+    privateButton.addEventListener('click', () => {
+      privateHiddenButton.disabled = !privateHiddenButton.disabled;
+    });
+    privateHiddenButton.disabled = privateButton.checked;
+  }
 })();
index 243ab1b27b050af425af433e0e79628350586403..a7f091e95d938f8ce9c6d10202f2288867fb7390 100644 (file)
@@ -69,20 +69,22 @@ pre {
   font-family: 'Roboto';
   font-weight: 400;
   font-style: normal;
-  src: local('Roboto'),
-  local('Roboto-Regular'),
-  url('../fonts/Roboto-Regular.woff2') format('woff2'),
-  url('../fonts/Roboto-Regular.woff') format('woff');
+  src:
+    local('Roboto'),
+    local('Roboto-Regular'),
+    url('../fonts/Roboto-Regular.woff2') format('woff2'),
+    url('../fonts/Roboto-Regular.woff') format('woff');
 }
 
 @font-face {
   font-family: 'Roboto';
   font-weight: 700;
   font-style: normal;
-  src: local('Roboto'),
-  local('Roboto-Bold'),
-  url('../fonts/Roboto-Bold.woff2') format('woff2'),
-  url('../fonts/Roboto-Bold.woff') format('woff');
+  src:
+    local('Roboto'),
+    local('Roboto-Bold'),
+    url('../fonts/Roboto-Bold.woff2') format('woff2'),
+    url('../fonts/Roboto-Bold.woff') format('woff');
 }
 
 body,
@@ -375,7 +377,7 @@ body,
 }
 
 @media screen and (max-width: 64em) {
-  .header-search ,
+  .header-search,
   .header-search * {
     visibility: hidden;
   }
@@ -490,6 +492,10 @@ body,
   }
 }
 
+.header-alert-message {
+  text-align: center;
+}
+
 // CONTENT - GENERAL
 .container {
   position: relative;
@@ -550,7 +556,6 @@ body,
   color: $dark-grey;
   font-size: .9em;
 
-
   a {
     display: inline-block;
     margin: 3px 0;
@@ -612,6 +617,11 @@ body,
     padding: 5px;
     text-decoration: none;
     color: $dark-grey;
+
+    &.selected {
+      background: var(--main-color);
+      color: $white;
+    }
   }
 
   input {
@@ -661,6 +671,10 @@ body,
       content: '';
     }
   }
+
+  .search-highlight {
+    background-color: yellow;
+  }
 }
 
 .linklist-item-buttons {
@@ -1009,6 +1023,10 @@ body,
     &.button-red {
       background: $red;
     }
+
+    &.button-grey {
+      background: $light-grey;
+    }
   }
 
   .submit-buttons {
@@ -1033,7 +1051,7 @@ body,
   }
 
   table {
-    margin: auto;
+    margin: 10px auto 25px auto;
     width: 90%;
 
     .order {
@@ -1069,6 +1087,11 @@ body,
           position: absolute;
           right: 5%;
         }
+
+        &.button-grey {
+          position: absolute;
+          left: 5%;
+        }
       }
     }
   }
@@ -1259,6 +1282,57 @@ form {
   }
 }
 
+.loading-input {
+  position: relative;
+
+  @keyframes around {
+    0% {
+      transform: rotate(0deg);
+    }
+
+    100% {
+      transform: rotate(360deg);
+    }
+  }
+
+  .icon-container {
+    position: absolute;
+    right: 60px;
+    top: calc(50% - 10px);
+  }
+
+  .loader {
+    position: relative;
+    height: 20px;
+    width: 20px;
+    display: inline-block;
+    animation: around 5.4s infinite;
+
+    &::after,
+    &::before {
+      content: "";
+      background: $form-input-background;
+      position: absolute;
+      display: inline-block;
+      width: 100%;
+      height: 100%;
+      border-width: 2px;
+      border-color: #333 #333 transparent transparent;
+      border-style: solid;
+      border-radius: 20px;
+      box-sizing: border-box;
+      top: 0;
+      left: 0;
+      animation: around 0.7s ease-in-out infinite;
+    }
+
+    &::after {
+      animation: around 0.7s ease-in-out 0.1s infinite;
+      background: transparent;
+    }
+  }
+}
+
 // LOGIN
 .login-form-container {
   .remember-me {
@@ -1548,11 +1622,11 @@ form {
   text-align: center;
 
   a {
+    background: $almost-white;
     display: inline-block;
-    margin: 0 15px;
+    padding: 5px;
     text-decoration: none;
-    color: $white;
-    font-weight: bold;
+    color: $dark-grey;
   }
 }
 
@@ -1600,13 +1674,14 @@ form {
 
   > div {
     border-radius: 10px;
-    background: repeating-linear-gradient(
-      -45deg,
-      $almost-white,
-      $almost-white 6px,
-      var(--background-color) 6px,
-      var(--background-color) 12px
-    );
+    background:
+      repeating-linear-gradient(
+        -45deg,
+        $almost-white,
+        $almost-white 6px,
+        var(--background-color) 6px,
+        var(--background-color) 12px
+      );
     width: 0%;
     height: 10px;
   }
@@ -1630,6 +1705,123 @@ form {
   }
 }
 
+// SERVER PAGE
+
+.server-tables-page,
+.server-tables {
+  .window-subtitle {
+    &::before {
+      display: block;
+      margin: 8px auto;
+      background: linear-gradient(to right, var(--background-color), $dark-grey, var(--background-color));
+      width: 50%;
+      height: 1px;
+      content: '';
+    }
+  }
+
+  .server-row {
+    p {
+      height: 25px;
+      padding: 0 10px;
+    }
+  }
+
+  .server-label {
+    text-align: right;
+    font-weight: bold;
+  }
+
+  i {
+    &.fa-color-green {
+      color: $main-green;
+    }
+
+    &.fa-color-orange {
+      color: $orange;
+    }
+
+    &.fa-color-red {
+      color: $red;
+    }
+  }
+
+  @media screen and (max-width: 64em) {
+    .server-label {
+      text-align: center;
+    }
+
+    .server-row {
+      p {
+        text-align: center;
+      }
+    }
+  }
+}
+
+// Batch creation
+input[name='save_edit_batch'] {
+  @extend %page-form-button;
+}
+
+.addlink-batch-show-more {
+  display: flex;
+  align-items: center;
+  margin: 20px 0 8px;
+
+  a {
+    color: var(--main-color);
+    text-decoration: none;
+  }
+
+  &::before,
+  &::after {
+    content: "";
+    flex-grow: 1;
+    background: rgba(0, 0, 0, 0.35);
+    height: 1px;
+    font-size: 0;
+    line-height: 0;
+  }
+
+  &::before {
+    margin: 0 16px 0 0;
+  }
+
+  &::after {
+    margin: 0 0 0 16px;
+  }
+}
+
+.dark-layer {
+  display: none;
+  position: fixed;
+  height: 100%;
+  width: 100%;
+  z-index: 998;
+  background-color: rgba(0, 0, 0, .75);
+  color: #fff;
+
+  .screen-center {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    align-items: center;
+    text-align: center;
+    min-height: 100vh;
+  }
+
+  .progressbar {
+    width: 33%;
+  }
+}
+
+.addlink-batch-form-block {
+  .pure-alert {
+    margin: 25px 0 0 0;
+  }
+}
+
 // Print rules
 @media print {
   .shaarli-menu {
index 87c440c86f702958224c7c5a334802ca7f817196..1688dce07217a781c8edf6c58368d47b639a9df0 100644 (file)
@@ -746,8 +746,6 @@ a.bigbutton, #pageheader a.bigbutton {
     text-align: left;
     background-color: transparent;
     background-color: rgba(0, 0, 0, 0.4);
-    /* FF3+, Saf3+, Opera 10.10+, Chrome, IE9 */
-    filter: progid: DXImageTransform.Microsoft.gradient(startColorstr=#66000000, endColorstr=#66000000);
     /* IE6\96IE9 */
     text-shadow: 2px 2px 1px #000000;
 }
index 6b670fa21636af4c395ddf5fbd5c09b388371bb4..9449258668054f396bf86816282880b1fd83b302 100644 (file)
@@ -10,6 +10,7 @@
     },
     "keywords": ["bookmark", "link", "share", "web"],
     "config": {
+        "sort-packages": true,
         "platform": {
             "php": "7.1.29"
         }
         "php": ">=7.1",
         "ext-json": "*",
         "ext-zlib": "*",
-        "shaarli/netscape-bookmark-parser": "^2.1",
-        "erusev/parsedown": "^1.6",
-        "slim/slim": "^3.0",
         "arthurhoaro/web-thumbnailer": "^2.0",
+        "erusev/parsedown": "^1.6",
+        "erusev/parsedown-extra": "^0.8.1",
+        "gettext/gettext": "^4.4",
+        "katzgrau/klogger": "^1.2",
+        "malkusch/lock": "^2.1",
         "pubsubhubbub/publisher": "dev-master",
-        "gettext/gettext": "^4.4"
+        "shaarli/netscape-bookmark-parser": "^2.1",
+        "slim/slim": "^3.0"
     },
     "require-dev": {
         "roave/security-advisories": "dev-master",
-        "phpunit/phpcov": "*",
-        "phpunit/phpunit": "^7.5",
-        "squizlabs/php_codesniffer": "3.*"
+        "squizlabs/php_codesniffer": "3.*",
+        "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0"
     },
     "suggest": {
         "ext-curl": "Allows fetching web pages and thumbnails in a more robust way",
             "Shaarli\\Feed\\": "application/feed",
             "Shaarli\\Formatter\\": "application/formatter",
             "Shaarli\\Front\\": "application/front",
-            "Shaarli\\Front\\Controller\\": "application/front/controllers",
+            "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
+            "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
             "Shaarli\\Front\\Exception\\": "application/front/exceptions",
+            "Shaarli\\Helper\\": "application/helper",
             "Shaarli\\Http\\": "application/http",
             "Shaarli\\Legacy\\": "application/legacy",
             "Shaarli\\Netscape\\": "application/netscape",
index b3373a3263c1c895814e929ab4363b28832323a9..3c89036f832012aac2ee631897f1aa786f2fe545 100644 (file)
@@ -4,29 +4,31 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "37e420b4b6e9fa74b27e127dd422d9a6",
+    "content-hash": "61360efbb2e1ba4c4fe00ce1f7a78ec5",
     "packages": [
         {
             "name": "arthurhoaro/web-thumbnailer",
-            "version": "v2.0.1",
+            "version": "v2.0.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/ArthurHoaro/web-thumbnailer.git",
-                "reference": "4aa27a1b54b9823341fedd7ca2dcfb11a6b3186a"
+                "reference": "39bfd4f3136d9e6096496b9720e877326cfe4775"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/ArthurHoaro/web-thumbnailer/zipball/4aa27a1b54b9823341fedd7ca2dcfb11a6b3186a",
-                "reference": "4aa27a1b54b9823341fedd7ca2dcfb11a6b3186a",
+                "url": "https://api.github.com/repos/ArthurHoaro/web-thumbnailer/zipball/39bfd4f3136d9e6096496b9720e877326cfe4775",
+                "reference": "39bfd4f3136d9e6096496b9720e877326cfe4775",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1",
-                "phpunit/php-text-template": "^1.2"
+                "phpunit/php-text-template": "^1.2 || ^2.0"
             },
             "require-dev": {
+                "gskema/phpcs-type-sniff": "^0.13.1",
                 "php-coveralls/php-coveralls": "^2.0",
-                "phpunit/phpunit": "^7.0 || ^8.0",
+                "phpstan/phpstan": "^0.12.9",
+                "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
                 "squizlabs/php_codesniffer": "^3.0"
             },
             "type": "library",
                 }
             ],
             "description": "PHP library which will retrieve a thumbnail for any given URL",
-            "time": "2020-01-17T19:42:49+00:00"
+            "support": {
+                "issues": "https://github.com/ArthurHoaro/web-thumbnailer/issues",
+                "source": "https://github.com/ArthurHoaro/web-thumbnailer/tree/v2.0.3"
+            },
+            "time": "2020-09-29T15:51:03+00:00"
         },
         {
             "name": "erusev/parsedown",
                 "markdown",
                 "parser"
             ],
+            "support": {
+                "issues": "https://github.com/erusev/parsedown/issues",
+                "source": "https://github.com/erusev/parsedown/tree/1.7.x"
+            },
             "time": "2019-12-30T22:54:17+00:00"
         },
+        {
+            "name": "erusev/parsedown-extra",
+            "version": "0.8.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/erusev/parsedown-extra.git",
+                "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/erusev/parsedown-extra/zipball/91ac3ff98f0cea243bdccc688df43810f044dcef",
+                "reference": "91ac3ff98f0cea243bdccc688df43810f044dcef",
+                "shasum": ""
+            },
+            "require": {
+                "erusev/parsedown": "^1.7.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-0": {
+                    "ParsedownExtra": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Emanuil Rusev",
+                    "email": "hello@erusev.com",
+                    "homepage": "http://erusev.com"
+                }
+            ],
+            "description": "An extension of Parsedown that adds support for Markdown Extra.",
+            "homepage": "https://github.com/erusev/parsedown-extra",
+            "keywords": [
+                "markdown",
+                "markdown extra",
+                "parsedown",
+                "parser"
+            ],
+            "support": {
+                "issues": "https://github.com/erusev/parsedown-extra/issues",
+                "source": "https://github.com/erusev/parsedown-extra/tree/0.8.x"
+            },
+            "time": "2019-12-30T23:20:37+00:00"
+        },
         {
             "name": "gettext/gettext",
             "version": "v4.8.2",
                 "po",
                 "translation"
             ],
+            "support": {
+                "email": "oom@oscarotero.com",
+                "issues": "https://github.com/oscarotero/Gettext/issues",
+                "source": "https://github.com/php-gettext/Gettext/tree/v4.8.2"
+            },
             "time": "2019-12-02T10:21:14+00:00"
         },
         {
                 "translations",
                 "unicode"
             ],
+            "support": {
+                "issues": "https://github.com/php-gettext/Languages/issues",
+                "source": "https://github.com/php-gettext/Languages/tree/2.6.0"
+            },
             "time": "2019-11-13T10:30:21+00:00"
         },
         {
             "keywords": [
                 "logging"
             ],
+            "support": {
+                "issues": "https://github.com/katzgrau/KLogger/issues",
+                "source": "https://github.com/katzgrau/KLogger/tree/master"
+            },
             "time": "2016-11-07T19:29:14+00:00"
         },
+        {
+            "name": "malkusch/lock",
+            "version": "v2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-lock/lock.git",
+                "reference": "093f389ec2f38fc8686d2f70e23378182fce7714"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-lock/lock/zipball/093f389ec2f38fc8686d2f70e23378182fce7714",
+                "reference": "093f389ec2f38fc8686d2f70e23378182fce7714",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1",
+                "psr/log": "^1"
+            },
+            "require-dev": {
+                "eloquent/liberator": "^2.0",
+                "ext-memcached": "*",
+                "ext-pcntl": "*",
+                "ext-pdo_mysql": "*",
+                "ext-pdo_sqlite": "*",
+                "ext-redis": "*",
+                "ext-sysvsem": "*",
+                "johnkary/phpunit-speedtrap": "^3.0",
+                "kriswallsmith/spork": "^0.3",
+                "mikey179/vfsstream": "^1.6",
+                "php-mock/php-mock-phpunit": "^2.1",
+                "phpunit/phpunit": "^7.4",
+                "predis/predis": "^1.1",
+                "squizlabs/php_codesniffer": "^3.3"
+            },
+            "suggest": {
+                "ext-pnctl": "Enables locking with flock without busy waiting in CLI scripts.",
+                "ext-redis": "To use this library with the PHP Redis extension.",
+                "ext-sysvsem": "Enables locking using semaphores.",
+                "predis/predis": "To use this library with predis."
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "malkusch\\lock\\": "classes/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "WTFPL"
+            ],
+            "authors": [
+                {
+                    "name": "Markus Malkusch",
+                    "email": "markus@malkusch.de",
+                    "homepage": "http://markus.malkusch.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Willem Stuursma-Ruwen",
+                    "email": "willem@stuursma.name",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Mutex library for exclusive code execution.",
+            "homepage": "https://github.com/malkusch/lock",
+            "keywords": [
+                "advisory-locks",
+                "cas",
+                "flock",
+                "lock",
+                "locking",
+                "memcache",
+                "mutex",
+                "mysql",
+                "postgresql",
+                "redis",
+                "redlock",
+                "semaphore"
+            ],
+            "support": {
+                "issues": "https://github.com/php-lock/lock/issues",
+                "source": "https://github.com/php-lock/lock/tree/v2.1"
+            },
+            "time": "2018-12-12T19:53:29+00:00"
+        },
         {
             "name": "nikic/fast-route",
             "version": "v1.3.0",
                 "router",
                 "routing"
             ],
+            "support": {
+                "issues": "https://github.com/nikic/FastRoute/issues",
+                "source": "https://github.com/nikic/FastRoute/tree/master"
+            },
             "time": "2018-02-13T20:26:39+00:00"
         },
         {
             "keywords": [
                 "template"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/1.2.1"
+            },
             "time": "2015-06-21T13:50:34+00:00"
         },
         {
                 "container",
                 "dependency injection"
             ],
+            "support": {
+                "issues": "https://github.com/silexphp/Pimple/issues",
+                "source": "https://github.com/silexphp/Pimple/tree/master"
+            },
             "time": "2018-01-21T07:42:36+00:00"
         },
         {
                 "container-interop",
                 "psr"
             ],
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/master"
+            },
             "time": "2017-02-14T16:28:37+00:00"
         },
         {
                 "request",
                 "response"
             ],
+            "support": {
+                "source": "https://github.com/php-fig/http-message/tree/master"
+            },
             "time": "2016-08-06T14:39:51+00:00"
         },
         {
             "name": "psr/log",
-            "version": "1.1.2",
+            "version": "1.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/php-fig/log.git",
-                "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801"
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/log/zipball/446d54b4cb6bf489fc9d75f55843658e6f25d801",
-                "reference": "446d54b4cb6bf489fc9d75f55843658e6f25d801",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/0f73288fd15629204f9d42b7055f72dacbe811fc",
+                "reference": "0f73288fd15629204f9d42b7055f72dacbe811fc",
                 "shasum": ""
             },
             "require": {
                 "psr",
                 "psr-3"
             ],
-            "time": "2019-11-01T11:05:21+00:00"
+            "support": {
+                "source": "https://github.com/php-fig/log/tree/1.1.3"
+            },
+            "time": "2020-03-23T09:12:05+00:00"
         },
         {
             "name": "pubsubhubbub/publisher",
                 "ext-curl": "*",
                 "php": "~5.4 || ~7.0"
             },
+            "default-branch": true,
             "type": "library",
             "autoload": {
                 "psr-4": {
                 "pubsubhubbub",
                 "websub"
             ],
+            "support": {
+                "issues": "https://github.com/pubsubhubbub/php-publisher/issues",
+                "source": "https://github.com/pubsubhubbub/php-publisher/tree/master"
+            },
             "time": "2018-10-09T05:20:28+00:00"
         },
         {
             "name": "shaarli/netscape-bookmark-parser",
-            "version": "v2.1.0",
+            "version": "v2.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/shaarli/netscape-bookmark-parser.git",
-                "reference": "819008ee42c4dd7e45d988176a4a22d6ed689577"
+                "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/819008ee42c4dd7e45d988176a4a22d6ed689577",
-                "reference": "819008ee42c4dd7e45d988176a4a22d6ed689577",
+                "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/432a010af2bb1832d6fbc4763e6b0100b980a1df",
+                "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df",
                 "shasum": ""
             },
             "require": {
                 "bookmark",
                 "link",
                 "netscape",
-                "parser"
+                "parse"
             ],
-            "time": "2018-10-06T14:43:38+00:00"
+            "support": {
+                "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues",
+                "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v2.2.0"
+            },
+            "time": "2020-06-06T15:53:53+00:00"
         },
         {
             "name": "slim/slim",
                 "micro",
                 "router"
             ],
+            "support": {
+                "issues": "https://github.com/slimphp/Slim/issues",
+                "source": "https://github.com/slimphp/Slim/tree/3.x"
+            },
             "time": "2019-11-28T17:40:33+00:00"
         }
     ],
     "packages-dev": [
         {
             "name": "doctrine/instantiator",
-            "version": "1.3.0",
+            "version": "1.3.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/instantiator.git",
-                "reference": "ae466f726242e637cebdd526a7d991b9433bacf1"
+                "reference": "f350df0268e904597e3bd9c4685c53e0e333feea"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/ae466f726242e637cebdd526a7d991b9433bacf1",
-                "reference": "ae466f726242e637cebdd526a7d991b9433bacf1",
+                "url": "https://api.github.com/repos/doctrine/instantiator/zipball/f350df0268e904597e3bd9c4685c53e0e333feea",
+                "reference": "f350df0268e904597e3bd9c4685c53e0e333feea",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "require-dev": {
                 "doctrine/coding-standard": "^6.0",
                 "constructor",
                 "instantiate"
             ],
-            "time": "2019-10-21T16:45:58+00:00"
+            "support": {
+                "issues": "https://github.com/doctrine/instantiator/issues",
+                "source": "https://github.com/doctrine/instantiator/tree/1.3.x"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-05-29T17:27:14+00:00"
         },
         {
             "name": "myclabs/deep-copy",
-            "version": "1.9.5",
+            "version": "1.10.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/myclabs/DeepCopy.git",
-                "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef"
+                "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/b2c28789e80a97badd14145fda39b545d83ca3ef",
-                "reference": "b2c28789e80a97badd14145fda39b545d83ca3ef",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
+                "reference": "969b211f9a51aa1f6c01d1d2aef56d3bd91598e5",
                 "shasum": ""
             },
             "require": {
-                "php": "^7.1"
+                "php": "^7.1 || ^8.0"
             },
             "replace": {
                 "myclabs/deep-copy": "self.version"
                 "object",
                 "object graph"
             ],
-            "time": "2020-01-17T21:11:47+00:00"
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.x"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-06-29T13:22:24+00:00"
         },
         {
             "name": "phar-io/manifest",
                 }
             ],
             "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/master"
+            },
             "time": "2018-07-08T19:23:20+00:00"
         },
         {
                 }
             ],
             "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/master"
+            },
             "time": "2018-07-08T19:19:57+00:00"
         },
         {
             "name": "phpdocumentor/reflection-common",
-            "version": "2.0.0",
+            "version": "2.1.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpDocumentor/ReflectionCommon.git",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a"
+                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/63a995caa1ca9e5590304cd845c15ad6d482a62a",
-                "reference": "63a995caa1ca9e5590304cd845c15ad6d482a62a",
+                "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
+                "reference": "6568f4687e5b41b054365f9ae03fcb1ed5f2069b",
                 "shasum": ""
             },
             "require": {
                 "php": ">=7.1"
             },
-            "require-dev": {
-                "phpunit/phpunit": "~6"
-            },
             "type": "library",
             "extra": {
                 "branch-alias": {
                 "reflection",
                 "static analysis"
             ],
-            "time": "2018-08-07T13:53:10+00:00"
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/master"
+            },
+            "time": "2020-04-27T09:25:28+00:00"
         },
         {
             "name": "phpdocumentor/reflection-docblock",
                 }
             ],
             "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
+            "support": {
+                "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
+                "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/release/4.x"
+            },
             "time": "2019-12-28T18:55:12+00:00"
         },
         {
                 }
             ],
             "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
+            "support": {
+                "issues": "https://github.com/phpDocumentor/TypeResolver/issues",
+                "source": "https://github.com/phpDocumentor/TypeResolver/tree/0.7.2"
+            },
             "time": "2019-08-22T18:11:29+00:00"
         },
         {
             "name": "phpspec/prophecy",
-            "version": "1.10.1",
+            "version": "v1.10.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpspec/prophecy.git",
-                "reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc"
+                "reference": "451c3cd1418cf640de218914901e51b064abb093"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
-                "reference": "cbe1df668b3fe136bcc909126a0f529a78d4cbbc",
+                "url": "https://api.github.com/repos/phpspec/prophecy/zipball/451c3cd1418cf640de218914901e51b064abb093",
+                "reference": "451c3cd1418cf640de218914901e51b064abb093",
                 "shasum": ""
             },
             "require": {
                 "doctrine/instantiator": "^1.0.2",
                 "php": "^5.3|^7.0",
                 "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0|^5.0",
-                "sebastian/comparator": "^1.2.3|^2.0|^3.0",
-                "sebastian/recursion-context": "^1.0|^2.0|^3.0"
+                "sebastian/comparator": "^1.2.3|^2.0|^3.0|^4.0",
+                "sebastian/recursion-context": "^1.0|^2.0|^3.0|^4.0"
             },
             "require-dev": {
                 "phpspec/phpspec": "^2.5 || ^3.2",
                 "spy",
                 "stub"
             ],
-            "time": "2019-12-22T21:05:45+00:00"
+            "support": {
+                "issues": "https://github.com/phpspec/prophecy/issues",
+                "source": "https://github.com/phpspec/prophecy/tree/v1.10.3"
+            },
+            "time": "2020-03-05T15:02:03+00:00"
         },
         {
             "name": "phpunit/php-code-coverage",
                 "testing",
                 "xunit"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/master"
+            },
             "time": "2018-10-31T16:06:48+00:00"
         },
         {
                 "filesystem",
                 "iterator"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/2.0.2"
+            },
             "time": "2018-09-13T20:33:42+00:00"
         },
         {
             "keywords": [
                 "timer"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/master"
+            },
             "time": "2019-06-07T04:22:29+00:00"
         },
         {
             "keywords": [
                 "tokenizer"
             ],
-            "time": "2019-09-17T06:23:10+00:00"
-        },
-        {
-            "name": "phpunit/phpcov",
-            "version": "5.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/phpcov.git",
-                "reference": "72fb974e6fe9b39d7e0b0d44061d2ba4c49ee0b8"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/phpcov/zipball/72fb974e6fe9b39d7e0b0d44061d2ba4c49ee0b8",
-                "reference": "72fb974e6fe9b39d7e0b0d44061d2ba4c49ee0b8",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1",
-                "phpunit/php-code-coverage": "^6.0",
-                "phpunit/phpunit": "^7.0",
-                "sebastian/diff": "^3.0",
-                "sebastian/finder-facade": "^1.1",
-                "sebastian/version": "^2.0",
-                "symfony/console": "^3.0 || ^4.0"
-            },
-            "bin": [
-                "phpcov"
-            ],
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "5.0-dev"
-                }
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-token-stream/issues",
+                "source": "https://github.com/sebastianbergmann/php-token-stream/tree/3.1.1"
             },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "CLI frontend for php-code-coverage",
-            "homepage": "https://github.com/sebastianbergmann/phpcov",
-            "time": "2018-02-04T10:18:50+00:00"
+            "abandoned": true,
+            "time": "2019-09-17T06:23:10+00:00"
         },
         {
             "name": "phpunit/phpunit",
                 "testing",
                 "xunit"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/7.5.20"
+            },
             "time": "2020-01-08T08:45:45+00:00"
         },
         {
             "source": {
                 "type": "git",
                 "url": "https://github.com/Roave/SecurityAdvisories.git",
-                "reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389"
+                "reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389",
-                "reference": "67ac6ea8f4a078c3c9b7aec5d7ae70f098c37389",
+                "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ba5d234b3a1559321b816b64aafc2ce6728799ff",
+                "reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff",
                 "shasum": ""
             },
             "conflict": {
                 "alterphp/easyadmin-extension-bundle": ">=1.2,<1.2.11|>=1.3,<1.3.1",
                 "amphp/artax": "<1.0.6|>=2,<2.0.6",
                 "amphp/http": "<1.0.1",
+                "amphp/http-client": ">=4,<4.4",
                 "api-platform/core": ">=2.2,<2.2.10|>=2.3,<2.3.6",
                 "asymmetricrypt/asymmetricrypt": ">=0,<9.9.99",
                 "aws/aws-sdk-php": ">=3,<3.2.1",
+                "bagisto/bagisto": "<0.1.5",
+                "barrelstrength/sprout-base-email": "<1.2.7",
+                "barrelstrength/sprout-forms": "<3.9",
+                "baserproject/basercms": ">=4,<=4.3.6",
+                "bolt/bolt": "<3.7.1",
                 "brightlocal/phpwhois": "<=4.2.5",
+                "buddypress/buddypress": "<5.1.2",
                 "bugsnag/bugsnag-laravel": ">=2,<2.0.2",
                 "cakephp/cakephp": ">=1.3,<1.3.18|>=2,<2.4.99|>=2.5,<2.5.99|>=2.6,<2.6.12|>=2.7,<2.7.6|>=3,<3.5.18|>=3.6,<3.6.15|>=3.7,<3.7.7",
                 "cart2quote/module-quotation": ">=4.1.6,<=4.4.5|>=5,<5.4.4",
                 "cartalyst/sentry": "<=2.1.6",
+                "centreon/centreon": "<18.10.8|>=19,<19.4.5",
+                "cesnet/simplesamlphp-module-proxystatistics": "<3.1",
                 "codeigniter/framework": "<=3.0.6",
                 "composer/composer": "<=1-alpha.11",
                 "contao-components/mediaelement": ">=2.14.2,<2.21.1",
                 "contao/core": ">=2,<3.5.39",
-                "contao/core-bundle": ">=4,<4.4.46|>=4.5,<4.8.6",
+                "contao/core-bundle": ">=4,<4.4.52|>=4.5,<4.9.6|= 4.10.0",
                 "contao/listing-bundle": ">=4,<4.4.8",
                 "datadog/dd-trace": ">=0.30,<0.30.2",
                 "david-garcia/phpwhois": "<=4.3.1",
+                "derhansen/sf_event_mgt": "<4.3.1|>=5,<5.1.1",
                 "doctrine/annotations": ">=1,<1.2.7",
                 "doctrine/cache": ">=1,<1.3.2|>=1.4,<1.4.2",
                 "doctrine/common": ">=2,<2.4.3|>=2.5,<2.5.1",
                 "doctrine/mongodb-odm": ">=1,<1.0.2",
                 "doctrine/mongodb-odm-bundle": ">=2,<3.0.1",
                 "doctrine/orm": ">=2,<2.4.8|>=2.5,<2.5.1",
+                "dolibarr/dolibarr": "<11.0.4",
                 "dompdf/dompdf": ">=0.6,<0.6.2",
-                "drupal/core": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1",
-                "drupal/drupal": ">=7,<7.69|>=8,<8.7.11|>=8.8,<8.8.1",
+                "drupal/core": ">=7,<7.73|>=8,<8.8.10|>=8.9,<8.9.6|>=9,<9.0.6",
+                "drupal/drupal": ">=7,<7.73|>=8,<8.8.10|>=8.9,<8.9.6|>=9,<9.0.6",
                 "endroid/qr-code-bundle": "<3.4.2",
+                "enshrined/svg-sanitize": "<0.13.1",
                 "erusev/parsedown": "<1.7.2",
-                "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.4",
-                "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.13.1|>=6,<6.7.9.1|>=6.8,<6.13.5.1|>=7,<7.2.4.1|>=7.3,<7.3.2.1",
-                "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.12.3|>=2011,<2017.12.4.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3",
+                "ezsystems/demobundle": ">=5.4,<5.4.6.1",
+                "ezsystems/ezdemo-ls-extension": ">=5.4,<5.4.2.1",
+                "ezsystems/ezfind-ls": ">=5.3,<5.3.6.1|>=5.4,<5.4.11.1|>=2017.12,<2017.12.0.1",
+                "ezsystems/ezplatform": ">=1.7,<1.7.9.1|>=1.13,<1.13.5.1|>=2.5,<2.5.4",
+                "ezsystems/ezplatform-admin-ui": ">=1.3,<1.3.5|>=1.4,<1.4.6",
+                "ezsystems/ezplatform-admin-ui-assets": ">=4,<4.2.1|>=5,<5.0.1|>=5.1,<5.1.1",
+                "ezsystems/ezplatform-kernel": ">=1,<1.0.2.1",
+                "ezsystems/ezplatform-user": ">=1,<1.0.1",
+                "ezsystems/ezpublish-kernel": ">=5.3,<5.3.12.1|>=5.4,<5.4.14.2|>=6,<6.7.9.1|>=6.8,<6.13.6.3|>=7,<7.2.4.1|>=7.3,<7.3.2.1|>=7.5,<7.5.7.1",
+                "ezsystems/ezpublish-legacy": ">=5.3,<5.3.12.6|>=5.4,<5.4.14.2|>=2011,<2017.12.7.3|>=2018.6,<2018.6.1.4|>=2018.9,<2018.9.1.3|>=2019.3,<2019.3.5.1",
+                "ezsystems/platform-ui-assets-bundle": ">=4.2,<4.2.3",
                 "ezsystems/repository-forms": ">=2.3,<2.3.2.1",
                 "ezyang/htmlpurifier": "<4.1.1",
                 "firebase/php-jwt": "<2",
                 "fooman/tcpdf": "<6.2.22",
                 "fossar/tcpdf-parser": "<6.2.22",
+                "friendsofsymfony/oauth2-php": "<1.3",
                 "friendsofsymfony/rest-bundle": ">=1.2,<1.2.2",
                 "friendsofsymfony/user-bundle": ">=1.2,<1.3.5",
+                "friendsoftypo3/mediace": ">=7.6.2,<7.6.5",
                 "fuel/core": "<1.8.1",
+                "getgrav/grav": "<1.7-beta.8",
+                "gos/web-socket-bundle": "<1.10.4|>=2,<2.6.1|>=3,<3.3",
                 "gree/jose": "<=2.2",
                 "gregwar/rst": "<1.0.3",
                 "guzzlehttp/guzzle": ">=4-rc.2,<4.2.4|>=5,<5.3.1|>=6,<6.2.1",
                 "illuminate/auth": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.10",
-                "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30",
-                "illuminate/database": ">=4,<4.0.99|>=4.1,<4.1.29",
+                "illuminate/cookie": ">=4,<=4.0.11|>=4.1,<=4.1.99999|>=4.2,<=4.2.99999|>=5,<=5.0.99999|>=5.1,<=5.1.99999|>=5.2,<=5.2.99999|>=5.3,<=5.3.99999|>=5.4,<=5.4.99999|>=5.5,<=5.5.49|>=5.6,<=5.6.99999|>=5.7,<=5.7.99999|>=5.8,<=5.8.99999|>=6,<6.18.31|>=7,<7.22.4",
+                "illuminate/database": ">=4,<4.0.99|>=4.1,<4.1.29|>=5.5,<=5.5.44|>=6,<6.18.34|>=7,<7.23.2",
                 "illuminate/encryption": ">=4,<=4.0.11|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.40|>=5.6,<5.6.15",
+                "illuminate/view": ">=7,<7.1.2",
                 "ivankristianto/phpwhois": "<=4.3",
                 "james-heinrich/getid3": "<1.9.9",
                 "joomla/session": "<1.3.1",
                 "jsmitty12/phpwhois": "<5.1",
                 "kazist/phpwhois": "<=4.2.6",
+                "kitodo/presentation": "<3.1.2",
                 "kreait/firebase-php": ">=3.2,<3.8.1",
                 "la-haute-societe/tcpdf": "<6.2.22",
-                "laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.31|>=4.2,<=4.2.22|>=5,<=5.0.35|>=5.1,<=5.1.46|>=5.2,<=5.2.45|>=5.3,<=5.3.31|>=5.4,<=5.4.36|>=5.5,<5.5.42|>=5.6,<5.6.30",
+                "laravel/framework": ">=4,<4.0.99|>=4.1,<=4.1.99999|>=4.2,<=4.2.99999|>=5,<=5.0.99999|>=5.1,<=5.1.99999|>=5.2,<=5.2.99999|>=5.3,<=5.3.99999|>=5.4,<=5.4.99999|>=5.5,<=5.5.49|>=5.6,<=5.6.99999|>=5.7,<=5.7.99999|>=5.8,<=5.8.99999|>=6,<6.18.34|>=7,<7.23.2",
                 "laravel/socialite": ">=1,<1.0.99|>=2,<2.0.10",
                 "league/commonmark": "<0.18.3",
+                "librenms/librenms": "<1.53",
+                "livewire/livewire": ">2.2.4,<2.2.6",
+                "magento/community-edition": ">=2,<2.2.10|>=2.3,<2.3.3",
                 "magento/magento1ce": "<1.9.4.3",
                 "magento/magento1ee": ">=1,<1.14.4.3",
                 "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2",
+                "marcwillmann/turn": "<0.3.3",
+                "mittwald/typo3_forum": "<1.2.1",
                 "monolog/monolog": ">=1.8,<1.12",
                 "namshi/jose": "<2.2",
+                "nette/application": ">=2,<2.0.19|>=2.1,<2.1.13|>=2.2,<2.2.10|>=2.3,<2.3.14|>=2.4,<2.4.16|>=3,<3.0.6",
+                "nette/nette": ">=2,<2.0.19|>=2.1,<2.1.13",
+                "nystudio107/craft-seomatic": "<3.3",
+                "nzo/url-encryptor-bundle": ">=4,<4.3.2|>=5,<5.0.1",
+                "october/backend": ">=1.0.319,<1.0.467",
+                "october/cms": ">=1.0.319,<1.0.466",
+                "october/october": ">=1.0.319,<1.0.466",
+                "october/rain": ">=1.0.319,<1.0.468",
                 "onelogin/php-saml": "<2.10.4",
+                "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5",
                 "openid/php-openid": "<2.3",
+                "openmage/magento-lts": "<19.4.6|>=20,<20.0.2",
                 "oro/crm": ">=1.7,<1.7.4",
                 "oro/platform": ">=1.7,<1.7.4",
                 "padraic/humbug_get_contents": "<1.1.2",
                 "paragonie/random_compat": "<2",
                 "paypal/merchant-sdk-php": "<3.12",
                 "pear/archive_tar": "<1.4.4",
-                "phpmailer/phpmailer": ">=5,<5.2.27|>=6,<6.0.6",
-                "phpoffice/phpexcel": "<=1.8.1",
-                "phpoffice/phpspreadsheet": "<=1.5",
+                "personnummer/personnummer": "<3.0.2",
+                "phpfastcache/phpfastcache": ">=5,<5.0.13",
+                "phpmailer/phpmailer": "<6.1.6",
+                "phpmussel/phpmussel": ">=1,<1.6",
+                "phpmyadmin/phpmyadmin": "<4.9.2",
+                "phpoffice/phpexcel": "<1.8.2",
+                "phpoffice/phpspreadsheet": "<1.8",
                 "phpunit/phpunit": ">=4.8.19,<4.8.28|>=5.0.10,<5.6.3",
                 "phpwhois/phpwhois": "<=4.2.5",
                 "phpxmlrpc/extras": "<0.6.1",
+                "pimcore/pimcore": "<6.3",
+                "prestashop/autoupgrade": ">=4,<4.10.1",
+                "prestashop/contactform": ">1.0.1,<4.3",
+                "prestashop/gamification": "<2.3.2",
+                "prestashop/ps_facetedsearch": "<3.4.1",
+                "privatebin/privatebin": "<1.2.2|>=1.3,<1.3.2",
                 "propel/propel": ">=2-alpha.1,<=2-alpha.7",
                 "propel/propel1": ">=1,<=1.7.1",
+                "pterodactyl/panel": "<0.7.19|>=1-rc.0,<=1-rc.6",
                 "pusher/pusher-php-server": "<2.2.1",
-                "robrichards/xmlseclibs": ">=1,<3.0.4",
+                "rainlab/debugbar-plugin": "<3.1",
+                "robrichards/xmlseclibs": "<3.0.4",
+                "sabberworm/php-css-parser": ">=1,<1.0.1|>=2,<2.0.1|>=3,<3.0.1|>=4,<4.0.1|>=5,<5.0.9|>=5.1,<5.1.3|>=5.2,<5.2.1|>=6,<6.0.2|>=7,<7.0.4|>=8,<8.0.1|>=8.1,<8.1.1|>=8.2,<8.2.1|>=8.3,<8.3.1",
                 "sabre/dav": ">=1.6,<1.6.99|>=1.7,<1.7.11|>=1.8,<1.8.9",
                 "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11",
                 "sensiolabs/connect": "<4.2.3",
                 "serluck/phpwhois": "<=4.2.6",
+                "shopware/core": "<=6.3.1",
+                "shopware/platform": "<=6.3.1",
                 "shopware/shopware": "<5.3.7",
-                "silverstripe/cms": ">=3,<=3.0.11|>=3.1,<3.1.11",
+                "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1",
+                "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2",
+                "silverstripe/cms": "<4.3.6|>=4.4,<4.4.4",
+                "silverstripe/comments": ">=1.3,<1.9.99|>=2,<2.9.99|>=3,<3.1.1",
                 "silverstripe/forum": "<=0.6.1|>=0.7,<=0.7.3",
-                "silverstripe/framework": ">=3,<3.6.7|>=3.7,<3.7.3|>=4,<4.4",
-                "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.1.2",
+                "silverstripe/framework": "<4.4.7|>=4.5,<4.5.4",
+                "silverstripe/graphql": ">=2,<2.0.5|>=3,<3.1.2|>=3.2,<3.2.4",
                 "silverstripe/registry": ">=2.1,<2.1.2|>=2.2,<2.2.1",
                 "silverstripe/restfulserver": ">=1,<1.0.9|>=2,<2.0.4",
+                "silverstripe/subsites": ">=2,<2.1.1",
+                "silverstripe/taxonomy": ">=1.3,<1.3.1|>=2,<2.0.1",
                 "silverstripe/userforms": "<3",
                 "simple-updates/phpwhois": "<=1",
                 "simplesamlphp/saml2": "<1.10.6|>=2,<2.3.8|>=3,<3.1.4",
-                "simplesamlphp/simplesamlphp": "<1.17.8",
+                "simplesamlphp/simplesamlphp": "<1.18.6",
                 "simplesamlphp/simplesamlphp-module-infocard": "<1.0.1",
+                "simplito/elliptic-php": "<1.0.6",
                 "slim/slim": "<2.6",
                 "smarty/smarty": "<3.1.33",
                 "socalnick/scn-social-auth": "<1.15.2",
                 "spoonity/tcpdf": "<6.2.22",
                 "squizlabs/php_codesniffer": ">=1,<2.8.1|>=3,<3.0.1",
+                "ssddanbrown/bookstack": "<0.29.2",
                 "stormpath/sdk": ">=0,<9.9.99",
-                "studio-42/elfinder": "<2.1.48",
+                "studio-42/elfinder": "<2.1.49",
+                "sulu/sulu": "<1.6.34|>=2,<2.0.10|>=2.1,<2.1.1",
                 "swiftmailer/swiftmailer": ">=4,<5.4.5",
                 "sylius/admin-bundle": ">=1,<1.0.17|>=1.1,<1.1.9|>=1.2,<1.2.2",
                 "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
                 "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
-                "sylius/sylius": ">=1,<1.1.18|>=1.2,<1.2.17|>=1.3,<1.3.12|>=1.4,<1.4.4",
+                "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
+                "sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5",
+                "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99",
+                "symbiote/silverstripe-versionedfiles": "<=2.0.3",
                 "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
                 "symfony/dependency-injection": ">=2,<2.0.17|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
+                "symfony/error-handler": ">=4.4,<4.4.4|>=5,<5.0.4",
                 "symfony/form": ">=2.3,<2.3.35|>=2.4,<2.6.12|>=2.7,<2.7.50|>=2.8,<2.8.49|>=3,<3.4.20|>=4,<4.0.15|>=4.1,<4.1.9|>=4.2,<4.2.1",
                 "symfony/framework-bundle": ">=2,<2.3.18|>=2.4,<2.4.8|>=2.5,<2.5.2|>=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
-                "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
-                "symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
+                "symfony/http-foundation": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
+                "symfony/http-kernel": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.4.13|>=5,<5.1.5",
                 "symfony/intl": ">=2.7,<2.7.38|>=2.8,<2.8.31|>=3,<3.2.14|>=3.3,<3.3.13",
                 "symfony/mime": ">=4.3,<4.3.8",
                 "symfony/phpunit-bridge": ">=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
                 "symfony/polyfill-php55": ">=1,<1.10",
                 "symfony/proxy-manager-bridge": ">=2.7,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
                 "symfony/routing": ">=2,<2.0.19",
-                "symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7",
+                "symfony/security": ">=2,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.1.12|>=4.2,<4.2.7|>=4.4,<4.4.7|>=5,<5.0.7",
                 "symfony/security-bundle": ">=2,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
                 "symfony/security-core": ">=2.4,<2.6.13|>=2.7,<2.7.9|>=2.7.30,<2.7.32|>=2.8,<2.8.37|>=3,<3.3.17|>=3.4,<3.4.7|>=4,<4.0.7",
                 "symfony/security-csrf": ">=2.4,<2.7.48|>=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
                 "symfony/security-guard": ">=2.8,<2.8.41|>=3,<3.3.17|>=3.4,<3.4.11|>=4,<4.0.11",
-                "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8",
+                "symfony/security-http": ">=2.3,<2.3.41|>=2.4,<2.7.51|>=2.8,<2.8.50|>=3,<3.4.26|>=4,<4.2.12|>=4.3,<4.3.8|>=4.4,<4.4.7|>=5,<5.0.7",
                 "symfony/serializer": ">=2,<2.0.11",
-                "symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
+                "symfony/symfony": ">=2,<2.8.52|>=3,<3.4.35|>=4,<4.2.12|>=4.3,<4.4.13|>=5,<5.1.5",
                 "symfony/translation": ">=2,<2.0.17",
                 "symfony/validator": ">=2,<2.0.24|>=2.1,<2.1.12|>=2.2,<2.2.5|>=2.3,<2.3.3",
                 "symfony/var-exporter": ">=4.2,<4.2.12|>=4.3,<4.3.8",
                 "symfony/web-profiler-bundle": ">=2,<2.3.19|>=2.4,<2.4.9|>=2.5,<2.5.4",
                 "symfony/yaml": ">=2,<2.0.22|>=2.1,<2.1.7",
+                "t3g/svg-sanitizer": "<1.0.3",
                 "tecnickcom/tcpdf": "<6.2.22",
                 "thelia/backoffice-default-template": ">=2.1,<2.1.2",
                 "thelia/thelia": ">=2.1-beta.1,<2.1.3",
                 "titon/framework": ">=0,<9.9.99",
                 "truckersmp/phpwhois": "<=4.3.1",
                 "twig/twig": "<1.38|>=2,<2.7",
-                "typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1",
-                "typo3/cms-core": ">=8,<8.7.30|>=9,<9.5.12|>=10,<10.2.1",
+                "typo3/cms": ">=6.2,<6.2.30|>=7,<7.6.32|>=8,<8.7.30|>=9,<9.5.20|>=10,<10.4.6",
+                "typo3/cms-core": ">=8,<8.7.30|>=9,<9.5.20|>=10,<10.4.6",
                 "typo3/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.10|>=3.1,<3.1.7|>=3.2,<3.2.7|>=3.3,<3.3.5",
                 "typo3/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4",
                 "typo3/phar-stream-wrapper": ">=1,<2.1.1|>=3,<3.1.1",
+                "typo3fluid/fluid": ">=2,<2.0.5|>=2.1,<2.1.4|>=2.2,<2.2.1|>=2.3,<2.3.5|>=2.4,<2.4.1|>=2.5,<2.5.5|>=2.6,<2.6.1",
                 "ua-parser/uap-php": "<3.8",
+                "usmanhalalit/pixie": "<1.0.3|>=2,<2.0.2",
+                "verot/class.upload.php": "<=1.0.3|>=2,<=2.0.4",
                 "wallabag/tcpdf": "<6.2.22",
                 "willdurand/js-translation-bundle": "<2.1.1",
+                "yii2mod/yii2-cms": "<1.9.2",
                 "yiisoft/yii": ">=1.1.14,<1.1.15",
-                "yiisoft/yii2": "<2.0.15",
+                "yiisoft/yii2": "<2.0.38",
                 "yiisoft/yii2-bootstrap": "<2.0.4",
                 "yiisoft/yii2-dev": "<2.0.15",
                 "yiisoft/yii2-elasticsearch": "<2.0.5",
                 "yiisoft/yii2-gii": "<2.0.4",
                 "yiisoft/yii2-jui": "<2.0.4",
                 "yiisoft/yii2-redis": "<2.0.8",
+                "yourls/yourls": "<1.7.4",
                 "zendframework/zend-cache": ">=2.4,<2.4.8|>=2.5,<2.5.3",
                 "zendframework/zend-captcha": ">=2,<2.4.9|>=2.5,<2.5.2",
                 "zendframework/zend-crypt": ">=2,<2.4.9|>=2.5,<2.5.2",
                     "name": "Marco Pivetta",
                     "email": "ocramius@gmail.com",
                     "role": "maintainer"
+                },
+                {
+                    "name": "Ilya Tribusean",
+                    "email": "slash3b@gmail.com",
+                    "role": "maintainer"
                 }
             ],
             "description": "Prevents installation of composer packages with known security vulnerabilities: no API, simply require it",
-            "time": "2020-01-06T19:16:46+00:00"
+            "support": {
+                "issues": "https://github.com/Roave/SecurityAdvisories/issues",
+                "source": "https://github.com/Roave/SecurityAdvisories/tree/latest"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/Ocramius",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/roave/security-advisories",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2020-10-08T21:02:27+00:00"
         },
         {
             "name": "sebastian/code-unit-reverse-lookup",
             ],
             "description": "Looks up which function or method a line of code belongs to",
             "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/master"
+            },
             "time": "2017-03-04T06:30:41+00:00"
         },
         {
                 "compare",
                 "equality"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/master"
+            },
             "time": "2018-07-12T15:12:46+00:00"
         },
         {
                 "unidiff",
                 "unified diff"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "source": "https://github.com/sebastianbergmann/diff/tree/master"
+            },
             "time": "2019-02-04T06:01:07+00:00"
         },
         {
                 "environment",
                 "hhvm"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "source": "https://github.com/sebastianbergmann/environment/tree/4.2.3"
+            },
             "time": "2019-11-20T08:46:58+00:00"
         },
         {
                 "export",
                 "exporter"
             ],
-            "time": "2019-09-14T09:02:43+00:00"
-        },
-        {
-            "name": "sebastian/finder-facade",
-            "version": "1.2.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/sebastianbergmann/finder-facade.git",
-                "reference": "167c45d131f7fc3d159f56f191a0a22228765e16"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/sebastianbergmann/finder-facade/zipball/167c45d131f7fc3d159f56f191a0a22228765e16",
-                "reference": "167c45d131f7fc3d159f56f191a0a22228765e16",
-                "shasum": ""
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/master"
             },
-            "require": {
-                "php": "^7.1",
-                "symfony/finder": "^2.3|^3.0|^4.0|^5.0",
-                "theseer/fdomdocument": "^1.6"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": []
-            },
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Sebastian Bergmann",
-                    "email": "sebastian@phpunit.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "FinderFacade is a convenience wrapper for Symfony's Finder component.",
-            "homepage": "https://github.com/sebastianbergmann/finder-facade",
-            "time": "2020-01-16T08:08:45+00:00"
+            "time": "2019-09-14T09:02:43+00:00"
         },
         {
             "name": "sebastian/global-state",
             "keywords": [
                 "global state"
             ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/2.0.0"
+            },
             "time": "2017-04-27T15:39:26+00:00"
         },
         {
             ],
             "description": "Traverses array structures and object graphs to enumerate all referenced objects",
             "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/master"
+            },
             "time": "2017-08-03T12:35:26+00:00"
         },
         {
             ],
             "description": "Allows reflection of object attributes, including inherited and non-public ones",
             "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/master"
+            },
             "time": "2017-03-29T09:07:27+00:00"
         },
         {
             ],
             "description": "Provides functionality to recursively process PHP variables",
             "homepage": "http://www.github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/master"
+            },
             "time": "2017-03-03T06:23:57+00:00"
         },
         {
             ],
             "description": "Provides a list of PHP built-in functions that operate on resources",
             "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
+                "source": "https://github.com/sebastianbergmann/resource-operations/tree/master"
+            },
             "time": "2018-10-04T04:07:39+00:00"
         },
         {
             ],
             "description": "Library that helps with managing the version number of Git-hosted PHP projects",
             "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "source": "https://github.com/sebastianbergmann/version/tree/master"
+            },
             "time": "2016-10-03T07:35:21+00:00"
         },
         {
             "name": "squizlabs/php_codesniffer",
-            "version": "3.5.3",
+            "version": "3.5.6",
             "source": {
                 "type": "git",
                 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
-                "reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb"
+                "reference": "e97627871a7eab2f70e59166072a6b767d5834e0"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
-                "reference": "557a1fc7ac702c66b0bbfe16ab3d55839ef724cb",
+                "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0",
+                "reference": "e97627871a7eab2f70e59166072a6b767d5834e0",
                 "shasum": ""
             },
             "require": {
                 "phpcs",
                 "standards"
             ],
-            "time": "2019-12-04T04:46:47+00:00"
-        },
-        {
-            "name": "symfony/console",
-            "version": "v4.4.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/console.git",
-                "reference": "82437719dab1e6bdd28726af14cb345c2ec816d0"
+            "support": {
+                "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
+                "source": "https://github.com/squizlabs/PHP_CodeSniffer",
+                "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
             },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/82437719dab1e6bdd28726af14cb345c2ec816d0",
-                "reference": "82437719dab1e6bdd28726af14cb345c2ec816d0",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1.3",
-                "symfony/polyfill-mbstring": "~1.0",
-                "symfony/polyfill-php73": "^1.8",
-                "symfony/service-contracts": "^1.1|^2"
-            },
-            "conflict": {
-                "symfony/dependency-injection": "<3.4",
-                "symfony/event-dispatcher": "<4.3|>=5",
-                "symfony/lock": "<4.4",
-                "symfony/process": "<3.3"
-            },
-            "provide": {
-                "psr/log-implementation": "1.0"
-            },
-            "require-dev": {
-                "psr/log": "~1.0",
-                "symfony/config": "^3.4|^4.0|^5.0",
-                "symfony/dependency-injection": "^3.4|^4.0|^5.0",
-                "symfony/event-dispatcher": "^4.3",
-                "symfony/lock": "^4.4|^5.0",
-                "symfony/process": "^3.4|^4.0|^5.0",
-                "symfony/var-dumper": "^4.3|^5.0"
-            },
-            "suggest": {
-                "psr/log": "For using the console logger",
-                "symfony/event-dispatcher": "",
-                "symfony/lock": "",
-                "symfony/process": ""
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Console\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony Console Component",
-            "homepage": "https://symfony.com",
-            "time": "2019-12-17T10:32:23+00:00"
-        },
-        {
-            "name": "symfony/finder",
-            "version": "v4.4.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/finder.git",
-                "reference": "ce8743441da64c41e2a667b8eb66070444ed911e"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/ce8743441da64c41e2a667b8eb66070444ed911e",
-                "reference": "ce8743441da64c41e2a667b8eb66070444ed911e",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1.3"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "4.4-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Finder\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony Finder Component",
-            "homepage": "https://symfony.com",
-            "time": "2019-11-17T21:56:56+00:00"
+            "time": "2020-08-10T04:50:15+00:00"
         },
         {
             "name": "symfony/polyfill-ctype",
-            "version": "v1.13.1",
+            "version": "v1.18.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3"
+                "reference": "1c302646f6efc070cd46856e600e5e0684d6b454"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
-                "reference": "f8f0b461be3385e56d6de3dbb5a0df24c0c275e3",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454",
+                "reference": "1c302646f6efc070cd46856e600e5e0684d6b454",
                 "shasum": ""
             },
             "require": {
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "1.13-dev"
+                    "dev-master": "1.18-dev"
+                },
+                "thanks": {
+                    "name": "symfony/polyfill",
+                    "url": "https://github.com/symfony/polyfill"
                 }
             },
             "autoload": {
                 "polyfill",
                 "portable"
             ],
-            "time": "2019-11-27T13:56:44+00:00"
-        },
-        {
-            "name": "symfony/polyfill-mbstring",
-            "version": "v1.13.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-mbstring.git",
-                "reference": "7b4aab9743c30be783b73de055d24a39cf4b954f"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7b4aab9743c30be783b73de055d24a39cf4b954f",
-                "reference": "7b4aab9743c30be783b73de055d24a39cf4b954f",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.3"
-            },
-            "suggest": {
-                "ext-mbstring": "For best performance"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.13-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Mbstring\\": ""
-                },
-                "files": [
-                    "bootstrap.php"
-                ]
+            "support": {
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.18.0"
             },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
+            "funding": [
                 {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
                 },
                 {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony polyfill for the Mbstring extension",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "mbstring",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "time": "2019-11-27T14:18:11+00:00"
-        },
-        {
-            "name": "symfony/polyfill-php73",
-            "version": "v1.13.1",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-php73.git",
-                "reference": "4b0e2222c55a25b4541305a053013d5647d3a25f"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/4b0e2222c55a25b4541305a053013d5647d3a25f",
-                "reference": "4b0e2222c55a25b4541305a053013d5647d3a25f",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.3"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.13-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Php73\\": ""
-                },
-                "files": [
-                    "bootstrap.php"
-                ],
-                "classmap": [
-                    "Resources/stubs"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
                 },
                 {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
                 }
             ],
-            "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "polyfill",
-                "portable",
-                "shim"
-            ],
-            "time": "2019-11-27T16:25:15+00:00"
-        },
-        {
-            "name": "symfony/service-contracts",
-            "version": "v1.1.8",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/service-contracts.git",
-                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
-                "reference": "ffc7f5692092df31515df2a5ecf3b7302b3ddacf",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.1.3",
-                "psr/container": "^1.0"
-            },
-            "suggest": {
-                "symfony/service-implementation": ""
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.1-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Contracts\\Service\\": ""
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Nicolas Grekas",
-                    "email": "p@tchwork.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Generic abstractions related to writing services",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "abstractions",
-                "contracts",
-                "decoupling",
-                "interfaces",
-                "interoperability",
-                "standards"
-            ],
-            "time": "2019-10-14T12:27:06+00:00"
-        },
-        {
-            "name": "theseer/fdomdocument",
-            "version": "1.6.6",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/theseer/fDOMDocument.git",
-                "reference": "6e8203e40a32a9c770bcb62fe37e68b948da6dca"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/theseer/fDOMDocument/zipball/6e8203e40a32a9c770bcb62fe37e68b948da6dca",
-                "reference": "6e8203e40a32a9c770bcb62fe37e68b948da6dca",
-                "shasum": ""
-            },
-            "require": {
-                "ext-dom": "*",
-                "lib-libxml": "*",
-                "php": ">=5.3.3"
-            },
-            "type": "library",
-            "autoload": {
-                "classmap": [
-                    "src/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "BSD-3-Clause"
-            ],
-            "authors": [
-                {
-                    "name": "Arne Blankerts",
-                    "email": "arne@blankerts.de",
-                    "role": "lead"
-                }
-            ],
-            "description": "The classes contained within this repository extend the standard DOM to use exceptions at all occasions of errors instead of PHP warnings or notices. They also add various custom methods and shortcuts for convenience and to simplify the usage of DOM.",
-            "homepage": "https://github.com/theseer/fDOMDocument",
-            "time": "2017-06-30T11:53:12+00:00"
+            "time": "2020-07-14T12:35:20+00:00"
         },
         {
             "name": "theseer/tokenizer",
                 }
             ],
             "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/master"
+            },
             "time": "2019-06-13T22:48:21+00:00"
         },
         {
             "name": "webmozart/assert",
-            "version": "1.6.0",
+            "version": "1.9.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/webmozart/assert.git",
-                "reference": "573381c0a64f155a0d9a23f4b0c797194805b925"
+                "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/webmozart/assert/zipball/573381c0a64f155a0d9a23f4b0c797194805b925",
-                "reference": "573381c0a64f155a0d9a23f4b0c797194805b925",
+                "url": "https://api.github.com/repos/webmozart/assert/zipball/bafc69caeb4d49c39fd0779086c03a3738cbb389",
+                "reference": "bafc69caeb4d49c39fd0779086c03a3738cbb389",
                 "shasum": ""
             },
             "require": {
-                "php": "^5.3.3 || ^7.0",
+                "php": "^5.3.3 || ^7.0 || ^8.0",
                 "symfony/polyfill-ctype": "^1.8"
             },
             "conflict": {
-                "vimeo/psalm": "<3.6.0"
+                "phpstan/phpstan": "<0.12.20",
+                "vimeo/psalm": "<3.9.1"
             },
             "require-dev": {
                 "phpunit/phpunit": "^4.8.36 || ^7.5.13"
                 "check",
                 "validate"
             ],
-            "time": "2019-11-24T13:36:37+00:00"
+            "support": {
+                "issues": "https://github.com/webmozart/assert/issues",
+                "source": "https://github.com/webmozart/assert/tree/master"
+            },
+            "time": "2020-07-08T17:02:28+00:00"
         }
     ],
     "aliases": [],
     "platform-dev": [],
     "platform-overrides": {
         "php": "7.1.29"
-    }
+    },
+    "plugin-api-version": "2.0.0"
 }
diff --git a/doc/md/3rd-party-libraries.md b/doc/md/3rd-party-libraries.md
deleted file mode 100644 (file)
index 7e7dd33..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-## CSS
-
-- Yahoo UI [CSS Reset](http://yuilibrary.com/yui/docs/cssreset/) - standardize cross-browser rendering
-
-## Javascript
-
-- [Awesomeplete](https://leaverou.github.io/awesomplete/) ([GitHub](https://github.com/LeaVerou/awesomplete)) - autocompletion in input forms
-- [bLazy](http://dinbror.dk/blazy/) ([GitHub](https://github.com/dinbror/blazy)) - lazy loading for thumbnails
-- [qr.js](http://neocotic.com/qr.js/) ([GitHub](https://github.com/neocotic/qr.js)) - QR code generation
-
-## PHP
-
-- [RainTPL](https://github.com/rainphp/raintpl) - HTML templating for PHP
-
-### Composer
-
-Library | Usage
----|---
-[`shaarli/netscape-bookmark-parser`](https://packagist.org/packages/shaarli/netscape-bookmark-parser) | Import bookmarks from Netscape files
-[`erusev/parsedown`](https://packagist.org/packages/erusev/parsedown) | Parse MarkDown syntax for the MarkDown plugin
-[`slim/slim`](https://packagist.org/packages/slim/slim) | Handle routes and middleware for the REST API
diff --git a/doc/md/Backup-and-restore.md b/doc/md/Backup-and-restore.md
new file mode 100644 (file)
index 0000000..e7e2775
--- /dev/null
@@ -0,0 +1,11 @@
+## Backup and restore
+
+All data and [configuration](Shaarli-configuration.md) is kept in the `data` directory. Backup this directory: 
+
+```bash
+rsync -avzP my.server.com:/var/www/shaarli.mydomain.org/data ~/backups/shaarli-data-$(date +%Y-%m-%d_%H%M)
+```
+
+It is strongly recommended to do periodic, automatic backups to a seperate machine. You can automate the command above using a cron job or full-featured backup solutions such as [rsnapshot](https://rsnapshot.org/)
+
+To restore a backup, simply put back the `data/` directory in place, owerwriting any existing files.
\ No newline at end of file
diff --git a/doc/md/Browsing-and-searching.md b/doc/md/Browsing-and-searching.md
deleted file mode 100644 (file)
index 16c6985..0000000
+++ /dev/null
@@ -1,37 +0,0 @@
-## Plain text search
-
-Use the `Search text` field to search in _any_ of the fields of all links (Title, URL, Description...)
-
-**Exclude text/tags:** Use the `-` operator before a word or tag (example `-uninteresting`) to prevent entries containing (or tagged) `uninteresting` from showing up in the search results.
-
-**Exact text search:** Use double-quotes (example `"exact search"`) to search for the exact expression.
-
-Both exclude patterns and exact searches can be combined with normal searches (example `"exact search" term otherterm -notthis "very exact" stuff -notagain`)
-
-## Tags search
-
-Use the `Filter by tags` field to restrict displayed links to entries tagged with one or multiple tags (use space to separate tags).  
-
-**Hidden tags:** Tags starting with a dot `.` (example `.secret`) are private. They can only be seen and searched when logged in.
-
-### Tag cloud
-
-The `Tag cloud` page diplays a "cloud" view of all tags in your Shaarli.
-
- * The most frequently used tags are displayed with a bigger font size.
- * When sorting by `Most used` or `Alphabetical`, tags are displayed as a _list_, along with counters and edit/delete buttons for each tag.
- * Clicking on any tag will display a list of all Shaares matching this tag.
- * Clicking on the counter next to a tag `example`, will filter the tag cloud to only display tags found in Shaares tagged `example`. Repeat this any number of times to further filter the tag cloud. Click `List all links with those tags` to display Shaares matching your current tag filter.
-
-## Filtering RSS feeds/Picture wall
-
-RSS feeds can also be restricted to only return items matching a text/tag search: see [RSS feeds](RSS-feeds).
-
-## Filter buttons
-
-Filter buttons can be found at the top left of the link list. They allow you to apply different filters to the list:
-
- * **Private links:** When this toggle button is enabled, only shaares set to `private` will be shown.
- * **Untagged links:** When the this toggle button is enabled (top left of the link list), only shaares _without any tags_ will be shown in the link list.
-Filter buttons are only available when logged in.
similarity index 74%
rename from doc/md/Community-&-Related-software.md
rename to doc/md/Community-and-related-software.md
index 54f18c8e36e416fb95be01cf3d7cedb33f289e7f..53a7555eab6f57c9e3fa26d19a74badc7cfd8678 100644 (file)
@@ -1,66 +1,87 @@
+# Community & related software
+
 _Unofficial but related work on Shaarli. If you maintain one of these,
 please get in touch with us to help us find a way to adapt your work to our fork._
 
-## Related software
 
+## Related software
 
 ### REST API clients
 See [REST API](REST-API) for a list of official and community clients.
 
 
 ### Third party plugins
-- [autosave](https://github.com/kalvn/shaarli-plugin-autosave) by [@kalvn](https://github.com/kalvn): Automatically saves data when editing a link to avoid any loss in case of crash or unexpected shutdown.
-- [Code Coloration](https://github.com/ArthurHoaro/code-coloration) by [@ArthurHoaro](https://github.com/ArthurHoaro): client side code syntax highlighter.
-- [Disqus](https://github.com/kalvn/shaarli-plugin-disqus) by [@kalvn](https://github.com/kalvn): Adds Disqus comment system to your Shaarli.
+
+- [autosave](https://github.com/kalvn/shaarli-plugin-autosave) by [@kalvn](https://github.com/kalvn): Automatically saves data when editing a Shaare to avoid any loss in case of crash or unexpected shutdown.
+- [code-coloration](https://github.com/ArthurHoaro/code-coloration) by [@ArthurHoaro](https://github.com/ArthurHoaro): client side code syntax highlighter.
+- [custom-css](https://github.com/immanuelfodor/shaarli-custom-css) by [@immanuelfodor](https://github.com/immanuelfodor) - Customize the look and feel of the UI with custom CSS rules
+- [disqus](https://github.com/kalvn/shaarli-plugin-disqus) by [@kalvn](https://github.com/kalvn): Adds Disqus comment system to your Shaarli.
+- [emojione](https://github.com/immanuelfodor/emojione) by [@immanuelfodor](https://github.com/immanuelfodor) - Resurrected fork of the original emojione project
+- [favicons](https://github.com/trailjeep/shaarli-favicons) by [@trailjeep](https://github.com/trailjeep) - Shaarli plugin to add favicon/filetype icons to Shaares.
 - [google analytics](https://github.com/ericjuden/Shaarli-Google-Analytics-Plugin) by [@ericjuden](http://github.com/ericjuden): Adds Google Analytics tracking support
 - [launch](https://github.com/ArthurHoaro/launch-plugin) - Launch Plugin is a plugin designed to enhance and customize Launch Theme for Shaarli.
-- [markdown-toolbar](https://github.com/immanuelfodor/shaarli-markdown-toolbar) by [@immanuelfodor](https://github.com/immanuelfodor) - Easily insert markdown syntax into the Description field when editing a link.
-- [related](https://github.com/ilesinge/shaarli-related) by [@ilesinge](https://github.com/ilesinge) - Show related links based on the number of identical tags.
-- [social](https://github.com/alexisju/social) by [@alexisju](https://github.com/alexisju): share links to social networks.
-- [shaarli2twitter](https://github.com/ArthurHoaro/shaarli2twitter) by [@ArthurHoaro](https://github.com/ArthurHoaro) - Automatically tweet your shared links from Shaarli
+- [markdown-toolbar](https://github.com/immanuelfodor/shaarli-markdown-toolbar) by [@immanuelfodor](https://github.com/immanuelfodor) - Easily insert markdown syntax into the Description field when editing a Shaare.
+- [related](https://github.com/ilesinge/shaarli-related) by [@ilesinge](https://github.com/ilesinge) - Show related Shaares based on the number of identical tags.
+- [shaarli-descriptor](https://github.com/immanuelfodor/shaarli-descriptor) by [@immanuelfodor](https://github.com/immanuelfodor) - Customize the default height/number of rows of the Description field when editing a Shaare.
 - [shaarli2mastodon](https://github.com/kalvn/shaarli2mastodon) by [@kalvn](https://github.com/kalvn) - This Shaarli plugin allows you to automatically publish links you post on your Mastodon timeline.
-- [shaarli-descriptor](https://github.com/immanuelfodor/shaarli-descriptor) by [@immanuelfodor](https://github.com/immanuelfodor) - Customize the default height/number of rows of the Description field when editing a link.
+- [shaarli2twitter](https://github.com/ArthurHoaro/shaarli2twitter) by [@ArthurHoaro](https://github.com/ArthurHoaro) - Automatically tweet your Shaares from Shaarli
+- [social](https://github.com/alexisju/social) by [@alexisju](https://github.com/alexisju): share links to social networks.
 - [urlextern](https://github.com/trailjeep/shaarli-urlextern) by [@trailjeep](https://github.com/trailjeep) - Shaarli plugin to open external links in a new tab/window.
-- [favicons](https://github.com/trailjeep/shaarli-favicons) by [@trailjeep](https://github.com/trailjeep) - Shaarli plugin to add favicon/filetype icons to links.
+- [webhooks](https://gitlab.com/flow.gunso/shaarli-webhooks) by [@flow.gunso](https://gitlab.com/flow.gunso) - Shaarli plugin that enables user-defined callback URL, i.e. webhooks, for specific Shaarli events (link saving, deletion...)
+
 
 ### Third-party themes
+
 See [Theming](Theming) for a list of community-contributed themes, and an installation guide.
 
 
 ### Integration with other platforms 
+
 - [tt-rss-shaarli](https://github.com/jcsaaddupuy/tt-rss-shaarli) - [Tiny-Tiny RSS](http://tt-rss.org/) plugin that adds support for sharing articles with Shaarli
-- [octopress-shaarli](https://github.com/ahmet2mir/octopress-shaarli) - Octopress plugin to retrieve Shaarli links on the sidebar
+- [octopress-shaarli](https://github.com/ahmet2mir/octopress-shaarli) - Octopress plugin to retrieve Shaarli Shaares on the sidebar
 - [Scuttle to Shaarli](https://github.com/q2apro/scuttle-to-shaarli) - Import bookmarks from Scuttle
 - [Shaarli app for Cloudron](https://git.cloudron.io/cloudron/shaarli-app) - Effortlessly run Shaarli with the help of [Cloudron](https://cloudron.io/) [![Install](https://cloudron.io/img/button.svg)](https://cloudron.io/button.html?app=com.github.shaarli)
 - [Shaarli_ynh](https://github.com/YunoHost-Apps/shaarli_ynh) - Shaarli is available as a [Yunohost](https://yunohost.org) app [![Install Shaarli with YunoHost](https://install-app.yunohost.org/install-with-yunohost.png)](https://install-app.yunohost.org/?app=shaarli)
 - [pelican](https://blog.getpelican.com) static blog generator plugin to auto-post articles on a Shaarli instance: [shaarli_poster](https://github.com/getpelican/pelican-plugins/tree/master/shaarli_poster)
 
+
 ### Mobile Apps
+
 - [ShaarliOS](https://github.com/mro/ShaarliOS) - Apple iOS share extension.
 - [Shaarli for Android](http://sebsauvage.net/links/?ZAyDzg) - Android application that adds Shaarli as a sharing provider
-- [Shaarlier for Android](https://github.com/dimtion/Shaarlier) - Android application to simply add links directly into your Shaarli
+- [Shaarlier for Android](https://github.com/dimtion/Shaarlier) - Android application to simply add Shaares directly into your Shaarli
 - [Stakali for Android](https://stakali.toneiv.eu) - Stakali is a personal bookmark manager which synchronizes with Shaarli 
 
+
 ### Desktop Apps
+
 - [Ulauncher Extension](https://github.com/sebw/ulauncher-shaarli) - Ulauncher is an an application launcher for Linux, this extension allows research in your Shaarli
 
+
 ### Browser addons
+
 - [Shaarli Firefox Extension](https://github.com/ikipatang/shaarli-web-extension) - toolbar button to share your current tab with Shaarli.
 - [Shaarli Chrome Extension](https://github.com/octplane/Shiny-Shaarli) - toolbar button to share your current tab with Shaarli.
 
+
 ### Server apps
+
 - [shaarchiver](https://github.com/nodiscc/shaarchiver) - Archive your Shaarli bookmarks and their content
 - [shaarli-river](https://github.com/mknexen/shaarli-river) - An aggregator for shaarlis with many features 
-- [Shaarlo](https://github.com/DMeloni/shaarlo) - An aggregator for shaarlis with many features (a very popular running instance among French shaarliers: [shaarli.fr](http://shaarli.fr/))
+- [Shaarlo](https://github.com/DMeloni/shaarlo) - An aggregator for shaarlis with many features
 - [Shaarlimages](https://github.com/BoboTiG/shaarlimages) - An image-oriented aggregator for Shaarlis
 - [mknexen/shaarli-api](https://github.com/mknexen/shaarli-api) - A REST API for Shaarli
 - [Self dead link](https://framagit.org/qwertygc/shaarli-dev-code/blob/master/self-dead-link.php) - Detect dead links on shaarli. This version use the database of shaarli. [Another version](https://framagit.org/qwertygc/shaarli-dev-code/blob/master/dead-link.php), can be used for other shaarli instances (but is more resource consuming).
 - [Bookmark Archiver](https://github.com/pirate/bookmark-archiver) - Save an archived copy of all websites starred using browser bookmarks/Shaarli/Delicious/Instapaper/Unmark.it/Pocket/Pinboard. Outputs browseable html. 
 
+
 ## Alternatives to Shaarli
+
 See [awesome-selfhosted: bookmarks & link sharing](https://github.com/Kickball/awesome-selfhosted/#bookmarks--link-sharing).
 
+
 ## Community
+
 - [Liens en vrac de sebsauvage](http://sebsauvage.net/links/) - the original Shaarli
 - [A large list of Shaarlis](http://porneia.free.fr/pub/links/ou-est-shaarli.html)
 - [A list of working Shaarli aggregators](https://raw.githubusercontent.com/Oros42/find_shaarlis/master/annuaires.json)
@@ -71,8 +92,17 @@ See [awesome-selfhosted: bookmarks & link sharing](https://github.com/Kickball/a
 - [Original revisions history](http://sebsauvage.net/wiki/doku.php?id=php:shaarli:history)
 - [Shaarli.fr/my](https://www.shaarli.fr/my.php) - Unofficial, unsupported (old fork) hosted Shaarlis provider, courtesy of [DMeloni](https://github.com/DMeloni)
 
+
 ### Articles and social media discussions
-- 2016-09-22 - Hacker News - https://news.ycombinator.com/item?id=12552176
+- 2020-04-05 - Hacker News - [Self-hosted instance of Shaarli - it is simple, fast and reliable](https://news.ycombinator.com/item?id=22780219)
+- 2016-10-10 - Framasoft - [MyFrama : vos favoris partout, avec vous, rien qu’à vous !](https://framablog.org/2016/10/10/myframa-vos-favoris-et-framasofteries-partout-avec-vous-rien-qua-vous/)
+- 2016-09-22 - Hacker News - [Shaarli – Personal, minimalist, database-free, bookmarking service (github.com)](https://news.ycombinator.com/item?id=12552176)
 - 2015-08-15 - Reddit - [Question about migrating from WordPress to Shaarli.](https://www.reddit.com/r/selfhosted/comments/3h3zwh/question_about_migrating_from_wordpress_to_shaarli/)
-- 2015-06-22 - Hacker News - https://news.ycombinator.com/item?id=9755366
+- 2015-06-22 - Hacker News - [Shaarli: Self-hosted del.icio.us alternative (sebsauvage.net)](https://news.ycombinator.com/item?id=9755366)
 - 2015-05-12 - Reddit - [shaarli - Self hosted Bookmarking / Delicious (PHP, MySQL)](https://www.reddit.com/r/selfhosted/comments/35pkkc/shaarli_self_hosted_bookmarking_delicious_php/)
+- 2014-10-15 - OpenSource.com - [Five open source alternatives to popular web apps](https://opensource.com/life/14/10/five-open-source-alternatives-popular-web-apps)
+
+It also appears in the following recommendation lists:
+- [AlternativeTo](https://alternativeto.net/software/shaarli/)
+- [FramaLibre](https://framalibre.org/content/shaarli)
+- [Project Awesome: Selfhosted Bookmarks and Link Sharing](https://project-awesome.org/Kickball/awesome-selfhosted)
diff --git a/doc/md/Continuous-integration-tools.md b/doc/md/Continuous-integration-tools.md
deleted file mode 100644 (file)
index f7819d5..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-## Local development
-A [`Makefile`](https://github.com/shaarli/Shaarli/blob/master/Makefile) is available to perform project-related operations:
-
-- Documentation - generate a local HTML copy of the GitHub wiki
-- [Static analysis](Static-analysis) - check that the code is compliant to PHP conventions
-- [Unit tests](Unit-tests) - ensure there are no regressions introduced by new commits
-
-## Automatic builds
-[Travis CI](http://docs.travis-ci.com/) is a Continuous Integration build server, that runs a build:
-
-- each time a commit is merged to the mainline (`master` branch)
-- each time a Pull Request is submitted or updated
-
-A build is composed of several jobs: one for each supported PHP version (see [Server requirements](Server requirements)).
-
-Each build job:
-
-- updates Composer
-- installs 3rd-party test dependencies with Composer
-- runs [Unit tests](Unit-tests)
-- runs ESLint check
-
-After all jobs have finished, Travis returns the results to GitHub:
-
-- a status icon represents the result for the `master` branch: [![](https://api.travis-ci.org/shaarli/Shaarli.svg)](https://travis-ci.org/shaarli/Shaarli)
-- Pull Requests are updated with the Travis result
-    - Green: all tests have passed
-    - Red: some tests failed
-    - Orange: tests are pending
-
-## Documentation
-[mkdocs](https://www.mkdocs.org/) is used to convert markdown documentation to HTML pages. The [public documentation](https://shaarli.readthedocs.io/en/master/) website is rendered and hosted by [readthedocs.org](https://readthedocs.org/). A copy of the documentation is also included in prebuilt [release archives](https://github.com/shaarli/Shaarli/releases) (`doc/html/` path in your Shaarli installation). To generate the HTML documentation locally, install a recent version of Python `setuptools` and run    `make doc`.
diff --git a/doc/md/Development-guidelines.md b/doc/md/Development-guidelines.md
deleted file mode 100644 (file)
index 46b7c6f..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-## Development guidelines
-
-Please have a look at the following pages:
-
-- [Contributing to Shaarli](https://github.com/shaarli/Shaarli/tree/master/CONTRIBUTING.md)
-- [Static analysis](Static-analysis) - patches should try to stick to the 
-[PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), especially:
-    - [PSR-1](http://www.php-fig.org/psr/psr-1/) - Basic Coding Standard
-    - [PSR-2](http://www.php-fig.org/psr/psr-2/) - Coding Style Guide
-- [Unit tests](Unit-tests)
-- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). 
-Run `make eslint` to check JS style.
-- [GnuPG signature](GnuPG-signature) for tags/releases
diff --git a/doc/md/Directory-structure.md b/doc/md/Directory-structure.md
deleted file mode 100644 (file)
index c0b4939..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-## Directory structure
-
-Here is the directory structure of Shaarli and the purpose of the different files:
-
-```bash
-       index.php        # Main program
-       application/     # Shaarli classes
-               ├── LinkDB.php
-
-        ...
-
-               └── Utils.php
-       tests/           # Shaarli unitary & functional tests
-               ├── LinkDBTest.php
-
-        ...
-
-               ├── utils    # utilities to ease testing
-               │   └── ReferenceLinkDB.php
-               └── UtilsTest.php
-       assets/
-           ├── common/                # Assets shared by multiple themes
-               ├── ...
-        ├── default/               # Assets for the default template, before compilation
-            ├── fonts/                  # Font files
-            ├── img/                    # Images used by the default theme
-            ├── js/                     # JavaScript files in ES6 syntax
-            ├── scss/                   # SASS files
-        └── vintage/               # Assets for the vintage template, before compilation
-            └── ...
-    COPYING          # Shaarli license
-    inc/             # static assets and 3rd party libraries
-        └── rain.tpl.class.php     # RainTPL templating library
-    images/          # Images and icons used in Shaarli
-    data/            # data storage: bookmark database, configuration, logs, banlist...
-        ├── config.json.php        # Shaarli configuration (login, password, timezone, title...)
-        ├── datastore.php          # Your link database (compressed).
-        ├── ipban.php              # IP address ban system data
-        ├── lastupdatecheck.txt    # Update check timestamp file
-        └── log.txt                # login/IPban log.
-    tpl/             # RainTPL templates for Shaarli. They are used to build the pages.
-        ├── default/               # Default Shaarli theme
-            ├── fonts/                  # Font files
-            ├── img/                    # Images
-            ├── js/                     # JavaScript files compiled by Babel and compatible with all browsers
-            ├── css/                    # CSS files compiled with SASS
-        └── vintage/               # Legacy Shaarli theme
-            └── ...
-    cache/           # thumbnails cache
-                     # This directory is automatically created. You can erase it anytime you want.
-    tmp/             # Temporary directory for compiled RainTPL templates.
-                     # This directory is automatically created. You can erase it anytime you want.
-    vendor/          # Third-party dependencies. This directory is created by Composer
-```
diff --git a/doc/md/Docker.md b/doc/md/Docker.md
new file mode 100644 (file)
index 0000000..c152fe9
--- /dev/null
@@ -0,0 +1,227 @@
+# Docker
+
+[Docker](https://docs.docker.com/get-started/overview/) is an open platform for developing, shipping, and running applications
+
+## Install Docker
+
+Install [Docker](https://docs.docker.com/engine/install/), by following the instructions relevant to your OS / distribution, and start the service. For example on [Debian](https://docs.docker.com/engine/install/debian/):
+
+```bash
+# update your package lists
+sudo apt update
+# remove old versions
+sudo apt-get remove docker docker-engine docker.io containerd runc
+# install requirements
+sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
+# add docker's GPG signing key
+curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
+# add the repository
+sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian $(lsb_release -cs) stable"
+# install docker engine
+sudo apt-get update
+sudo apt-get install docker-ce docker-ce-cli containerd.io
+# Start and enable Docker service
+sudo systemctl enable docker && sudo systemctl start docker
+# verify that Docker is properly configured
+sudo docker run hello-world
+```
+
+In order to run Docker commands as a non-root user, you must add the `docker` group to this user:
+
+```bash
+# Add docker group as secondary group
+sudo usermod -aG docker your-user
+# Reboot or logout
+# Then verify that Docker is properly configured, as "your-user"
+docker run hello-world
+```
+
+## Get and run a Shaarli image
+
+Shaarli images are available on [DockerHub](https://hub.docker.com/r/shaarli/shaarli/) `shaarli/shaarli`:
+
+- `latest`: latest branch (last release)
+- `stable`: stable branch (last release in previous major version)
+- `master`: master branch (development branch)
+
+These images are built automatically on DockerHub and rely on:
+
+- [Alpine Linux](https://www.alpinelinux.org/)
+- [PHP7-FPM](http://php-fpm.org/)
+- [Nginx](http://nginx.org/)
+
+Additional Dockerfiles are provided for the `arm32v7` platform, relying on [Linuxserver.io Alpine armhf images](https://hub.docker.com/r/lsiobase/alpine.armhf/). These images must be built using [`docker build`](https://docs.docker.com/engine/reference/commandline/build/) on an `arm32v7` machine or using an emulator such as [qemu](https://resin.io/blog/building-arm-containers-on-any-x86-machine-even-dockerhub/).
+
+Here is an example of how to run Shaarli latest image using Docker:
+
+```bash
+# download the 'latest' image from dockerhub
+docker pull shaarli/shaarli
+
+# create persistent data volumes/directories on the host
+docker volume create shaarli-data
+docker volume create shaarli-cache
+
+# create a new container using the Shaarli image
+# --detach: run the container in background
+# --name: name of the created container/instance
+# --publish: map the host's :8000 port to the container's :80 port
+# --rm: automatically remove the container when it exits
+# --volume: mount persistent volumes in the container ($volume_name:$volume_mountpoint)
+docker run --detach \
+           --name myshaarli \
+           --publish 8000:80 \
+           --rm \
+           --volume shaarli-data:/var/www/shaarli/data \
+           --volume shaarli-cache:/var/www/shaarli/cache \
+           shaarli/shaarli:latest
+
+# verify that the container is running
+docker ps | grep myshaarli
+
+# to completely remove the container
+docker stop myshaarli # stop the running container
+docker ps | grep myshaarli # verify the container is no longer running
+docker ps -a | grep myshaarli # verify the container is stopped
+docker rm myshaarli # destroy the container
+docker ps -a | grep myshaarli # verify th container has been destroyed
+
+```
+
+After running `docker run` command, your Shaarli instance should be available on the host machine at [localhost:8000](http://localhost:8000). In order to access your instance through a reverse proxy, we recommend using our [Docker Compose](#docker-compose) build.
+
+## Docker Compose
+
+A [Compose file](https://docs.docker.com/compose/compose-file/) is a common format for defining and running multi-container Docker applications.
+
+A `docker-compose.yml` file can be used to run a persistent/autostarted shaarli service using [Docker Compose](https://docs.docker.com/compose/) or in a [Docker stack](https://docs.docker.com/engine/reference/commandline/stack_deploy/).
+
+Shaarli provides configuration file for Docker Compose, that will setup a Shaarli instance, a [Træfik](https://containo.us/traefik/) instance (reverse proxy) with [Let's Encrypt](https://letsencrypt.org/) certificates, a Docker network, and volumes for Shaarli data and Træfik TLS configuration and certificates.
+
+Download docker-compose from the [release page](https://docs.docker.com/compose/install/):
+
+```bash
+$ sudo curl -L "https://github.com/docker/compose/releases/download/1.26.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
+$ sudo chmod +x /usr/local/bin/docker-compose
+```
+
+To run Shaarli container and its reverse proxy, you can execute the following commands:
+
+```bash
+# create a new directory to store the configuration:
+$ mkdir shaarli && cd shaarli
+# Download the latest version of Shaarli's docker-compose.yml
+$ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/latest/docker-compose.yml -o docker-compose.yml
+# Create the .env file and fill in your VPS and domain information
+# (replace <MY_SHAARLI_DOMAIN> and <MY_CONTACT_EMAIL> with your actual information)
+$ echo 'SHAARLI_VIRTUAL_HOST=shaarli.mydomain.org' > .env
+$ echo 'SHAARLI_LETSENCRYPT_EMAIL=admin@mydomain.org' >> .env
+# Pull the Docker images
+$ docker-compose pull
+# Run!
+$ docker-compose up -d
+```
+
+After a few seconds, you should be able to access your Shaarli instance at [https://shaarli.mydomain.org](https://shaarli.mydomain.org) (replace your own domain name).
+
+## Running dockerized Shaarli as a systemd service
+
+It is possible to start a dockerized Shaarli instance as a systemd service (systemd is the service management tool on several distributions). After installing Docker, use the following steps to run your shaarli container Shaarli to run on system start.
+
+As root, create `/etc/systemd/system/docker.shaarli.service`:
+
+```ini
+[Unit]
+Description=Shaarli Bookmark Manager Container
+After=docker.service
+Requires=docker.service
+
+
+[Service]
+Restart=always
+
+# Put any environment you want in an included file, like $host- or $domainname in this example
+EnvironmentFile=/etc/sysconfig/box-environment
+
+# It's just an example..
+ExecStart=/usr/bin/docker run \
+  -p 28010:80 \
+  --name ${hostname}-shaarli \
+  --hostname shaarli.${domainname} \
+  -v /srv/docker-volumes-local/shaarli-data:/var/www/shaarli/data:rw \
+  -v /etc/localtime:/etc/localtime:ro \
+  shaarli/shaarli:latest
+
+ExecStop=/usr/bin/docker rm -f ${hostname}-shaarli
+
+[Install]
+WantedBy=multi-user.target
+```
+
+```bash
+# reload systemd services definitions
+systemctl daemon-reload
+# start the servie and enable it a boot time
+systemctl enable docker.shaarli.service --now
+# verify that the service is running
+systemctl status docker.*
+# inspect system log if needed
+journalctl -f
+```
+
+
+
+## Docker cheatsheet
+
+```bash
+# pull/update an image
+$ docker pull shaarli/shaarli:release
+# run a container from an image
+$ docker run shaarli/shaarli:latest
+# list available images
+$ docker images ls
+# list running containers
+$ docker ps
+# list running AND stopped containers
+$ docker ps -a
+# run a command in a running container
+$ docker exec -ti <container-name-or-first-letters-of-id> bash
+# follow logs of a running container
+$ docker logs -f <container-name-or-first-letters-of-id>
+# delete unused images to free up disk space
+$ docker system prune --images
+# delete unused volumes to free up disk space (CAUTION all data in unused volumes will be lost)
+$ docker system prunt --volumes
+# delete unused containers
+$ docker system prune
+```
+
+
+## References
+
+- [Docker: using volumes](https://docs.docker.com/storage/volumes/)
+- [Dockerfile best practices](https://docs.docker.com/articles/dockerfile_best-practices/)
+- [Dockerfile reference](https://docs.docker.com/reference/builder/)
+- [DockerHub: GitHub automated build](https://docs.docker.com/docker-hub/github/)
+- [DockerHub: Repositories](https://docs.docker.com/userguide/dockerrepos/)
+- [DockerHub: Teams and organizations](https://docs.docker.com/docker-hub/orgs/)
+- [Get Docker CE for Debian](https://docs.docker.com/install/linux/docker-ce/debian/)
+- [Install Docker Compose](https://docs.docker.com/compose/install/)
+- [Interactive Docker training portal](https://www.katacoda.com/courses/docker/) on [Katakoda](https://www.katacoda.com/)
+- [Service management: Nginx in the foreground](http://nginx.org/en/docs/ngx_core_module.html#daemon)
+- [Service management: Using supervisord](https://docs.docker.com/articles/using_supervisord/)
+- [Volumes](https://docs.docker.com/storage/volumes/)
+- [Volumes](https://docs.docker.com/userguide/dockervolumes/)
+- [Where are Docker images stored?](http://blog.thoward37.me/articles/where-are-docker-images-stored/)
+- [docker create](https://docs.docker.com/engine/reference/commandline/create/)
+- [Docker Documentation](https://docs.docker.com/)
+- [docker exec](https://docs.docker.com/engine/reference/commandline/exec/)
+- [docker images](https://docs.docker.com/engine/reference/commandline/images/)
+- [docker logs](https://docs.docker.com/engine/reference/commandline/logs/)
+- [docker logs](https://docs.docker.com/engine/reference/commandline/logs/)
+- [Docker Overview](https://docs.docker.com/engine/docker-overview/)
+- [docker ps](https://docs.docker.com/engine/reference/commandline/ps/)
+- [docker pull](https://docs.docker.com/engine/reference/commandline/pull/)
+- [docker run](https://docs.docker.com/engine/reference/commandline/run/)
+- [docker-compose logs](https://docs.docker.com/compose/reference/logs/)
+- Træfik: [Getting Started](https://docs.traefik.io/), [Docker backend](https://docs.traefik.io/configuration/backends/docker/), [Let's Encrypt](https://docs.traefik.io/user-guide/docker-and-lets-encrypt/), [Docker image](https://hub.docker.com/_/traefik/)
\ No newline at end of file
diff --git a/doc/md/Download-and-Installation.md b/doc/md/Download-and-Installation.md
deleted file mode 100644 (file)
index ec68762..0000000
+++ /dev/null
@@ -1,124 +0,0 @@
-To install Shaarli, simply place the files in a directory under your webserver's
-Document Root (or directly at the document root).
-
-Also, please make sure your server is properly [configured](Server-configuration.md).
-
-Multiple releases branches are available:
-
-- latest (last release)
-- stable (previous major release)
-- master (development)
-
-Using one of the following methods:
-
-- by downloading full release archives including all dependencies
-- by downloading Github archives
-- by cloning the Git repository
-- using Docker: [see the documentation](docker/shaarli-images.md)
-
---------------------------------------------------------------------------------
-
-## Latest release (recommended)
-
-### Download as an archive
-
-In most cases, you should download the latest Shaarli release from the [releases](https://github.com/shaarli/Shaarli/releases) page. Download our **shaarli-full** archive to include dependencies.
-
-The current latest released version is `v0.10.4`
-
-```bash
-$ wget https://github.com/shaarli/Shaarli/releases/download/v0.10.4/shaarli-v0.10.4-full.zip
-$ unzip shaarli-v0.10.4-full.zip
-$ mv Shaarli /path/to/shaarli/
-```
-
-### Using git
-
-Cloning using `git` or downloading Github branches as zip files requires additional steps:
-
- * Install [Composer](Unit-tests.md#install_composer) to manage third-party [PHP dependencies](3rd-party-libraries.md#composer).
- * Install [yarn](https://yarnpkg.com/lang/en/docs/install/) to build the frontend dependencies.
- * Install [python3-virtualenv](https://pypi.python.org/pypi/virtualenv) to build the local HTML documentation.
-
-```
-$ mkdir -p /path/to/shaarli && cd /path/to/shaarli/
-$ git clone -b latest https://github.com/shaarli/Shaarli.git .
-$ composer install --no-dev --prefer-dist
-$ make build_frontend
-$ make translate
-$ make htmldoc
-```
-
---------------------------------------------------------------------------------
-
-## Stable version
-
-The stable version has been experienced by Shaarli users, and will receive security updates.
-
-
-### Download as an archive
-
-As a .zip archive:
-
-```bash
-$ wget https://github.com/shaarli/Shaarli/archive/stable.zip
-$ unzip stable.zip
-$ mv Shaarli-stable /path/to/shaarli/
-```
-
-As a .tar.gz archive :
-
-```bash
-$ wget https://github.com/shaarli/Shaarli/archive/stable.tar.gz
-$ tar xvf stable.tar.gz
-$ mv Shaarli-stable /path/to/shaarli/
-```
-
-### Using git
-
-Install [Composer](Unit-tests.md#install_composer) to manage Shaarli dependencies.
-
-```bash
-$ git clone https://github.com/shaarli/Shaarli.git -b stable /path/to/shaarli/
-# install/update third-party dependencies
-$ cd /path/to/shaarli/
-$ composer install --no-dev --prefer-dist
-```
-
-
---------------------------------------------------------------------------------
-
-## Development version (mainline)
-
-_Use at your own risk!_
-
-Install [Composer](Unit-tests.md#install_composer) to manage Shaarli PHP dependencies,
-and [yarn](https://yarnpkg.com/lang/en/docs/install/)
-for front-end dependencies.
-
-To get the latest changes from the `master` branch:
-
-```bash
-# clone the repository
-$ git clone https://github.com/shaarli/Shaarli.git -b master /path/to/shaarli/
-# install/update third-party dependencies
-$ cd /path/to/shaarli
-$ composer install --no-dev --prefer-dist
-$ make build_frontend
-$ make translate
-$ make htmldoc
-```
-
--------------------------------------------------------------------------------
-
-## Finish Installation
-
-Once Shaarli is downloaded and files have been placed at the correct location, open it this location your favorite browser.
-
-![install screenshot](images/install-shaarli.png)
-
-Setup your Shaarli installation, and it's ready to use!
-
-## Updating Shaarli
-
-See [Upgrade and Migration](Upgrade-and-migration)
diff --git a/doc/md/FAQ.md b/doc/md/FAQ.md
deleted file mode 100644 (file)
index a2ec7d5..0000000
+++ /dev/null
@@ -1,46 +0,0 @@
-### Why did you create Shaarli ?
-
-I was a StumbleUpon user. Then I got fed up with they big toolbar. I switched to delicious, which was lighter, faster and more beautiful. Until Yahoo bought it. Then the export API broke all the time, delicious became slow and was ditched by Yahoo. I switched to Diigo, which is not bad, but does too much. And Diigo is sslllooooowww and their Firefox extension a bit buggy. And… oh… **their Firefox addon sends to Diigo every single URL you visit** (Don't believe me ? Use [Tamper Data](https://addons.mozilla.org/en-US/firefox/addon/tamper-data/) and open any page).
-
-Enough is enough. Saving simple links should not be a complicated heavy thing. I ditched them all and wrote my own: Shaarli. It's simple, but it does the job and does it well. And my data is not hosted on a foreign server, but on my server.
-
-### Why use Shaarli and not Delicious/Diigo ?
-
-With Shaarli:
-
-- The data is yours: It's hosted on your server.
-- Never fear of having your data locked-in.
-- Never fear to have your data sold to third party.
-- Your private links are not hosted on a third party server.
-- You are not tracked by browser addons (like Diigo does)
-- You can change the look and feel of the pages if you want.
-- You can change the behaviour of the program.
-- It's magnitude faster than most bookmarking services.
-
-### What does Shaarli mean?
-
-Shaarli stands for _shaaring_ your _links_.
-
-### My Shaarli is broken!
-First of all, ensure that both the [web server](Server-configuration) and
-[Shaarli](Shaarli-configuration) are correctly configured, and that your
-installation is [supported](Server-configuration).
-
-If everything looks right but the issue(s) remain(s), please:
-
-- take a look at the [troubleshooting](Troubleshooting) section
-- come [chat with us](https://gitter.im/shaarli/Shaarli) on Gitter, we'll be happy to help ;-)
-- browse active [issues](https://github.com/shaarli/Shaarli/issues) and [Pull Requests](https://github.com/shaarli/Shaarli/pulls)
-    - if you find one that is related to the issue, feel free to comment and provide additional details (host/Shaarli setup)
-    - else, [open a new issue](https://github.com/shaarli/Shaarli/issues/new), and provide information about the problem:
-        - _what happens?_ - display glitches, invalid data, security flaws...
-        - _what is your configuration?_  - OS, server version, activated extensions, web browser...
-        - _is it reproducible?_
-
-### Why not use a real database? Files are slow!
-
-Does browsing [this page](http://sebsauvage.net/links/) feel slow? Try browsing older pages, too.
-
-It's not slow at all, is it? And don't forget the database contains more than 16000 links, and it's on a shared host, with 32000 visitors/day for my website alone. And it's still damn fast. Why?
-
-The data file is only 3.7 Mb. It's read 99% of the time, and is probably already in the operation system disk cache. So generating a page involves no I/O at all most of the time.
diff --git a/doc/md/Installation.md b/doc/md/Installation.md
new file mode 100644 (file)
index 0000000..11b5da8
--- /dev/null
@@ -0,0 +1,78 @@
+# Installation
+
+Once your server is [configured](Server-configuration.md), install Shaarli:
+
+## From release ZIP
+
+To install Shaarli, simply place the files from the latest [release .zip archive](https://github.com/shaarli/Shaarli/releases) under your webserver's document root (directly at the document root, or in a subdirectory). Download the **shaarli-vX.X.X-full** archive to include dependencies.
+
+```bash
+wget https://github.com/shaarli/Shaarli/releases/download/v0.11.1/shaarli-v0.11.1-full.zip
+unzip shaarli-v0.11.1-full.zip
+sudo rsync -avP Shaarli/ /var/www/shaarli.mydomain.org/
+```
+
+## From sources
+
+These components are required to build Shaarli:
+
+- [Composer](dev/Development.md#install-composer) to manage third-party [PHP dependencies](dev/Development#third-party-libraries).
+- [yarn](https://yarnpkg.com/lang/en/docs/install/) to build frontend dependencies.
+- [python3-virtualenv](https://pypi.python.org/pypi/virtualenv) to build local HTML documentation.
+
+Clone the repository, either pointing to:
+
+- any [tagged release](https://github.com/shaarli/Shaarli/releases)
+- `latest`: the latest tagged release
+- `master`: development branch
+
+```bash
+# clone the branch/tag of your choice
+$ git clone -b latest https://github.com/shaarli/Shaarli.git /home/me/Shaarli
+# OR download/extract the tar.gz/zip: wget https://github.com/shaarli/Shaarli/archive/latest.tar.gz...
+
+# enter the directory
+$ cd /home/me/Shaarli
+# install 3rd-party PHP dependencies
+$ composer install --no-dev --prefer-dist
+# build frontend static assets
+$ make build_frontend
+# build translations
+$ make translate
+# build HTML documentation
+$ make htmldoc
+# copy the resulting shaarli directory under your webserver's document root
+$ rsync -avP /home/me/Shaarli/ /var/www/shaarli.mydomain.org/
+```
+
+## Set file permissions
+
+Regardless of the installation method, appropriate [file permissions](dev/Development.md#directory-structure) must be set:
+
+```bash
+sudo chown -R root:www-data /var/www/shaarli.mydomain.org
+sudo chmod -R g+rX /var/www/shaarli.mydomain.org
+sudo chmod -R g+rwX /var/www/shaarli.mydomain.org/{cache/,data/,pagecache/,tmp/}
+```
+
+## Using Docker
+
+[See the documentation](Docker.md)
+
+
+## Finish Installation
+
+Once Shaarli is downloaded and files have been placed at the correct location, open this location your web browser.
+
+Enter basic settings for your Shaarli installation, and it's ready to use!
+
+![](images/07-installation.jpg)
+
+Congratulations! Your Shaarli is now available at `https://shaarli.mydomain.org`.
+
+You can further [configure Shaarli](Shaarli-configuration.md), setup [Plugins](Plugins.md) or [additional software](Community-and-related-software.md).
+
+
+## Upgrading Shaarli
+
+See [Upgrade and Migration](Upgrade-and-migration)
diff --git a/doc/md/Link-structure.md b/doc/md/Link-structure.md
deleted file mode 100644 (file)
index 0a2d0f8..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-## Link structure
-
-Every link available through the `LinkDB` object is represented as an array 
-containing the following fields:
-
-  * `id` (integer): Unique identifier.
-  * `title` (string): Title of the link.
-  * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.).  
-           Can be absolute or relative for Notes.
-  * `real_url` (string): Real destination URL, can be redirected, encoded, etc.
-  * `shorturl` (string): Permalink small hash.
-  * `description` (string): Link text description.
-  * `private` (boolean): whether the link is private or not.
-  * `tags` (string): all link tags separated by a single space
-  * `thumbnail` (string|boolean): relative path of the thumbnail cache file, or false if there isn't any.
-  * `created` (DateTime): link creation date time.
-  * `updated` (DateTime): last modification date time.
-  
\ No newline at end of file
index 3e261815252ac38473dace088a6c40fa7574d573..a9f5f1a84e0bf8f248ae18c438cfde98d649935c 100644 (file)
@@ -1,14 +1,13 @@
-## Plugin installation
+# Plugins
 
-There is a bunch of plugins shipped with Shaarli, where there is nothing to do to install them.
+## Installation
 
-If you want to install a third party plugin:
+For plugins shipped with Shaarli, no installation is required.
 
-- Download it.
-- Put it in the `plugins` directory in Shaarli's installation folder.
-- Make sure you put it correctly:
+If you want to install a third party plugin, download it to the `plugins` directory in Shaarli's installation folder:
 
-```
+```bash
+# example directory structure
 | index.php
 | plugins/
 |---| custom_plugin/
@@ -17,63 +16,47 @@ If you want to install a third party plugin:
 
 ```
 
-  * Make sure your webserver can read and write the files in your plugin folder.
+Make sure your webserver can read and write the files in your plugin folder.
 
-## Plugin configuration
 
-In Shaarli's administration page (`Tools` link), go to `Plugin administration`.
+## Configuration
 
-Here you can enable and disable all plugins available, and configure them.
+From Shaarli's administration page (`Tools` link), go to `Plugin administration`. Here you can enable and disable all plugins available, and configure them.
 
 ![administration screenshot](https://camo.githubusercontent.com/5da68e191969007492ca0fbeb25f3b2357b748cc/687474703a2f2f692e696d6775722e636f6d2f766837544643712e706e67)
 
-## Plugin order
+
+## Order
 
 In the plugin administration page, you can move enabled plugins to the top or bottom of the list. The first plugins in the list will be processed first.
 
-This is important in case plugins are depending on each other. Read plugins README details for more information.
+This is important in case plugins depend on each other. Read plugins READMEs for more information.
 
 **Use case**: The (non existent) plugin `shaares_footer` adds a footer to every shaare in Markdown syntax. It needs to be processed *before* (higher in the list) the Markdown plugin. Otherwise its syntax won't be translated in HTML.
 
-## File mode
 
-Enabled plugin are stored in your `config.json.php` parameters file, under the `array`:
+## Configuration file
 
-```php
-$GLOBALS['config']['ENABLED_PLUGINS']
-```
+Enabled plugins are stored in your [Configuration file](Shaarli-configuration).
 
-You can edit them manually here.
-Example:
+## Usage
 
-```php
-$GLOBALS['config']['ENABLED_PLUGINS'] = array(
-    'qrcode',
-    'archiveorg',
-    'wallabag',
-    'markdown',
-);
-```
-
-### Plugin usage
-
-#### Official plugins
+### Official plugins
 
 Usage of each plugin is documented in it's README file:
 
- * `addlink-toolbar`: Adds the addlink input on the linklist page
- * `archiveorg`: For each link, add an Archive.org icon
+ * `addlink-toolbar`: Adds the addlink input on the Shaares list page
+ * `archiveorg`: For each Shaare, add a link to the archived page on Archive.org
  * `default_colors`: Override default theme colors.
  * `isso`: Let visitor comment your shaares on permalinks with Isso.
  * [`markdown`](https://github.com/shaarli/Shaarli/blob/master/plugins/markdown/README.md): Render shaare description with Markdown syntax.
  * `piwik`: A plugin that adds Piwik tracking code to Shaarli pages.
  * [`playvideos`](https://github.com/shaarli/Shaarli/blob/master/plugins/playvideos/README.md): Add a button in the toolbar allowing to watch all videos.
  * `pubsubhubbub`: Enable PubSubHubbub feed publishing
- * `qrcode`: For each link, add a QRCode icon.
- * [`wallabag`](https://github.com/shaarli/Shaarli/blob/master/plugins/wallabag/README.md):  For each link, add a Wallabag icon to save it in your instance.
-
+ * `qrcode`: For each Shaare, add a QRCode icon.
+ * [`wallabag`](https://github.com/shaarli/Shaarli/blob/master/plugins/wallabag/README.md):  For each Shaare, add a Wallabag icon to save it in your instance.
 
 
-#### Third party plugins
+### Third party plugins
 
-See [Community & related software](https://shaarli.readthedocs.io/en/master/Community-&-Related-software/)
+See [Community & related software](https://shaarli.readthedocs.io/en/master/Community-and-Related-software/)
index 11bd1cd22f9a62a275ef3b5ce8570849545bb00e..01071d8e550775d7836d99a6129ca5871a7a3d27 100644 (file)
-## Usage and Prerequisites
+# REST API
 
-See the [REST API documentation](http://shaarli.github.io/api-documentation/)
-for a list of available endpoints and parameters.
+## Server requirements
 
-Please ensure that your server meets the
-[requirements](Server-configuration#prerequisites) and is properly
-[configured](Server-configuration):
+See the **[REST API documentation](http://shaarli.github.io/api-documentation/)** for a list of available endpoints and parameters.
+
+Please ensure that your server meets the requirements and is properly [configured](Server-configuration):
 
 - URL rewriting is enabled (see specific Apache and Nginx sections)
 - the server's timezone is properly defined
-- the server's clock is synchronized with
-  [NTP](https://en.wikipedia.org/wiki/Network_Time_Protocol)
-
-The host where the API client is invoked should also be synchronized with NTP,
-see [token expiration](#payload).
-
-## Authentication
-
-All requests to Shaarli's API must include a JWT token to verify their authenticity.
-
-This token has to be included as an HTTP header called `Authentication: Bearer <jwt token>`.
-
-JWT resources :
-
-- [jwt.io](https://jwt.io) (including a list of client per language).
-- RFC : https://tools.ietf.org/html/rfc7519
-- https://float-middle.com/json-web-tokens-jwt-vs-sessions/
-- HackerNews thread: https://news.ycombinator.com/item?id=11929267
-
-
-### Shaarli JWT Token
-
-JWT tokens are composed by three parts, separated by a dot `.` and encoded in base64:
-
-```
-[header].[payload].[signature]
-```
-
-#### Header
-
-Shaarli only allow one hash algorithm, so the header will always be the same:
-
-```json
-{
-    "typ": "JWT",
-    "alg": "HS512"
-}
-```
-
-Encoded in base64, it gives:
-
-```
-ewogICAgICAgICJ0eXAiOiAiSldUIiwKICAgICAgICAiYWxnIjogIkhTNTEyIgogICAgfQ==
-```
-
-#### Payload
-
-**Token expiration**
-
-To avoid infinite token validity, JWT tokens must include their creation date
-in UNIX timestamp format (timezone independent - UTC) under the key `iat` (issued at).
-This token will be valid during **9 minutes**.
-
-```json
-{
-    "iat": 1468663519
-}
-```
-
-See [RFC reference](https://tools.ietf.org/html/rfc7519#section-4.1.6).
-
+- the server's clock is synchronized with [NTP](https://en.wikipedia.org/wiki/Network_Time_Protocol)
 
-#### Signature
-
-The signature authenticate the token validity. It contains the base64 of the header and the body, separated by a dot `.`, hashed in SHA512 with the API secret available in Shaarli administration page.
-
-Signature example with PHP:
-
-```php
-$content = base64_encode($header) . '.' . base64_encode($payload);
-$signature = hash_hmac('sha512', $content, $secret);
-```
+The host where the API client is invoked should also be synchronized with NTP, see _payload/token expiration_
 
 
 ## Clients and examples
-### Android, Java, Kotlin
-
-- [Android client example with Kotlin](https://gitlab.com/snippets/1665808)
-  by [Braincoke](https://github.com/Braincoke)
-
-### Javascript, NodeJS
 
-- [shaarli-client](https://www.npmjs.com/package/shaarli-client)
-  ([source code](https://github.com/laBecasse/shaarli-client))
-  by [laBecasse](https://github.com/laBecasse)
+- **[python-shaarli-client](https://github.com/shaarli/python-shaarli-client)** - the reference API client ([Documentation](http://python-shaarli-client.readthedocs.io/en/latest/))
+- [shaarli-client](https://www.npmjs.com/package/shaarli-client) - NodeJs client ([source code](https://github.com/laBecasse/shaarli-client)) by [laBecasse](https://github.com/laBecasse)
+- [Android client example with Kotlin](https://gitlab.com/snippets/1665808) by [Braincoke](https://github.com/Braincoke)
 
-### PHP
 
 This example uses the [PHP cURL](http://php.net/manual/en/book.curl.php) library.
 
@@ -145,13 +68,57 @@ function getInfo($baseUrl, $secret) {
 var_dump(getInfo($baseUrl, $secret));
 ```
 
+## Implementation
+
+### Authentication
+
+- All requests to Shaarli's API must include a **JWT token** to verify their authenticity.
+- This token must be included as an HTTP header called `Authentication: Bearer <jwt token>`.
+- JWT tokens are composed by three parts, separated by a dot `.` and encoded in base64:
+
+```
+[header].[payload].[signature]
+```
+
+##### Header
+
+Shaarli only allow one hash algorithm, so the header will always be the same:
+
+```json
+{
+    "typ": "JWT",
+    "alg": "HS512"
+}
+```
+
+Encoded in base64, it gives:
 
-### Python
+```
+ewogICAgICAgICJ0eXAiOiAiSldUIiwKICAgICAgICAiYWxnIjogIkhTNTEyIgogICAgfQ==
+```
+
+##### Payload
+
+Token expiration: To avoid infinite token validity, JWT tokens must include their creation date in UNIX timestamp format (timezone independent - UTC) under the key `iat` (issued at) field ([1](https://tools.ietf.org/html/rfc7519#section-4.1.6)). This token will be valid during **9 minutes**.
+
+```json
+{
+    "iat": 1468663519
+}
+```
+
+##### Signature
+
+The signature authenticates the token validity. It contains the base64 of the header and the body, separated by a dot `.`, hashed in SHA512 with the API secret available in Shaarli administration page.
+
+Example signature with PHP:
+
+```php
+$content = base64_encode($header) . '.' . base64_encode($payload);
+$signature = hash_hmac('sha512', $content, $secret);
+```
 
-See the reference API client:
 
-- [Documentation](http://python-shaarli-client.readthedocs.io/en/latest/) on ReadTheDocs
-- [python-shaarli-client](https://github.com/shaarli/python-shaarli-client) on Github
 
 ## Troubleshooting
 
@@ -171,3 +138,13 @@ to get the actual error message in the HTTP response body with:
   }
 }
 ```
+
+## References
+
+- [jwt.io](https://jwt.io) (including a list of client per language).
+- [RFC - JSON Web Token (JWT)](https://tools.ietf.org/html/rfc7519)
+- [JSON Web Tokens (JWT) vs Sessions](https://float-middle.com/json-web-tokens-jwt-vs-sessions/), [HackerNews thread](https://news.ycombinator.com/item?id=11929267)
+
+
+
+
diff --git a/doc/md/RSS-feeds.md b/doc/md/RSS-feeds.md
deleted file mode 100644 (file)
index d943218..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-### Feeds options
-
-Feeds are available in ATOM with `?do=atom` and RSS with `do=RSS`.
-
-Options:
-
-- You can use `permalinks` in the feed URL to get permalink to Shaares instead of direct link to shaared URL.
-    - E.G. `https://my.shaarli.domain/?do=atom&permalinks`.
-- You can use `nb` parameter in the feed URL to specify the number of Shaares you want in a feed (default if not specified: `50`). The keyword `all` is available if you want everything.
-    - `https://my.shaarli.domain/?do=atom&permalinks&nb=42`
-    - `https://my.shaarli.domain/?do=atom&permalinks&nb=all`
-
-### RSS Feeds or Picture Wall for a specific search/tag
-
-It is possible to filter RSS/ATOM feeds and Picture Wall on a Shaarli to **only display results of a specific search, or for a specific tag**.
-
-For example, if you want to subscribe only to links tagged `photography`:
-
-- Go to the desired Shaarli instance.
-- Search for the `photography` tag in the _Filter by tag_ box. Links tagged `photography` are displayed.
-- Click on the `RSS Feed` button.
-- You are presented with an RSS feed showing only these links. Subscribe to it to receive only updates with this tag.
-- The same method **also works for a full-text search** (_Search_ box) **and for the Picture Wall** (want to only see pictures about `nature`?)
-- You can also build the URLs manually: 
-    - `https://my.shaarli.domain/?do=rss&searchtags=nature`
-    - `https://my.shaarli.domain/links/?do=picwall&searchterm=poney`
-
-![](images/rss-filter-1.png) ![](images/rss-filter-2.png)
diff --git a/doc/md/Release-Shaarli.md b/doc/md/Release-Shaarli.md
deleted file mode 100644 (file)
index e22eabc..0000000
+++ /dev/null
@@ -1,161 +0,0 @@
-See  [Git - Maintaining a project - Tagging your 
-releases](http://git-scm.com/book/en/v2/Distributed-Git-Maintaining-a-Project#Tagging-Your-Releases).
-
-## Prerequisites
-This guide assumes that you have:
-
-- a GPG key matching your GitHub authentication credentials
-    - i.e., the email address identified by the GPG key is the same as the one in your `~/.gitconfig` 
-- a GitHub fork of Shaarli
-- a local clone of your Shaarli fork, with the following remotes:
-    - `origin` pointing to your GitHub fork
-    - `upstream` pointing to the main Shaarli repository
-- maintainer permissions on the main Shaarli repository, to:
-    - push the signed tag
-    - create a new release
-- [Composer](https://getcomposer.org/) needs to be installed
-- The [venv](https://docs.python.org/3/library/venv.html) Python 3 module needs to be installed for HTML documentation generation.
-
-## GitHub release draft and `CHANGELOG.md`
-See http://keepachangelog.com/en/0.3.0/ for changelog formatting.
-
-### GitHub release draft
-GitHub allows drafting the release note for the upcoming release, from the [Releases](https://github.com/shaarli/Shaarli/releases) page. This way, the release note can be drafted while contributions are merged to `master`.
-
-### `CHANGELOG.md`
-This file should contain the same information as the release note draft for the upcoming version.
-
-Update it to:
-
-- add new entries (additions, fixes, etc.)
-- mark the current version as released by setting its date and link
-- add a new section for the future unreleased version
-
-```bash
-$ cd /path/to/shaarli
-
-$ nano CHANGELOG.md
-
-[...]
-## vA.B.C - UNRELEASED
-TBA
-
-## [vX.Y.Z](https://github.com/shaarli/Shaarli/releases/tag/vX.Y.Z) - YYYY-MM-DD
-[...]
-```
-
-
-## Increment the version code, update docs, create and push a signed tag
-### Update the list of Git contributors
-```bash
-$ make authors
-$ git commit -s -m "Update AUTHORS"
-```
-
-### Create and merge a Pull Request
-This one is pretty straightforward ;-)
-
-### Bump Shaarli version to v0.x branch
-
-```bash
-$ git checkout master
-$ git fetch upstream
-$ git pull upstream master
-
-# IF the branch doesn't exists
-$ git checkout -b v0.5
-# OR if the branch already exists
-$ git checkout v0.5
-$ git rebase upstream/master
-
-# Bump shaarli version from dev to 0.5.0, **without the `v`**
-$ vim shaarli_version.php
-$ git add shaarli_version
-$ git commit -s -m "Bump Shaarli version to v0.5.0"
-$ git push upstream v0.5
-```
-
-### Create and push a signed tag
-```bash
-# update your local copy
-$ git checkout v0.5
-$ git fetch upstream
-$ git pull upstream v0.5
-
-# create a signed tag
-$ git tag -s -m "Release v0.5.0" v0.5.0
-
-# push it to "upstream"
-$ git push --tags upstream
-```
-
-### Verify a signed tag
-[`v0.5.0`](https://github.com/shaarli/Shaarli/releases/tag/v0.5.0) is the first GPG-signed tag pushed on the Community Shaarli.
-
-Let's have a look at its signature!
-
-```bash
-$ cd /path/to/shaarli
-$ git fetch upstream
-
-# get the SHA1 reference of the tag
-$ git show-ref tags/v0.5.0
-f7762cf803f03f5caf4b8078359a63783d0090c1 refs/tags/v0.5.0
-
-# verify the tag signature information
-$ git verify-tag f7762cf803f03f5caf4b8078359a63783d0090c1
-gpg: Signature made Thu 30 Jul 2015 11:46:34 CEST using RSA key ID 4100DF6F
-gpg: Good signature from "VirtualTam <virtualtam@flibidi.net>" [ultimate]
-```
-
-## Publish the GitHub release
-### Update release badges
-Update `README.md` so version badges display and point to the newly released Shaarli version(s), in the `master` branch.
-
-### Create a GitHub release from a Git tag
-From the previously drafted release:
-
-- edit the release notes (if needed)
-- specify the appropriate Git tag
-- publish the release
-- profit!
-
-### Generate and upload all-in-one release archives
-Users with a shared hosting may have:
-
-- no SSH access
-- no possibility to install PHP packages or server extensions
-- no possibility to run scripts
-
-To ease Shaarli installations, it is possible to generate and upload additional release archives,
-that will contain Shaarli code plus all required third-party libraries.
-
-**From the `v0.5` branch:**
-
-```bash
-$ make release_archive
-```
-
-This will create the following archives:
-
-- `shaarli-vX.Y.Z-full.tar`
-- `shaarli-vX.Y.Z-full.zip`
-
-The archives need to be manually uploaded on the previously created GitHub release.
-
-### Update `stable` and `latest` branches
-
-```
-$ git checkout latest
-# latest release
-$ git merge v0.5.0
-# fix eventual conflicts
-$ make test
-$ git push upstream latest
-$ git checkout stable
-# latest previous major
-$ git merge v0.4.5 
-# fix eventual conflicts
-$ make test
-$ git push upstream stable
-```
diff --git a/doc/md/Reverse-proxy.md b/doc/md/Reverse-proxy.md
new file mode 100644 (file)
index 0000000..b7e347d
--- /dev/null
@@ -0,0 +1,141 @@
+# Reverse proxy
+
+If Shaarli is hosted on a server behind a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) (i.e. there is a proxy server between clients and the web server hosting Shaarli), configure it accordingly. See [Reverse proxy](Reverse-proxy.md) configuration. In this example:
+
+- The Shaarli application server exposes port `10080` to the proxy (for example docker container started with `--publish 127.0.0.1:10080:80`).
+- The Shaarli application server runs at `127.0.0.1` (container). Replace with the server's IP address if running on a different machine.
+- Shaarli's Fully Qualified Domain Name (FQDN) is `shaarli.mydomain.org`.
+- No HTTPS is setup on the application server, SSL termination is done at the reverse proxy.
+
+In your [Shaarli configuration](Shaarli-configuration) `data/config.json.php`, add the public IP of your proxy under `security.trusted_proxies`.
+
+See also [proxy-related](https://github.com/shaarli/Shaarli/issues?utf8=%E2%9C%93&q=label%3Aproxy+) issues.
+
+
+## Apache
+
+```apache
+<VirtualHost *:80>
+    ServerName shaarli.mydomain.org
+
+    # For SSL/TLS certificates acquired with certbot or self-signed certificates
+    # Redirect HTTP requests to HTTPS, except Let's Encrypt ACME challenge requests
+    RewriteEngine on
+    RewriteRule ^.well-known/acme-challenge/ - [L]
+    RewriteCond %{HTTP_HOST} =shaarli.mydomain.org
+    RewriteRule  ^ https://shaarli.mydomain.org%{REQUEST_URI} [END,NE,R=permanent]
+</VirtualHost>
+
+# SSL/TLS configuration for Let's Encrypt certificates managed with mod_md
+#MDomain shaarli.mydomain.org
+#MDCertificateAgreement accepted
+#MDContactEmail admin@shaarli.mydomain.org
+#MDPrivateKeys RSA 4096
+
+<VirtualHost *:443>
+    ServerName shaarli.mydomain.org
+
+    # SSL/TLS configuration for Let's Encrypt certificates acquired with certbot standalone
+    SSLEngine             on
+    SSLCertificateFile    /etc/letsencrypt/live/shaarli.mydomain.org/fullchain.pem
+    SSLCertificateKeyFile /etc/letsencrypt/live/shaarli.mydomain.org/privkey.pem
+    # Let's Encrypt settings from https://github.com/certbot/certbot/blob/master/certbot-apache/certbot_apache/_internal/tls_configs/current-options-ssl-apache.conf
+    SSLProtocol             all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
+    SSLCipherSuite          ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
+    SSLHonorCipherOrder     off
+    SSLSessionTickets       off
+    SSLOptions +StrictRequire
+
+    # SSL/TLS configuration for self-signed certificates
+    #SSLEngine             on
+    #SSLCertificateFile    /etc/ssl/certs/ssl-cert-snakeoil.pem
+    #SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
+
+    # let the proxied shaarli server/container know HTTPS URLs should be served
+    RequestHeader set X-Forwarded-Proto "https"
+
+    # send the original SERVER_NAME to the proxied host
+    ProxyPreserveHost On
+    
+    # pass requests to the proxied host
+    # sets X-Forwarded-For, X-Forwarded-Host and X-Forwarded-Server headers
+    ProxyPass        / http://127.0.0.1:10080/
+    ProxyPassReverse / http://127.0.0.1:10080/
+</VirtualHost>
+```
+
+
+## HAProxy
+
+
+```conf
+global
+    [...]
+
+defaults
+    [...]
+
+frontend http-in
+    bind :80
+    redirect scheme https code 301 if !{ ssl_fc }
+    bind :443 ssl crt /path/to/cert.pem
+    default_backend shaarli
+
+backend shaarli
+    mode http
+    option http-server-close
+    option forwardfor
+    reqadd X-Forwarded-Proto: https
+    server shaarli1 127.0.0.1:10080
+```
+
+- [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/)
+
+## Nginx
+
+
+```nginx
+http {
+    [...]
+
+    index index.html index.php;
+
+    root        /home/john/web;
+    access_log  /var/log/nginx/access.log combined;
+    error_log   /var/log/nginx/error.log;
+
+    server {
+        listen       80;
+        server_name  shaarli.mydomain.org;
+        # redirect HTTP to HTTPS
+        return       301 https://shaarli.mydomain.org$request_uri;
+    }
+
+    server {
+        listen       443 ssl http2;
+        server_name  shaarli.mydomain.org;
+
+        ssl_certificate       /path/to/certificate
+        ssl_certificate_key   /path/to/private/key
+
+        location / {
+            proxy_set_header  X-Real-IP         $remote_addr;
+            proxy_set_header  X-Forwarded-For   $proxy_add_x_forwarded_for;
+            proxy_set_header  X-Forwarded-Proto $scheme;
+            proxy_set_header  X-Forwarded-Host  $host;
+
+            # pass requests to the proxied host
+            proxy_pass             http://localhost:10080/;
+            proxy_set_header Host  $host;
+            proxy_connect_timeout  30s;
+            proxy_read_timeout     120s;
+        }
+    }
+}
+```
+
+## References
+
+- [`X-Forwarded-Proto`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Proto)
+- [`X-Forwarded-Host`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-Host)
+- [`X-Forwarded-For`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For)
diff --git a/doc/md/Security.md b/doc/md/Security.md
deleted file mode 100644 (file)
index 65db422..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-## Client browser
-- Shaarli relies on `HTTP_REFERER` for some functions (like redirects and clicking on tags). If you have disabled or masqueraded `HTTP_REFERER` in your browser, some features of Shaarli may not work
-
-## Server and sessions
-- Directories are protected using `.htaccess` files
-- Forms are protected against XSRF (Cross-site requests forgery):
-    - Forms which act on data (save,delete…) contain a token generated by the server.
-    - Any posted form which does not contain a valid token is rejected.
-    - Any token can only be used once.
-    - Tokens are attached to the session and cannot be reused in another session.
-- Sessions automatically expire after 60 minutes.
-- Sessions are protected against hijacking: the session ID cannot be used from a different IP address.
-
-## Shaarli datastore and configuration
-- The password is salted, hashed and stored in the data subdirectory, in a PHP file, and protected by htaccess. Even if the webserver does not support htaccess, the hash is not readable by URL. Even if the .php file is stolen, the password cannot deduced from the hash. The salt prevents rainbow-tables attacks.
-- Links are stored as an associative array which is serialized, compressed (with deflate), base64-encoded and saved as a comment in a `.php` file.
-- Even if the server does not support `.htaccess` files, the data file will still not be readable by URL.
-- The database looks like this:
-
-```php
-<?php /* zP1ZjxxJtiYIvvevEPJ2lDOaLrZv7o...
-...ka7gaco/Z+TFXM2i7BlfMf8qxpaSSYfKlvqv/x8= */ ?>
-```
-
-- Small hashes are used to make a link to an entry in Shaarli. They are unique. In fact, the date of the items (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 `@`.
index f9ea2ed2145801f79c99819750f06b700b7876c8..4e74d80bb28d92f4899eda3fda1610967e2bd1a5 100644 (file)
@@ -1,20 +1,48 @@
+# Server configuration
 
-- [Prerequisites](#prerequisistes)
-- [Apache](#apache)
-- [Nginx](#nginx)
-- [Proxies](#proxies)
-- [See also](#see-also)
+## Requirements
 
-## Prerequisites
-### Shaarli
+### Operating system and web server
 
-- A web server and PHP interpreter module/service have been installed.
-- You have write access to the Shaarli installation directory.
-- The correct read/write permissions have been granted to the web server user and group.
-- Your PHP interpreter is compatible with supported PHP versions:
+Shaarli can be hosted on dedicated/virtual servers, or shared hosting.
+
+You need write access to the Shaarli installation directory - you should have received instructions from your hosting provider on how to connect to the server using SSH (or FTP for shared hosts).
+
+Examples in this documentation are given for [Debian](https://www.debian.org/), a GNU/Linux distribution widely used in server environments. Please adapt them to your specific Linux distribution.
+
+A $5/month VPS (1 CPU, 1 GiB RAM and 25 GiB SSD) will run any Shaarli installation without problems. Some hosting providers: [DigitalOcean](https://www.digitalocean.com/) ([1](https://www.digitalocean.com/docs/droplets/overview/), [2](https://www.digitalocean.com/pricing/), [3](https://www.digitalocean.com/docs/droplets/how-to/create/), [4](https://www.digitalocean.com/docs/droplets/how-to/add-ssh-keys/), [5](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-debian-8), [6](https://www.digitalocean.com/community/tutorials/an-introduction-to-securing-your-linux-vps)), [Gandi](https://www.gandi.net/en), [OVH](https://www.ovh.co.uk/), [RackSpace](https://www.rackspace.com/), etc.
+
+
+### Network and domain name
+
+Try to host the server in a region that is geographically close to your users.
+
+A **domain name** ([DNS record](https://opensource.com/article/17/4/introduction-domain-name-system-dns)) pointing to the server's public IP address is required to obtain a SSL/TLS certificate and setup HTTPS to secure client traffic to your Shaarli instance.
+
+You can obtain a domain name from a [registrar](https://en.wikipedia.org/wiki/Domain_name_registrar) ([1](https://www.ovh.co.uk/domains), [2](https://www.gandi.net/en/domain)), or from free subdomain providers ([1](https://freedns.afraid.org/)). If you don't have a domain name, please set up a private domain name ([FQDN](ttps://en.wikipedia.org/wiki/Fully_qualified_domain_name)) in your clients' [hosts files](https://en.wikipedia.org/wiki/Hosts_(file)) to access the server (direct access by IP address can result in unexpected behavior).
+
+Setup a **firewall** (using `iptables`, [ufw](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-firewall-with-ufw-on-debian-10), [fireHOL](https://firehol.org/) or any frontend of your choice) to deny all incoming traffic except `tcp/80` and `tcp/443`, which are needed to access the web server (and any other posrts you might need, like SSH). If the server is in a private network behind a NAT, ensure these **ports are forwarded** to the server.
+
+Shaarli makes outbound HTTP/HTTPS connections to websites you bookmark to fetch page information (title, thumbnails), the server must then have access to the Internet as well, and a working DNS resolver.
+
+
+### Screencast
+
+Here is a screencast of the installation procedure
+
+[![asciicast](https://asciinema.org/a/z3RXxcJIRgWk0jM2ws6EnUFgO.svg)](https://asciinema.org/a/z3RXxcJIRgWk0jM2ws6EnUFgO)
+
+--------------------------------------------------------------------------------
+
+### PHP
+
+Supported PHP versions:
 
 Version | Status | Shaarli compatibility
 :---:|:---:|:---:
+8.0 | Supported | Yes
+7.4 | Supported | Yes
+7.3 | Supported | Yes
 7.2 | Supported | Yes
 7.1 | Supported | Yes
 7.0 | EOL: 2018-12-03 | Yes (up to Shaarli 0.10.x)
@@ -23,71 +51,132 @@ Version | Status | Shaarli compatibility
 5.4 | EOL: 2015-09-14 | Yes (up to Shaarli 0.8.x)
 5.3 | EOL: 2014-08-14 | Yes (up to Shaarli 0.8.x)
 
-- The following PHP extensions are installed on the server:
+Required PHP extensions:
 
 Extension | Required? | Usage
 ---|:---:|---
-[`openssl`](http://php.net/manual/en/book.openssl.php) | All | OpenSSL, HTTPS
+[`openssl`](http://php.net/manual/en/book.openssl.php) | required | OpenSSL, HTTPS
 [`php-json`](http://php.net/manual/en/book.json.php) | required | configuration parsing
+[`php-simplexml`](https://www.php.net/manual/en/book.simplexml.php) | required | REST API (Slim framework)
 [`php-mbstring`](http://php.net/manual/en/book.mbstring.php) | CentOS, Fedora, RHEL, Windows, some hosting providers | multibyte (Unicode) string support
 [`php-gd`](http://php.net/manual/en/book.image.php) | optional | required to use thumbnails
 [`php-intl`](http://php.net/manual/en/book.intl.php) | optional | localized text sorting (e.g. `e->è->f`)
 [`php-curl`](http://php.net/manual/en/book.curl.php) | optional | using cURL for fetching webpages and thumbnails in a more robust way
 [`php-gettext`](http://php.net/manual/en/book.gettext.php) | optional | Use the translation system in gettext mode (faster)
---------------------------------------------------------------------------------
 
-### SSL/TLS configuration 
+Some [plugins](Plugins.md) may require additional configuration.
+
+- [PHP: Supported versions](http://php.net/supported-versions.php)
+- [PHP: Unsupported versions (EOL/End-of-life)](http://php.net/eol.php)
+- [PHP 7 Changelog](http://php.net/ChangeLog-7.php)
+- [PHP 5 Changelog](http://php.net/ChangeLog-5.php)
+- [PHP: Bugs](https://bugs.php.net/)
+
+
+## SSL/TLS (HTTPS)
 
-To setup HTTPS / SSL on your webserver (recommended), you must generate a public/private **key pair** and a **certificate**, and install, configure and activate the appropriate **webserver SSL extension**.
+We recommend setting up [HTTPS](https://en.wikipedia.org/wiki/HTTPS) (SSL/[TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security)) on your webserver for secure communication between clients and the server.
 
-#### Let's Encrypt
+### Let's Encrypt
 
-[Let's Encrypt](https://en.wikipedia.org/wiki/Let%27s_Encrypt) is a certificate authority that provides free TLS/X.509 certificates via an automated process.
+For public-facing web servers this can be done using free SSL/TLS certificates from [Let's Encrypt](https://en.wikipedia.org/wiki/Let's_Encrypt), a non-profit certificate authority provididing free certificates.
 
- * Install `certbot` using the appropriate method described on https://certbot.eff.org/.
-Location of the `certbot` program and template configuration files may vary depending on which installation method was used. Change the file paths below accordingly. Here is an easy way to create a signed certificate using `certbot`, it assumes `certbot` was installed through APT on a Debian-based distribution:
+ - [How to secure Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-debian-10)
+ - [How to secure Nginx with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-debian-10)
+ - [How To Use Certbot Standalone Mode to Retrieve Let's Encrypt SSL Certificates](https://www.digitalocean.com/community/tutorials/how-to-use-certbot-standalone-mode-to-retrieve-let-s-encrypt-ssl-certificates-on-debian-10).
 
- * Stop the apache2/nginx service.
- * Run `certbot --agree-tos --standalone --preferred-challenges tls-sni --email "youremail@example.com" --domain yourdomain.example.com`
- * For the Apache webserver, copy `/usr/lib/python2.7/dist-packages/certbot_apache/options-ssl-apache.conf` to `/etc/letsencrypt/options-ssl-apache.conf` (paths may vary depending on installation method)
- * For Nginx: TODO
- * Setup your webserver as described below
- * Restart the apache2/nginx service.
+In short:
 
-#### Self-signed certificates
+```bash
+# install certbot
+sudo apt install certbot
 
-If you don't want to request a certificate from Let's Encrypt, or are unable to (for example, webserver on a LAN, or domain name not registered in the public DNS system), you can generate a self-signed certificate. This certificate will trigger security warnings in web browsers, unless you add it to the browser's SSL store manually.
+# stop your webserver if you already have one running
+# certbot in standalone mode needs to bind to port 80 (only needed on initial generation)
+sudo systemctl stop apache2
+sudo systemctl stop nginx
 
-* Apache: run `make-ssl-cert generate-default-snakeoil --force-overwrite`
-* Nginx: TODO
+# generate initial certificates
+# Let's Encrypt ACME servers must be able to access your server! port forwarding and firewall must be properly configured
+sudo certbot certonly --standalone --noninteractive --agree-tos --email "admin@shaarli.mydomain.org" -d shaarli.mydomain.org
+# this will generate a private key and certificate at /etc/letsencrypt/live/shaarli.mydomain.org/{privkey,fullchain}.pem
+
+# restart the web server
+sudo systemctl start apache2
+sudo systemctl start nginx
+```
+
+On apache `2.4.43+`, you can also delegate LE certificate management to [mod_md](https://httpd.apache.org/docs/2.4/mod/mod_md.html) [[1](https://www.cyberciti.biz/faq/how-to-secure-apache-with-mod_md-lets-encrypt-on-ubuntu-20-04-lts/)] in which case you don't need certbot and manual SSL configuration in virtualhosts.
+
+### Self-signed
+
+If you don't want to rely on a certificate authority, or the server can only be accessed from your own network, you can also generate self-signed certificates. Not that this will generate security warnings in web browsers/clients trying to access Shaarli:
+
+- [How To Create a Self-Signed SSL Certificate for Apache](https://www.digitalocean.com/community/tutorials/how-to-create-a-self-signed-ssl-certificate-for-apache-on-debian-10)
+- [How To Create a Self-Signed SSL Certificate for Nginx](https://www.digitalocean.com/community/tutorials/how-to-create-a-self-signed-ssl-certificate-for-nginx-on-debian-10)
+- [How to Create Self-Signed SSL Certificates with OpenSSL](http://www.xenocafe.com/tutorials/linux/centos/openssl/self_signed_certificates/index.php)
+- [How do I create my own Certificate Authority?](https://workaround.org/certificate-authority)
 
 --------------------------------------------------------------------------------
 
-## Apache
+## Examples
 
-Here is a basic configuration example for the Apache web server with `mod_php`.
+The following examples assume a Debian-based operating system is installed. On other distributions you may have to adapt details such as package installation procedures, configuration file locations, and webserver username/group (`www-data` or `httpd` are common values). In these examples we assume the document root for your web server/virtualhost is at `/var/www/shaarli.mydomain.org/`:
 
-In `/etc/apache2/sites-available/shaarli.conf`:
+```bash
+# create the document root (replace with your own domain name)
+sudo mkdir -p /var/www/shaarli.mydomain.org/
+```
+
+You can install Shaarli at the root of your virtualhost, or in a subdirectory as well. See [Directory structure](Directory-structure)
+
+
+### Apache
+
+```bash
+# Install apache + mod_php and PHP modules
+sudo apt update
+sudo apt install apache2 libapache2-mod-php php-json php-mbstring php-gd php-intl php-curl php-gettext
+
+# Edit the virtualhost configuration file with your favorite editor (replace the example domain name)
+sudo nano /etc/apache2/sites-available/shaarli.mydomain.org.conf
+```
 
 ```apache
-<VirtualHost *:443>
-    ServerName   shaarli.my-domain.org
-    DocumentRoot /absolute/path/to/shaarli/
+<VirtualHost *:80>
+    ServerName shaarli.mydomain.org
+    DocumentRoot /var/www/shaarli.mydomain.org/
+
+    # For SSL/TLS certificates acquired with certbot or self-signed certificates
+    # Redirect HTTP requests to HTTPS, except Let's Encrypt ACME challenge requests
+    RewriteEngine on
+    RewriteRule ^.well-known/acme-challenge/ - [L]
+    RewriteCond %{HTTP_HOST} =shaarli.mydomain.org
+    RewriteRule  ^ https://shaarli.mydomain.org%{REQUEST_URI} [END,NE,R=permanent]
+</VirtualHost>
 
-    # Logging
-    # Possible values include: debug, info, notice, warn, error, crit, alert, emerg.
-    LogLevel  warn
-    ErrorLog  /var/log/apache2/shaarli-error.log
-    CustomLog /var/log/apache2/shaarli-access.log combined
+# SSL/TLS configuration for Let's Encrypt certificates managed with mod_md
+#MDomain shaarli.mydomain.org
+#MDCertificateAgreement accepted
+#MDContactEmail admin@shaarli.mydomain.org
+#MDPrivateKeys RSA 4096
 
-    # Let's Encrypt SSL configuration (recommended)
-    SSLEngine             on
-    SSLCertificateFile    /etc/letsencrypt/live/yourdomain.example.com/fullchain.pem
-    SSLCertificateKeyFile /etc/letsencrypt/live/yourdomain.example.com/privkey.pem
-    Include /etc/letsencrypt/options-ssl-apache.conf
+<VirtualHost *:443>
+    ServerName   shaarli.mydomain.org
+    DocumentRoot /var/www/shaarli.mydomain.org/
 
-    # Self-signed SSL cert configuration
+    # SSL/TLS configuration for Let's Encrypt certificates acquired with certbot standalone
+    SSLEngine             on
+    SSLCertificateFile    /etc/letsencrypt/live/shaarli.mydomain.org/fullchain.pem
+    SSLCertificateKeyFile /etc/letsencrypt/live/shaarli.mydomain.org/privkey.pem
+    # Let's Encrypt settings from https://github.com/certbot/certbot/blob/master/certbot-apache/certbot_apache/_internal/tls_configs/current-options-ssl-apache.conf
+    SSLProtocol             all -SSLv2 -SSLv3 -TLSv1 -TLSv1.1
+    SSLCipherSuite          ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
+    SSLHonorCipherOrder     off
+    SSLSessionTickets       off
+    SSLOptions +StrictRequire
+
+    # SSL/TLS configuration for self-signed certificates
     #SSLEngine             on
     #SSLCertificateFile    /etc/ssl/certs/ssl-cert-snakeoil.pem
     #SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
@@ -98,345 +187,283 @@ In `/etc/apache2/sites-available/shaarli.conf`:
     #php_value error_reporting 2147483647
     #php_value error_log /var/log/apache2/shaarli-php-error.log
 
-    <Directory /absolute/path/to/shaarli/>
-        #Required for .htaccess support
+    <Directory /var/www/shaarli.mydomain.org/>
+        # Required for .htaccess support
         AllowOverride All
-        Order allow,deny
-        Allow from all
-
-        Options Indexes FollowSymLinks MultiViews #TODO is Indexes/Multiviews required?
-
-        # Optional - required for playvideos plugin
-        #Header set Content-Security-Policy "script-src 'self' 'unsafe-inline' https://www.youtube.com https://s.ytimg.com 'unsafe-eval'"
+        Require all granted
     </Directory>
 
-</VirtualHost>
-```
-
-Enable this configuration with `sudo a2ensite shaarli`
+    <LocationMatch "/\.">
+        # Prevent accessing dotfiles
+        RedirectMatch 404 ".*"
+    </LocationMatch>
 
-_Note: If you use Apache 2.2 or lower, you need [mod_version](https://httpd.apache.org/docs/current/mod/mod_version.html) to be installed and enabled._
+    <LocationMatch "\.(?:ico|css|js|gif|jpe?g|png)$">
+        # allow client-side caching of static files
+        Header set Cache-Control "max-age=2628000, public, must-revalidate, proxy-revalidate"
+    </LocationMatch>
 
-_Note: Apache module `mod_rewrite` must be enabled to use the REST API._
+    # serve the Shaarli favicon from its custom location
+    Alias favicon.ico /var/www/shaarli.mydomain.org/images/favicon.ico
 
+</VirtualHost>
+```
 
-## Nginx
-
-Here is a basic configuration example for the Nginx web server, using the [php-fpm](http://php-fpm.org) PHP FastCGI Process Manager, and Nginx's [FastCGI](https://en.wikipedia.org/wiki/FastCGI) module.
-
-<!--- TODO refactor everything below this point --->
-
-### Common setup
-Once Nginx and PHP-FPM are installed, we need to ensure:
+```bash
+# Enable the virtualhost
+sudo a2ensite shaarli.mydomain.org
 
-- Nginx and PHP-FPM are running using the _same user and group_
-- both these user and group have
-    - `read` permissions for Shaarli resources
-    - `execute` permissions for Shaarli directories _AND_ their parent directories
+# mod_ssl must be enabled to use TLS/SSL certificates
+# https://httpd.apache.org/docs/current/mod/mod_ssl.html
+sudo a2enmod ssl
 
-On a production server:
+# mod_rewrite must be enabled to use the REST API
+# https://httpd.apache.org/docs/current/mod/mod_rewrite.html
+sudo a2enmod rewrite
 
-- `user:group` will likely be `http:http`, `www:www` or `www-data:www-data`
-- files will be located under `/var/www`, `/var/http` or `/usr/share/nginx`
+# mod_headers must be enabled to set custom headers from the server config
+sudo a2enmod headers
 
-On a development server:
+# mod_version must only be enabled if you use Apache 2.2 or lower
+# https://httpd.apache.org/docs/current/mod/mod_version.html
+# sudo a2enmod version
 
-- files may be located in a user's home directory
-- in this case, make sure both Nginx and PHP-FPM are running as the local user/group!
+# restart the apache service
+sudo systemctl restart apache2
+```
 
-For all following configuration examples, this user/group pair will be used:
+- [How to install the Apache web server](https://www.digitalocean.com/community/tutorials/how-to-install-the-apache-web-server-on-debian-10)
+- [Apache/PHP - error log per VirtualHost - StackOverflow](http://stackoverflow.com/q/176)
+- [Apache - PHP: php_value vs php_admin_value and the use of php_flag explained](https://ma.ttias.be/php-php_value-vs-php_admin_value-and-the-use-of-php_flag-explained/)
+- [Server-side TLS (Apache) - Mozilla](https://wiki.mozilla.org/Security/Server_Side_TLS#Apache)
+- [Apache 2.4 documentation](https://httpd.apache.org/docs/2.4/)
+- [Apache mod_proxy](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html)
+- [Apache Reverse Proxy Request Headers](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#x-headers)
 
-- `user:group = john:users`,
 
-which corresponds to the following service configuration:
+### Nginx
 
-```ini
-; /etc/php/php-fpm.conf
-user = john
-group = users
+This examples uses nginx and the [PHP-FPM](https://www.digitalocean.com/community/tutorials/how-to-install-linux-nginx-mariadb-php-lemp-stack-on-debian-10#step-3-%E2%80%94-installing-php-for-processing) PHP interpreter. Nginx and PHP-FPM must be running using the same user and group, here we assume the user/group to be `www-data:www-data`.
 
-[...]
-listen.owner = john
-listen.group = users
-```
 
-```nginx
-# /etc/nginx/nginx.conf
-user john users;
+```bash
+# install nginx and php-fpm
+sudo apt update
+sudo apt install nginx php-fpm
 
-http {
-    [...]
-}
+# Edit the virtualhost configuration file with your favorite editor
+sudo nano /etc/nginx/sites-available/shaarli.mydomain.org
 ```
 
-### (Optional) Increase the maximum file upload size
-Some bookmark dumps generated by web browsers can be _huge_ due to the presence of Base64-encoded images and favicons, as well as extra verbosity when nesting links in (sub-)folders.
-
-To increase upload size, you will need to modify both nginx and PHP configuration:
-
 ```nginx
-# /etc/nginx/nginx.conf
-
-http {
-    [...]
-
-    client_max_body_size 10m;
+server {
+    listen       80;
+    server_name  shaarli.mydomain.org;
 
-    [...]
+    # redirect all plain HTTP requests to HTTPS
+    return 301 https://shaarli.mydomain.org$request_uri;
 }
-```
 
-```ini
-# /etc/php/<PHP_VERSION>/fpm/php.ini
+server {
+    # ipv4 listening port/protocol
+    listen       443 ssl http2;
+    # ipv6 listening port/protocol
+    listen           [::]:443 ssl http2;
+    server_name  shaarli.mydomain.org;
+    root         /var/www/shaarli.mydomain.org;
+
+    # log file locations
+    # combined log format prepends the virtualhost/domain name to log entries
+    access_log  /var/log/nginx/access.log combined;
+    error_log   /var/log/nginx/error.log;
 
-[...]
-post_max_size = 10M
-[...]
-upload_max_filesize = 10M
-```
+    # paths to private key and certificates for SSL/TLS
+    ssl_certificate      /etc/ssl/shaarli.mydomain.org.crt;
+    ssl_certificate_key  /etc/ssl/private/shaarli.mydomain.org.key;
+
+    # Let's Encrypt SSL settings from https://github.com/certbot/certbot/blob/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf
+    ssl_session_cache shared:le_nginx_SSL:10m;
+    ssl_session_timeout 1440m;
+    ssl_session_tickets off;
+    ssl_protocols TLSv1.2 TLSv1.3;
+    ssl_prefer_server_ciphers off;
+    ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
+
+    # increase the maximum file upload size if needed: by default nginx limits file upload to 1MB (413 Entity Too Large error)
+    client_max_body_size 100m;
+
+    # relative path to shaarli from the root of the webserver
+    location / {
+        # default index file when no file URI is requested
+        index index.php;
+        try_files $uri /index.php$is_args$args;
+    }
 
-### Minimal
-_WARNING: Use for development only!_ 
+    location ~ (index)\.php$ {
+        try_files $uri =404;
+        # slim API - split URL path into (script_filename, path_info)
+        fastcgi_split_path_info ^(.+\.php)(/.+)$;
+        # pass PHP requests to PHP-FPM
+        fastcgi_pass   unix:/var/run/php-fpm/php-fpm.sock;
+        fastcgi_index  index.php;
+        include        fastcgi.conf;
+    }
 
-```nginx
-user john users;
-worker_processes  1;
-events {
-    worker_connections  1024;
-}
+    location ~ \.php$ {
+        # deny access to all other PHP scripts
+        # disable this if you host other PHP applications on the same virtualhost
+        deny all;
+    }
 
-http {
-    include            mime.types;
-    default_type       application/octet-stream;
-    keepalive_timeout  20;
-
-    index index.html index.php;
-
-    server {
-        listen       80;
-        server_name  localhost;
-        root         /home/john/web;
-
-        access_log  /var/log/nginx/access.log;
-        error_log   /var/log/nginx/error.log;
-
-        location /shaarli/ {
-            try_files $uri /shaarli/index.php$is_args$args;
-            access_log  /var/log/nginx/shaarli.access.log;
-            error_log   /var/log/nginx/shaarli.error.log;
-        }
-
-        location ~ (index)\.php$ {
-            try_files $uri =404;
-            fastcgi_split_path_info ^(.+\.php)(/.+)$;
-            fastcgi_pass   unix:/var/run/php-fpm/php-fpm.sock;
-            fastcgi_index  index.php;
-            include        fastcgi.conf;
-        }
+    location ~ /\. {
+        # deny access to dotfiles
+        deny all;
     }
-}
-```
 
-### Modular
-The previous setup is sufficient for development purposes, but has several major caveats:
+    location ~ ~$ {
+        # deny access to temp editor files, e.g. "script.php~"
+        deny all;
+    }
 
-- every content that does not match the PHP rule will be sent to client browsers:
-    - dotfiles - in our case, `.htaccess`
-    - temporary files, e.g. Vim or Emacs files: `index.php~`
-- asset / static resource caching is not optimized
-- if serving several PHP sites, there will be a lot of duplication: `location /shaarli/`, `location /mysite/`, etc.
+    location ~ /doc/ {
+        default_type "text/html";
+        try_files $uri $uri/ $uri.html =404;
+    }
 
-To solve this, we will split Nginx configuration in several parts, that will be included when needed:
+    location = /favicon.ico {
+        # serve the Shaarli favicon from its custom location
+        alias /var/www/shaarli/images/favicon.ico;
+    }
 
-```nginx
-# /etc/nginx/deny.conf
-location ~ /\. {
-    # deny access to dotfiles
-    access_log off;
-    log_not_found off;
-    deny all;
-}
+    # allow client-side caching of static files
+    location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
+        expires    max;
+        add_header Cache-Control "public, must-revalidate, proxy-revalidate";
+        # HTTP 1.0 compatibility
+        add_header Pragma public;
+    }
 
-location ~ ~$ {
-    # deny access to temp editor files, e.g. "script.php~"
-    access_log off;
-    log_not_found off;
-    deny all;
 }
 ```
 
-```nginx
-# /etc/nginx/php.conf
-location ~ (index)\.php$ {
-    # Slim - split URL path into (script_filename, path_info)
-    try_files $uri =404;
-    fastcgi_split_path_info ^(.+\.php)(/.+)$;
-
-    # filter and proxy PHP requests to PHP-FPM
-    fastcgi_pass   unix:/var/run/php-fpm/php-fpm.sock;
-    fastcgi_index  index.php;
-    include        fastcgi.conf;
-}
-
-location ~ \.php$ {
-    # deny access to all other PHP scripts
-    deny all;
-}
+```bash
+# enable the configuration/virtualhost
+sudo ln -s /etc/nginx/sites-available/shaarli.mydomain.org /etc/nginx/sites-enabled/shaarli.mydomain.org
+# reload nginx configuration
+sudo systemctl reload nginx
 ```
 
-```nginx
-# /etc/nginx/static_assets.conf
-location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
-    expires    max;
-    add_header Pragma public;
-    add_header Cache-Control "public, must-revalidate, proxy-revalidate";
-}
-```
+- [How to install the Nginx web server](https://www.digitalocean.com/community/tutorials/how-to-install-nginx-on-debian-10)
+- [Nginx Beginner's guide](http://nginx.org/en/docs/beginners_guide.html)
+- [Nginx documentation](https://nginx.org/en/docs/)
+- [Nginx ngx_http_fastcgi_module](http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html)
+- [Nginx Pitfalls](http://wiki.nginx.org/Pitfalls)
+- [Nginx PHP configuration examples - Karl Blessing](http://kbeezie.com/nginx-configuration-examples/)
+- [Server-side TLS (Nginx) - Mozilla](https://wiki.mozilla.org/Security/Server_Side_TLS#Nginx)
 
-```nginx
-# /etc/nginx/nginx.conf
-[...]
 
-http {
-    [...]
 
-    root        /home/john/web;
-    access_log  /var/log/nginx/access.log;
-    error_log   /var/log/nginx/error.log;
+## Reverse proxies
 
-    server {
-        # virtual host for a first domain
-        listen       80;
-        server_name  my.first.domain.org;
+If Shaarli is hosted on a server behind a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) (i.e. there is a proxy server between clients and the web server hosting Shaarli), configure it accordingly. See [Reverse proxy](Reverse-proxy.md) configuration.
 
-        location /shaarli/ {
-            # Slim - rewrite URLs
-            try_files $uri /shaarli/index.php$is_args$args;
+## Using Shaarli without URL rewriting
 
-            access_log  /var/log/nginx/shaarli.access.log;
-            error_log   /var/log/nginx/shaarli.error.log;
-        }
+By default, Shaarli uses Slim framework's URL, which requires
+URL rewriting.
 
-        location = /shaarli/favicon.ico {
-            # serve the Shaarli favicon from its custom location
-            alias /var/www/shaarli/images/favicon.ico;
-        }
+If you can't use URL rewriting for any reason (not supported by
+your web server, shared hosting, etc.), you *can* use Shaarli
+without URL rewriting.
 
-        include deny.conf;
-        include static_assets.conf;
-        include php.conf;
-    }
+You just need to prefix your URL by `/index.php/`.
+Example: instead of accessing `https://shaarli.mydomain.org/`,
+use `https://shaarli.mydomain.org/index.php/`.
 
-    server {
-        # virtual host for a second domain
-        listen       80;
-        server_name  second.domain.com;
+**Recommended:**
+  * after installation, in the configuration page, set your header link to `/index.php/`.
+  * in your configuration file `config.json.php` set `general.root_url` to
+    `https://shaarli.mydomain.org/index.php/`.
 
-        location /minigal/ {
-            access_log  /var/log/nginx/minigal.access.log;
-            error_log   /var/log/nginx/minigal.error.log;
-        }
+## Allow import of large browser bookmarks export
 
-        include deny.conf;
-        include static_assets.conf;
-        include php.conf;
-    }
-}
-```
+Web browser bookmark exports can be large due to the presence of base64-encoded images and favicons/long subfolder names. Edit the PHP configuration file.
 
-### Redirect HTTP to HTTPS
-Assuming you have generated a (self-signed) key and certificate, and they are
-located under `/home/john/ssl/localhost.{key,crt}`, it is pretty straightforward
-to set an HTTP (:80) to HTTPS (:443) redirection to force SSL/TLS usage.
+- Apache: `/etc/php/<PHP_VERSION>/apache2/php.ini`
+- Nginx + PHP-FPM: `/etc/php/<PHP_VERSION>/fpm/php.ini` (in addition to `client_max_body_size` in the [Nginx configuration](#nginx))
 
-```nginx
-# /etc/nginx/nginx.conf
+```ini
 [...]
+# (optional) increase the maximum file upload size:
+post_max_size = 100M
+[...]
+# (optional) increase the maximum file upload size:
+upload_max_filesize = 100M
+```
 
-http {
-    [...]
-
-    index index.html index.php;
-
-    root        /home/john/web;
-    access_log  /var/log/nginx/access.log;
-    error_log   /var/log/nginx/error.log;
-
-    server {
-        listen       80;
-        server_name  localhost;
+To verify PHP settings currently set on the server, create a `phpinfo.php` in your webserver's document root
 
-        return 301 https://localhost$request_uri;
-    }
+```bash
+# example
+echo '<?php phpinfo(); ?>' | sudo tee /var/www/shaarli.mydomain.org/phpinfo.php
+#give read-only access to this file to the webserver user
+sudo chown www-data:root /var/www/shaarli.mydomain.org/phpinfo.php
+sudo chmod 0400 /var/www/shaarli.mydomain.org/phpinfo.php
+```
 
-    server {
-        listen       443 ssl;
-        server_name  localhost;
+Access the file from a web browser (eg. <https://shaarli.mydomain.org/phpinfo.php> and look at the _Loaded Configuration File_ and _Scan this dir for additional .ini files_ entries
 
-        ssl_certificate      /home/john/ssl/localhost.crt;
-        ssl_certificate_key  /home/john/ssl/localhost.key;
+It is recommended to remove the `phpinfo.php` when no longer needed as it publicly discloses details about your webserver configuration.
 
-        location /shaarli/ {
-            # Slim - rewrite URLs
-            try_files $uri /index.php$is_args$args;
 
-            access_log  /var/log/nginx/shaarli.access.log;
-            error_log   /var/log/nginx/shaarli.error.log;
-        }
+## Robots and crawlers
 
-        location = /shaarli/favicon.ico {
-            # serve the Shaarli favicon from its custom location
-            alias /var/www/shaarli/images/favicon.ico;
-        }
+To opt-out of indexing your Shaarli instance by search engines, create a `robots.txt` file at the root of your virtualhost:
 
-        include deny.conf;
-        include static_assets.conf;
-        include php.conf;
-    }
-}
+```
+User-agent: *
+Disallow: /
 ```
 
-## Proxies
-
-If Shaarli is served behind a proxy (i.e. there is a proxy server between clients and the web server hosting Shaarli), please refer to the proxy server documentation for proper configuration. In particular, you have to ensure that the following server variables are properly set:
-
-- `X-Forwarded-Proto`
-- `X-Forwarded-Host`
-- `X-Forwarded-For`
+By default Shaarli already disallows indexing of your local copy of the documentation by default, using `<meta name="robots">` HTML tags. Your Shaarli instance may still be indexed by various robots on the public Internet, that do not respect this header or the robots standard.
 
-In you [Shaarli configuration](Shaarli-configuration) `data/config.json.php`, add the public IP of your proxy under `security.trusted_proxies`.
+- [Robots exclusion standard](https://en.wikipedia.org/wiki/Robots_exclusion_standard)
+- [Introduction to robots.txt](https://support.google.com/webmasters/answer/6062608?hl=en)
+- [Robots meta tag, data-nosnippet, and X-Robots-Tag specifications](https://developers.google.com/search/reference/robots_meta_tag)
+- [About robots.txt](http://www.robotstxt.org)
+- [About the robots META tag](https://www.robotstxt.org/meta.html)
 
-See also [proxy-related](https://github.com/shaarli/Shaarli/issues?utf8=%E2%9C%93&q=label%3Aproxy+) issues.
 
-## Robots and crawlers
+## Fail2ban
 
-Shaarli disallows indexing and crawling of your local documentation pages by search engines, using `<meta name="robots">` HTML tags.
-Your Shaarli instance and other pages you host may still be indexed by various robots on the public Internet.
-You may want to setup a robots.txt file or other crawler control mechanism on your server.
-See [[1]](https://en.wikipedia.org/wiki/Robots_exclusion_standard), [[2]](https://support.google.com/webmasters/answer/6062608?hl=en) and [[3]](https://developers.google.com/search/reference/robots_meta_tag)
+[fail2ban](http://www.fail2ban.org/wiki/index.php/Main_Page) is an intrusion prevention framework that reads server (Apache, SSH, etc.) and uses `iptables` profiles to block brute-force attempts. You need to create a filter to detect shaarli login failures in logs, and a jail configuation to configure the behavior when failed login attempts are detected:
 
-## See also
+```ini
+# /etc/fail2ban/filter.d/shaarli-auth.conf
+[INCLUDES]
+before = common.conf
+[Definition]
+failregex = \s-\s<HOST>\s-\sLogin failed for user.*$
+ignoreregex =
+```
 
- * [Server security](Server-security.md)
+```ini
+# /etc/fail2ban/jail.local
+[shaarli-auth]
+enabled  = true
+port     = https,http
+filter   = shaarli-auth
+logpath  = /var/www/shaarli.mydomain.org/data/log.txt
+# allow 3 login attempts per IP address
+# (over a period specified by findtime = in /etc/fail2ban/jail.conf)
+maxretry = 3
+# permanently ban the IP address after reaching the limit
+bantime = -1
+```
 
-#### Webservers
+Then restart the service: `sudo systemctl restart fail2ban`
 
-- [Apache/PHP - error log per VirtualHost](http://stackoverflow.com/q/176) (StackOverflow)
-- [Apache - PHP: php_value vs php_admin_value and the use of php_flag explained](https://ma.ttias.be/php-php_value-vs-php_admin_value-and-the-use-of-php_flag-explained/)
-- [Server-side TLS (Apache)](https://wiki.mozilla.org/Security/Server_Side_TLS#Apache) (Mozilla)
-- [Nginx Beginner's guide](http://nginx.org/en/docs/beginners_guide.html)
-- [Nginx ngx_http_fastcgi_module](http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html)
-- [Nginx Pitfalls](http://wiki.nginx.org/Pitfalls)
-- [Nginx PHP configuration examples](http://kbeezie.com/nginx-configuration-examples/) (Karl Blessing)
-- [Server-side TLS (Nginx)](https://wiki.mozilla.org/Security/Server_Side_TLS#Nginx) (Mozilla)
-- [How to Create Self-Signed SSL Certificates with OpenSSL](http://www.xenocafe.com/tutorials/linux/centos/openssl/self_signed_certificates/index.php)
-- [How do I create my own Certificate Authority?](https://workaround.org/certificate-authority)
 
-#### PHP
+## What next?
 
-- [Travis configuration](https://github.com/shaarli/Shaarli/blob/master/.travis.yml)
-- [PHP: Supported versions](http://php.net/supported-versions.php)
-- [PHP: Unsupported versions](http://php.net/eol.php) _(EOL - End Of Life)_
-- [PHP 7 Changelog](http://php.net/ChangeLog-7.php)
-- [PHP 5 Changelog](http://php.net/ChangeLog-5.php)
-- [PHP: Bugs](https://bugs.php.net/)
+[Shaarli installation](Installation.md)
diff --git a/doc/md/Server-security.md b/doc/md/Server-security.md
deleted file mode 100644 (file)
index ea1b637..0000000
+++ /dev/null
@@ -1,76 +0,0 @@
-## php.ini
-PHP settings are defined in:
-
-- a main configuration file, usually found under `/etc/php/$php_version/php.ini`; some distributions provide different configuration environments, e.g.
-    - `/etc/php/$php_version/cli/php.ini` - used when running console scripts
-    - `/etc/php/$php_version/apache2/php.ini` - used when a client requests PHP resources from Apache
-    - `/etc/php/$php_version/php-fpm.conf` - used when PHP requests are proxied to PHP-FPM
-- additional configuration files/entries, depending on the installed/enabled extensions:
-    - `/etc/php/conf.d/xdebug.ini`
-
-### Locate .ini files
-#### Console environment
-```bash
-$ php --ini
-Configuration File (php.ini) Path: /etc/php
-Loaded Configuration File:         /etc/php/php.ini
-Scan for additional .ini files in: /etc/php/conf.d
-Additional .ini files parsed:      /etc/php/conf.d/xdebug.ini
-```
-
-#### Server environment
-- create a `phpinfo.php` script located in a path supported by the web server, e.g.
-    - Apache (with user dirs enabled): `/home/myself/public_html/phpinfo.php`
-    - `/var/www/test/phpinfo.php`
-- make sure the script is readable by the web server user/group (usually, `www`, `www-data` or `httpd`)
-- access the script from a web browser
-- look at the _Loaded Configuration File_ and _Scan this dir for additional .ini files_ entries
-```php
-<?php phpinfo(); ?>
-```
-
-## fail2ban
-`fail2ban` is an intrusion prevention framework that reads server (Apache, SSH, etc.) and uses `iptables` profiles to block brute-force attempts:
-
-- [Official website](http://www.fail2ban.org/wiki/index.php/Main_Page)
-- [Source code](https://github.com/fail2ban/fail2ban)
-
-### Read Shaarli logs to ban IPs
-Example configuration:
-- allow 3 login attempts per IP address
-- after 3 failures, permanently ban the corresponding IP adddress
-
-`/etc/fail2ban/jail.local`
-```ini
-[shaarli-auth]
-enabled  = true
-port     = https,http
-filter   = shaarli-auth
-logpath  = /var/www/path/to/shaarli/data/log.txt
-maxretry = 3
-bantime = -1
-```
-
-`/etc/fail2ban/filter.d/shaarli-auth.conf`
-```ini
-[INCLUDES]
-before = common.conf
-[Definition]
-failregex = \s-\s<HOST>\s-\sLogin failed for user.*$
-ignoreregex = 
-```
-
-## Robots - Restricting search engines and web crawler traffic
-
-Creating a `robots.txt` with the following contents at the root of your Shaarli installation will prevent _honest_ web crawlers from indexing each and every link and Daily page from a Shaarli instance, thus getting rid of a certain amount of unsollicited network traffic.
-
-```
-User-agent: *
-Disallow: /
-```
-
-See:
-
-- http://www.robotstxt.org
-- http://www.robotstxt.org/robotstxt.html
-- http://www.robotstxt.org/meta.html
index 2462e20e51dba615ca4d2b0b71395640e2398db3..dbfc3da953fc220bfccaa86bb8fc33454933c123 100644 (file)
-## Foreword
-
-**Do not edit configuration options in index.php! Your changes would be lost.** 
+# Shaarli configuration
 
 Once your Shaarli instance is installed, the file `data/config.json.php` is generated:
-* it contains all settings in JSON format, and can be edited to customize values
-* it defines which [plugins](Plugin-System) are enabled
-* its values override those defined in `index.php`
-* it is wrap in a PHP comment to prevent anyone accessing it, regardless of server configuration
-
-## File and directory permissions
-
-The server process running Shaarli must have:
-
-- `read` access to the following resources:
-    - PHP scripts: `index.php`, `application/*.php`, `plugins/*.php`
-    - 3rd party PHP and Javascript libraries: `inc/*.php`, `inc/*.js`
-    - static assets:
-        - CSS stylesheets: `inc/*.css`
-        - `images/*`
-    - RainTPL templates: `tpl/*.html`
-- `read`, `write` and `execution` access to the following directories:
-    - `cache` - thumbnail cache
-    - `data` - link data store, configuration options
-    - `pagecache` - Atom/RSS feed cache
-    - `tmp` - RainTPL page cache
-
-On a Linux distribution:
-
-- the web server user will likely be `www` or `http` (for Apache2)
-- it will be a member of a group of the same name: `www:www`, `http:http`
-- to give it access to Shaarli, either:
-    - unzip Shaarli in the default web server location (usually `/var/www/`) and set the web server user as the owner
-    - put users in the same group as the web server, and set the appropriate access rights
-- if you have a domain / subdomain to serve Shaarli, [configure the server](Server-configuration) accordingly
-
-## Configuration
-
-In `data/config.json.php`.
-
-See also [Plugin System](Plugin-System).
-
-### Credentials
-_These settings should not be edited_
-
-- **login**: Login username.  
-- **hash**: Generated password hash.  
-- **salt**: Password salt.
-
-### General
-
-- **title**: Shaarli's instance title.  
-- **header_link**: Link to the homepage.  
-- **links_per_page**: Number of shaares displayed per page.  
-- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).  
-- **enabled_plugins**: List of enabled plugins.
-- **default_note_title**: Default title of a new note.
-- **retrieve_description** (boolean): If set to true, for every new links Shaarli will try
-to retrieve the description and keywords from the HTML meta tags.
-
-### Security
-
-- **session_protection_disabled**: Disable session cookie hijacking protection (not recommended). 
-  It might be useful if your IP adress often changes.  
-- **ban_after**: Failed login attempts before being IP banned.  
-- **ban_duration**: IP ban duration in seconds.  
-- **open_shaarli**: Anyone can add a new link while logged out if enabled.  
-- **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy.  
-- **allowed_protocols**: List of allowed protocols in shaare URLs or markdown-rendered descriptions. Useful if you want to store `javascript:` links (bookmarklets) in Shaarli (default: `["ftp", "ftps", "magnet"]`).
-
-### Resources
 
-- **data_dir**: Data directory.  
-- **datastore**: Shaarli's links database file path.  
-- **history**: Shaarli's operation history file path.
-- **updates**: File path for the ran updates file.  
-- **log**: Log file path.  
-- **update_check**: Last update check file path.  
-- **raintpl_tpl**: Templates directory.  
-- **raintpl_tmp**: Template engine cache directory.  
-- **thumbnails_cache**: Thumbnails cache directory.  
-- **page_cache**: Shaarli's internal cache directory.  
-- **ban_file**: Banned IP file path.
+- it contains all settings in JSON format, and can be edited to customize values
+- it defines which [plugins](Plugins.md) are enabled
+- its values override those defined in `index.php`
+- it is wrapped in a PHP comment so that its contents are never served by the web server, regardless of configuration
 
-### Translation
+**Do not edit configuration options in index.php! Your changes would be lost.**
 
-- **language**: translation language (also see [Translations](Translations))
-    - **auto** (default): The translation language is chosen from the browser locale. 
-    It means that the language can be different for 2 different visitors depending on their locale.  
-    - **en**: Use the English translation.
-    - **fr**: Use the French translation.
-- **mode**: 
-    - **auto** or **php** (default): Use the PHP implementation of gettext (slower)
-    - **gettext**: Use PHP builtin gettext extension 
-    (faster, but requires `php-gettext` to be installed and to reload the web server on update)
-- **extension**: Translation extensions for custom themes or plugins. 
-Must be an associative array: `translation domain => translation path`.
-
-### Updates
-
-- **check_updates**: Enable or disable update check to the git repository.  
-- **check_updates_branch**: Git branch used to check updates (e.g. `stable` or `master`).  
-- **check_updates_interval**: Look for new version every N seconds (default: every day).
+## Tools menu
 
-### Privacy
-
-- **default_private_links**: Check the private checkbox by default for every new link.  
-- **hide_public_links**: All links are hidden while logged out.  
-- **force_login**: if **hide_public_links** and this are set to `true`, all anonymous users are redirected to the login page.
-- **hide_timestamps**: Timestamps are hidden.
-- **remember_user_default**: Default state of the login page's *remember me* checkbox
-    - `true`: checked by default, `false`: unchecked by default
-
-### Feed
-
-- **rss_permalinks**: Enable this to redirect RSS links to Shaarli's permalinks instead of shaared URL.  
-- **show_atom**: Display ATOM feed button.
-
-### Thumbnail
+Some settings can be configured directly from a web browser by accesing the `Tools` menu. Values are read/written to/from the configuration file.
 
-- **enable_thumbnails**: Enable or disable thumbnail display.  
-- **enable_localcache**: Enable or disable local cache.
+![](https://i.imgur.com/boaaibC.png)
 
 ### LDAP
 
@@ -182,6 +75,9 @@ Must be an associative array: `translation domain => translation path`.
         "title": "My Shaarli",
         "header_link": "?"
     },
+    "dev": {
+        "debug": false,
+    }
     "extras": {
         "show_atom": false,
         "hide_public_links": false,
@@ -236,9 +132,91 @@ Must be an associative array: `translation domain => translation path`.
 } ?>
 ```
 
-## Additional configuration
+## Settings
+
+### Credentials
+
+_These settings should not be edited_
+
+- **login**: Login username.
+- **hash**: Generated password hash.
+- **salt**: Password salt.
+
+### General
+
+- **title**: Shaarli's instance title.
+- **header_link**: Link to the homepage.
+- **links_per_page**: Number of Shaares displayed per page.
+- **timezone**: See [the list of supported timezones](http://php.net/manual/en/timezones.php).
+- **enabled_plugins**: List of enabled plugins.
+- **default_note_title**: Default title of a new note.
+- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
+- **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags.
+- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
+
+### Security
+
+- **session_protection_disabled**: Disable session cookie hijacking protection (not recommended).
+  It might be useful if your IP adress often changes.
+- **ban_after**: Failed login attempts before being IP banned.
+- **ban_duration**: IP ban duration in seconds.
+- **open_shaarli**: Anyone can add a new Shaare while logged out if enabled.
+- **trusted_proxies**: List of trusted IP which won't be banned after failed login attemps. Useful if Shaarli is behind a reverse proxy.
+- **allowed_protocols**: List of allowed protocols in shaare URLs or markdown-rendered descriptions. Useful if you want to store `javascript:` links (bookmarklets) in Shaarli (default: `["ftp", "ftps", "magnet"]`).
+
+### Resources
+
+- **data_dir**: Data directory.
+- **datastore**: Shaarli's Shaares database file path.
+- **history**: Shaarli's operation history file path.
+- **updates**: File path for the ran updates file.
+- **log**: Log file path.
+- **update_check**: Last update check file path.
+- **raintpl_tpl**: Templates directory.
+- **raintpl_tmp**: Template engine cache directory.
+- **thumbnails_cache**: Thumbnails cache directory.
+- **page_cache**: Shaarli's internal cache directory.
+- **ban_file**: Banned IP file path.
+
+### Translation
+
+- **language**: translation language (also see [Translations](Translations))
+    - **auto** (default): The translation language is chosen from the browser locale.
+    It means that the language can be different for 2 different visitors depending on their locale.
+    - **en**: Use the English translation.
+    - **fr**: Use the French translation.
+- **mode**:
+    - **auto** or **php** (default): Use the PHP implementation of gettext (slower)
+    - **gettext**: Use PHP builtin gettext extension
+    (faster, but requires `php-gettext` to be installed and to reload the web server on update)
+- **extension**: Translation extensions for custom themes or plugins.
+Must be an associative array: `translation domain => translation path`.
+
+### Updates
+
+- **check_updates**: Enable or disable update check to the git repository.
+- **check_updates_branch**: Git branch used to check updates (e.g. `stable` or `master`).
+- **check_updates_interval**: Look for new version every N seconds (default: every day).
+
+### Privacy
+
+- **default_private_links**: Check the private checkbox by default for every new Shaare.
+- **hide_public_links**: All Shaares are hidden while logged out.
+- **force_login**: if **hide_public_links** and this are set to `true`, all anonymous users are redirected to the login page.
+- **hide_timestamps**: Timestamps are hidden.
+- **remember_user_default**: Default state of the login page's *remember me* checkbox
+    - `true`: checked by default, `false`: unchecked by default
+
+### Feed
+
+- **rss_permalinks**: Enable this to redirect RSS links to Shaarli's permalinks instead of shaared URL.
+- **show_atom**: Display ATOM feed button.
+
+### Thumbnail
+
+- **enable_thumbnails**: Enable or disable thumbnail display.
+- **enable_localcache**: Enable or disable local cache.
 
-The `playvideos` plugin may require that you adapt your server's 
-[Content Security Policy](https://github.com/shaarli/Shaarli/blob/master/plugins/playvideos/README.md#troubleshooting) 
-configuration to work properly.
+## Plugins configuration
 
+See [Plugins](Plugins.md)
diff --git a/doc/md/Sharing-content.md b/doc/md/Sharing-content.md
deleted file mode 100644 (file)
index 9a16fc6..0000000
+++ /dev/null
@@ -1,71 +0,0 @@
-Content posted to Shaarli is separated in items called _Shaares_. For each Shaare,
-you can customize the following aspects:
-
- * URL to link to
- * Title
- * Free-text description
- * Tags
- * Public/private status
-
---------------------------------------------------------------------------------
-
-## Adding new Shaares
-
-While logged in to your Shaarli, you can add new Shaares in several ways:
-
- * [+Shaare button](#shaare-button)
- * [Bookmarklet](#bookmarklet)
- * Third-party [apps and browser addons](Community-&-Related-software.md#mobile-apps)
- * [REST API](https://shaarli.github.io/api-documentation/)
-
-### +Shaare button
-
- * While logged in to your Shaarli, click the **`+Shaare`** button located in the toolbar.
- * Enter the URL of a link you want to share.
- * Click `Add link`
- * The `New Shaare` dialog appears, allowing you to fill in the details of your Shaare.
-   * The Description, Title, and Tags will help you find your Shaare later using tags or full-text search.
-   * You can also check the “Private” box so that the link is saved but only visible to you (the logged-in user).
- * Click `Save`.
-
-<!-- TODO Add screenshot of add/edit link dialog -->
-
-### Bookmarklet
-
-The _Bookmarklet_ \[[1](https://en.wikipedia.org/wiki/Bookmarklet)\] is a special
-browser bookmark you can use to add new content to your Shaarli. This bookmarklet is
-compatible with Firefox, Opera, Chrome and Safari. To set it up:
-
- * Access the `Tools` page from the button in the toolbar.
- * Drag the **`✚Shaare link` button** to your browser's bookmarks bar.
-
-Once this is done, you can shaare any URL you are visiting simply by clicking the
-bookmarklet in your browser! The same `New Shaare` dialog as above is displayed.
-
-| Note | Websites which enforce Content Security Policy (CSP), such as github.com, disallow usage of bookmarklets. Unfortunately, there is nothing Shaarli can do about it. \[[1](https://github.com/shaarli/Shaarli/issues/196)]\ \[[2](https://bugzilla.mozilla.org/show_bug.cgi?id=866522)]\ \[[3](https://code.google.com/p/chromium/issues/detail?id=233903)]\ |
-|---------|---------|
-
-| Note | Under Opera, you can't drag'n drop the button: You have to right-click on it and add a bookmark to your personal toolbar. |
-|---------|---------|
-
-![](images/bookmarklet.png)
-
-
---------------------------------------------------------------------------------
-
-## Editing Shaares
-
-Any Shaare can edited by clicking its ![](images/edit_icon.png) `Edit` button.
-
-Editing a Shaare will not change it's permalink, each permalink always points to the
-latest revision of a Shaare.
-
---------------------------------------------------------------------------------
-
-## Using shaarli as a blog, notepad, pastebin...
-
-While adding or editing a link, leave the URL field blank to create a text-only
-("note") post. This allows you to post any kind of text content, such as blog
-articles, private or public notes, snippets... There is no character limit! You can
-access your Shaare from its permalink.
-
diff --git a/doc/md/Static-analysis.md b/doc/md/Static-analysis.md
deleted file mode 100644 (file)
index 29d9836..0000000
+++ /dev/null
@@ -1,13 +0,0 @@
-## WIP
-This topic is currently being discussed here:
-
-- [Fix coding style (static analysis)](https://github.com/shaarli/Shaarli/issues/95) (#95)
-- [Continuous Integration tools & features](https://github.com/shaarli/Shaarli/issues/130) (#130)
-
-### Usage
-Static analysis tools can be installed with Composer, and used through Shaarli's [Makefile](https://github.com/shaarli/Shaarli/blob/master/Makefile).
-
-For an overview of the available features, see:
-
-- [Code quality: Makefile to run static code checkers](https://github.com/shaarli/Shaarli/pull/124) (#124)
-- [Run PHPCS against different coding standards](https://github.com/shaarli/Shaarli/pull/276) (#276)
index 01fd9840dd5d18a3543c2b2284b102c92c4f7797..e1ed5e0006ce9368f1d3fb21cda23b62d4850c6f 100644 (file)
@@ -1,5 +1,8 @@
 # Troubleshooting
 
+First of all, ensure that both the [web server](Server-configuration.md) and [Shaarli](Shaarli-configuration.md) are correctly configured.
+
+
 ## Login
 
 ### I forgot my password!
@@ -8,38 +11,48 @@ Delete the file `data/config.json.php` and display the page again. You will be a
 
 ### I'm locked out - Login bruteforce protection
 
-Login form is protected against brute force attacks: 4 failed logins will ban the IP address from login for 30 minutes. Banned IPs can still browse links.
+Login form is protected against brute force attacks: 4 failed logins will ban the IP address from login for 30 minutes. Banned IPs can still browse Shaares.
 
 - To remove the current IP bans, delete the file `data/ipbans.php`
 - To list all login attempts, see `data/log.txt` (succesful/failed logins, bans/lifted bans)
 
+--------------------------------------
+
 ## Browser issues
 
 ### Redirection issues (HTTP Referer)
 
-Depending on its configuration and installed plugins, the browser may remove or alter (spoof) [HTTP referers](https://en.wikipedia.org/wiki/HTTP_referer), thus preventing Shaarli from properly redirecting between pages. Referer settings are available by browsing `about:config` and are documented [here](https://wiki.mozilla.org/Security/Referrer). `network.http.referer.spoofSource = true` in particular is known to break some functionality in Shaarli.
+Shaarli relies on `HTTP_REFERER` for some functions (like redirects and clicking on tags). If you have disabled or altered/spoofed [HTTP referers](https://en.wikipedia.org/wiki/HTTP_referer) in your browser, some features of Shaarli may not work as expected (depending on configuration and installed plugins), notably redirections between pages.
+
+Firefox Referer settings are available by browsing `about:config` and are documented [here](https://wiki.mozilla.org/Security/Referrer). `network.http.referer.spoofSource = true` in particular is known to break some functionality in Shaarli.
+
 
 ### Firefox, localhost and redirections
 
 `localhost` is not a proper Fully Qualified Domain Name (FQDN); if Firefox has been set up to spoof referers, or only accept requests from the same base domain/host,
 Shaarli redirections will not work properly. To solve this, assign a local domain to your host, e.g. `localhost.lan` in your [hosts file](https://en.wikipedia.org/wiki/Hosts_(file)) and browse Shaarli at http://localhost.lan/.
 
+-----------------------------------------
+
 ## Hosting problems
 
 ### Old PHP versions
 
-On **free.fr**: free.fr now supports php 5.6.x([link](http://les.pages.perso.chez.free.fr/migrations/php5v6.io))
-and so support now the tag autocompletion but you have to do the following.
-
-At the root of your webspace create a `sessions` directory and a `.htaccess` file containing:
+- On hosts (such as **free.fr**) which only support PHP 5.6, Shaarli [v0.10.4](https://github.com/shaarli/Shaarli/releases/tag/v0.10.4) is the maximum supported version. At the root of your webspace create a `sessions` directory and a `.htaccess` file containing:
 
 ```xml
 <IfDefine Free>
 php56 1
 </IfDefine>
+<Files ".ht*">
+Order allow,deny
+Deny from all
+Satisfy all
+</Files>
+Options -Indexes
 ```
 
-- If you have an error such as: `Parse error: syntax error, unexpected '=', expecting '(' in /links/index.php on line xxx`, it means that your host is using php4, not php5. Shaarli requires php 5.1. Try changing the file extension to `.php5`
+- If you have an error such as: `Parse error: syntax error, unexpected '=', expecting '(' in /links/index.php on line xxx`, it means that your host is using PHP 4, not PHP 5. Shaarli requires PHP 5.1. Try changing the file extension to `.php5`
 - On **1and1** : If you add the link from the page (and not from the bookmarklet), Shaarli will no be able to get the title of the page. You will have to enter it manually. (Because they have disabled the ability to download a file through HTTP).
 - If you have the error `Warning: file_get_contents() [function.file-get-contents]: URL file-access is disabled in the server configuration in /…/index.php on line xxx`, it means that your host has disabled the ability to fetch a file by HTTP in the php config (Typically in 1and1 hosting). Bad host. Change host. Or comment the following lines:
 
@@ -49,9 +62,11 @@ php56 1
 //if (strpos($status,'200 OK')) $title=html_extract_title($data);
 ```
 
-- On hosts which forbid outgoing HTTP requests (such as free.fr), some thumbnails will not work.
+- On hosts (such as **free.fr**) which forbid outgoing HTTP requests, some thumbnails will not work.
+- On hosts (such as **free.fr**) which limit the number of FTP connections, setup your FTP client accordingly (else some files may be missing after upload).
 - On **lost-oasis**, RSS doesn't work correctly, because of this message at the begining of the RSS/ATOM feed : `<? // tout ce qui est charge ici (generalement des includes et require) est charge en permanence. ?>`. To fix this, remove this message from `php-include/prepend.php`
 
+
 ### Dates are not properly formatted
 
 Shaarli tries to sniff the language of the browser (using `HTTP_ACCEPT_LANGUAGE` headers)
@@ -71,11 +86,118 @@ This can be caused by several things:
 - You may be using OperaTurbo or OperaMini, which use their own proxies which may change from time to time.
 - If you have another application on the same webserver where Shaarli is installed, these application may forcefully expire php sessions.
 
+
 ### Old apache versions, Internal Server Error
 
 If you hosting provider only provides apache 2.2 and no support for `mod_version`, `.htaccess` files may cause 500 errors (Internal Server Error). See [this workaround](https://github.com/shaarli/Shaarli/issues/1196#issuecomment-412271085).
 
-## Sessions do not seem to work correctly on your server
+
+### Sessions do not seem to work correctly on your server
 
 Follow the instructions in the error message. Make sure you are accessing shaarli via a direct IP address or a proper hostname. If you have **no dots** in the hostname (e.g. `localhost` or `http://my-webserver/shaarli/`), some browsers will not store cookies at all (this respects the [HTTP cookie specification](http://curl.haxx.se/rfc/cookie_spec.html)).
 
+----------------------------------------------------------
+
+## Upgrades
+
+### You must specify an integer as a key
+
+In `v0.8.1` we changed how Shaare keys are handled (from timestamps to incremental integers). Take a look at `data/updates.txt` content.
+
+
+### `updates.txt` contains `updateMethodDatastoreIds`
+
+Try to delete it and refresh your page while being logged in.
+
+### `updates.txt` doesn't exist or doesn't contain `updateMethodDatastoreIds`
+
+1. Create `data/updates.txt` if it doesn't exist
+2. Paste this string in the update file `;updateMethodRenameDashTags;`
+3. Login to Shaarli
+4. Delete the update file
+5. Refresh
+
+
+
+--------------------------------------------------------
+
+## Import/export
+
+### Importing shaarli data to Firefox
+
+- In Firefox, open the bookmark manager (`Bookmarks menu > Show all bookmarks` or `Ctrl+Shift+B`), select `Import and Backup > Import bookmarks in HTML format`
+- Make sure the `Prepend note permalinks with this Shaarli instance's URL` box is checked when exporting, so that text-only/notes Shaares still point to the Shaarli instance you exported them from.
+- Depending on the number of bookmarks, the import can take some time.
+
+You may be interested in these Firefox addons to manage bookmarks imported from Shaarli
+
+- [Bookmark Deduplicator](https://addons.mozilla.org/en-US/firefox/addon/bookmark-deduplicator/) - provides an easy way to deduplicate your bookmarks
+- [TagSieve](https://addons.mozilla.org/en-US/firefox/addon/tagsieve/) - browse your bookmarks by their tags
+
+### Diigo
+
+If you export your bookmark from Diigo, make sure you use the Delicious export, not the Netscape export. (Their Netscape export is broken, and they don't seem to be interested in fixing it.)
+
+### Mister Wong
+
+See [this issue](https://github.com/sebsauvage/Shaarli/issues/146) for import tweaks.
+
+### SemanticScuttle
+
+To correctly import the tags from a [SemanticScuttle](http://semanticscuttle.sourceforge.net/) HTML export, edit the HTML file before importing and replace all occurences of `tags=` (lowercase) to `TAGS=` (uppercase).
+
+### Scuttle
+
+Shaarli cannot import data directly from [Scuttle](https://github.com/scronide/scuttle).
+
+However, you can use the third-party [scuttle-to-shaarli](https://github.com/q2apro/scuttle-to-shaarli)
+tool to export the Scuttle database to the Netscape HTML format compatible with the Shaarli importer.
+
+### Refind.com
+
+You can use the third-party tool [Derefind](https://github.com/ShawnPConroy/Derefind) to convert refind.com bookmark exports to a format that can be imported into Shaarli.
+
+
+-------------------------------------------------------
+
+## Other
+
+### The bookmarklet doesn't work
+
+Websites which enforce Content Security Policy (CSP), such as github.com, disallow usage of bookmarklets. Unfortunately, there is nothing Shaarli can do about it ([1](https://github.com/shaarli/Shaarli/issues/196), [2](https://bugzilla.mozilla.org/show_bug.cgi?id=866522), [3](https://code.google.com/p/chromium/issues/detail?id=233903).
+
+Under Opera, you can't drag'n drop the button: You have to right-click on it and add a bookmark to your personal toolbar.
+
+
+### Changing the timestamp for a shaare
+
+- Look for `<input type="hidden" name="lf_linkdate" value="{$link.linkdate}">` in `tpl/editlink.tpl` (line 14)
+- Replace `type="hidden"` with `type="text"` from this line
+- A new date/time field becomes available in the edit/new Shaare dialog.
+- You can set the timestamp manually by entering it in the format `YYYMMDD_HHMMS`.
+
+### Clearing Shaarli caches
+
+For debugging purposes:
+
+```bash
+# clear raintpl cache and temporary files
+find /var/www/links/cache/ /var/www/links/pagecache/ /var/www/links/tmp/ -type f -exec rm -v '{}' \;
+# if you have a php accelerator such as php-apcu, restart the webserver
+sudo systemctl restart apache2
+```
+
+-------------------------------------------------------
+
+## Support
+
+If the solutions above did not help, please:
+
+- Come and ask question on the [Gitter chat](https://gitter.im/shaarli/Shaarli) (also reachable via [IRC](https://irc.gitter.im/))
+- Search for [issues](https://github.com/shaarli/Shaarli/issues) and [Pull Requests](https://github.com/shaarli/Shaarli/pulls)
+    - if you find one that is related to the issue, feel free to comment and provide additional details (host/Shaarli setup...)
+    - check issues labeled [`feature`](https://github.com/shaarli/Shaarli/labels/feature), [`enhancement`](https://github.com/shaarli/Shaarli/labels/enhancement), and [`plugin`](https://github.com/shaarli/Shaarli/labels/plugin) if you would like a feature added to Shaarli.
+    - else, [open a new issue](https://github.com/shaarli/Shaarli/issues/new), and provide information about the problem:
+        - _what happens?_ - display glitches, invalid data, security flaws...
+        - _what is your configuration?_  - OS, server version, activated extensions, web browser...
+        - _is it reproducible?_
\ No newline at end of file
diff --git a/doc/md/Unit-tests.md b/doc/md/Unit-tests.md
deleted file mode 100644 (file)
index a954465..0000000
+++ /dev/null
@@ -1,119 +0,0 @@
-The testing framework used is [PHPUnit](https://phpunit.de/); it can be installed with [Composer](https://getcomposer.org/), which is a dependency management tool.
-
-## Setup a testing environment
-
-### Install composer
-
-You can either use:
-
-- a system-wide version, e.g. installed through your distro's package manager (eg. `sudo apt install composer`)
-- a local version, downloadable [here](https://getcomposer.org/download/). To update a local composer installation, run `php composer.phar self-update`
-
-
-### Install Shaarli development dependencies
-
-```bash
-$ cd /path/to/shaarli
-$ composer install
-```
-
-### Install Xdebug
-
-Xdebug must be installed and enable for PHPUnit to generate coverage reports. See http://xdebug.org/docs/install.
-
-```bash
-# for Debian-based distributions
-$ aptitude install php-xdebug
-
-# for ArchLinux:
-$ pacman -S xdebug
-```
-
-Then add the following line to `/etc/php/<PHP_VERSION>/cli/php.ini`:
-
-```ini
-zend_extension=xdebug.so
-```
-
-## Run unit tests
-
-Run `make test` and ensure tests return `OK`. If tests return failures, refer to PHPUnit messages and fix your code/tests accordingly.
-
-By default, PHPUnit will run all suitable tests found under the `tests` directory. Each test has 3 possible outcomes:
-
-- `.` - success
-- `F` - failure: the test was run but its results are invalid
-    - the code does not behave as expected
-    - dependencies to external elements: globals, session, cache...
-- `E` - error: something went wrong and the tested code has crashed
-    - typos in the code, or in the test code
-    - dependencies to missing external elements
-
-If Xdebug has been installed and activated, two coverage reports will be generated:
-
-- a summary in the console
-- a detailed HTML report with metrics for tested code
-    - to open it in a web browser: `firefox coverage/index.html &`
-
-### Executing specific tests
-
-Add a [`@group`](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.group) annotation in a test class or method comment:
-
-```php
-/**
- * Netscape bookmark import
- * @group WIP
- */
-class BookmarkImportTest extends PHPUnit_Framework_TestCase
-{
-   [...]
-}
-```
-
-To run all tests annotated with `@group WIP`:
-```bash
-$ vendor/bin/phpunit --group WIP tests/
-```
-
-### Running tests inside Docker containers
-
-Test Dockerfiles are located under `tests/docker/<distribution>/Dockerfile`,
-and can be used to build Docker images to run Shaarli test suites under common
-Linux environments.
-
-Dockerfiles are provided for the following environments:
-
-- `alpine36` - [Alpine 3.6](https://www.alpinelinux.org/downloads/)
-- `debian8` - [Debian 8 Jessie](https://www.debian.org/DebianJessie) (oldstable)
-- `debian9` - [Debian 9 Stretch](https://wiki.debian.org/DebianStretch) (stable)
-- `ubuntu16` - [Ubuntu 16.04 Xenial Xerus](http://releases.ubuntu.com/16.04/) (LTS)
-
-What's behind the curtains:
-
-- each image provides:
-    - a base Linux OS
-    - Shaarli PHP dependencies (OS packages)
-    - test PHP dependencies (OS packages)
-    - Composer
-- the local workspace is mapped to the container's `/shaarli/` directory,
-- the files are rsync'd so tests are run using a standard Linux user account
-  (running tests as `root` would bypass permission checks and may hide issues)
-- the tests are run inside the container.
-
-To run tests inside a Docker container:
-
-```bash
-# build the Debian 9 Docker image for unit tests
-$ cd /path/to/shaarli
-$ cd tests/docker/debian9
-$ docker build -t shaarli-test:debian9 .
-
-# install/update 3rd-party test dependencies
-$ composer install --prefer-dist
-
-# run tests using the freshly built image
-$ docker run -v $PWD:/shaarli shaarli-test:debian9 docker_test
-
-# run the full test campaign
-$ docker run -v $PWD:/shaarli shaarli-test:debian9 docker_all_tests
-```
index d5682a340dd35d650e8ed025ca4d5e75e666ba99..bfef3e8c1059a8ce137ba53665e3ae830de1709a 100644 (file)
@@ -1,96 +1,83 @@
-## Preparation
+# Upgrade and migration
 
-### Note your current version
+## Note your current version
 
 If anything goes wrong, it's important for us to know which version you're upgrading from.
 The current version is present in the `shaarli_version.php` file.
 
-### Backup your data
 
-Shaarli stores all user data under the `data` directory:
+## Backup your data
 
-- `data/config.json.php` (or `data/config.php` for older Shaarli versions) - main configuration file
-- `data/datastore.php` - bookmarked links
-- `data/ipbans.php` - banned IP addresses
-- `data/updates.txt` - contains all automatic update to the configuration and datastore files already run
+Shaarli stores all user data and [configuration](Shaarli-configuration.md) under the `data` directory. [Backup](Backup-and-restore.md) this repository _before_ upgrading Shaarli. You will need to restore it after the following upgrade steps.
 
-See [Shaarli configuration](Shaarli-configuration) for more information about Shaarli resources.
-
-It is recommended to backup this repository _before_ starting updating/upgrading Shaarli:
-
-- users with SSH access: copy or archive the directory to a temporary location
-- users with FTP access: download a local copy of your Shaarli installation using your favourite client
+```bash
+sudo cp -r /var/www/shaarli.mydomain.org/data ~/shaarli-data-backup
+```
 
-### Migrating data from a previous installation
+## Upgrading from ZIP archives
 
-As all user data is kept under `data`, this is the only directory you need to worry about when migrating to a new installation, which corresponds to the following steps:
+If you installed Shaarli from a [release ZIP archive](Installation.md#from-release-zip):
 
-- backup the `data` directory
-- install or update Shaarli:
-    - fresh installation - see [Download and Installation](Download-and-Installation)
-    - update - see the following sections
-- check or restore the `data` directory
+```bash
+# Download the archive to the server, and extract it
+cd ~
+wget https://github.com/shaarli/Shaarli/releases/download/v0.X.Y/shaarli-v0.X.Y-full.zip
+unzip shaarli-v0.X.Y-full.zip
 
-## Recommended : Upgrading from release archives
+# overwrite your Shaarli installation with the new release **All data will be lost, see _Backup your data_ above.**
+sudo rsync -avP --delete Shaarli/ /var/www/shaarli.mydomain.org/
 
-All tagged revisions can be downloaded as tarballs or ZIP archives from the [releases](https://github.com/shaarli/Shaarli/releases) page.
+# restore file permissions as described on the installation page
+sudo chown -R root:www-data /var/www/shaarli.mydomain.org
+sudo chmod -R g+rX /var/www/shaarli.mydomain.org
+sudo chmod -R g+rwX /var/www/shaarli.mydomain.org/{cache/,data/,pagecache/,tmp/}
 
-We recommend that you use the latest release tarball with the `-full` suffix. It contains the dependencies, please read [Download and Installation](Download-and-Installation) for `git` complete instructions.
+# restore backups of the data directory
+sudo cp -r ~/shaarli-data-backup/* /var/www/shaarli.mydomain.org/data/
 
-Once downloaded, extract the archive locally and update your remote installation (e.g. via FTP) -be sure you keep the content of the `data` directory!
+# If you use gettext mode for translations (not the default), reload your web server.
+sudo systemctl restart apache2
+sudo systemctl restart nginx
+```
 
-If you use translations in gettext mode - meaning you manually changed the default mode -,
-reload your web server.
+If you don't have shell access (eg. on shared hosting), backup the shaarli data directory, download the ZIP archive locally, extract it, upload it to the server using file transfer, and restore the data directory backup.
 
-After upgrading, access your fresh Shaarli installation from a web browser; the configuration and data store will then be automatically updated, and new settings added to `data/config.json.php` (see [Shaarli configuration](Shaarli configuration) for more details).
+Access your fresh Shaarli installation from a web browser; the configuration and data store will then be automatically updated, and new settings added to `data/config.json.php` (see [Shaarli configuration](Shaarli-configuration.md) for more details).
 
-## Upgrading with Git
 
-### Updating a community Shaarli
+## Upgrading from Git
 
-If you have installed Shaarli from the [community Git repository](Download#clone-with-git-recommended), simply [pull new changes](https://www.git-scm.com/docs/git-pull) from your local clone:
+If you have installed Shaarli [from sources](Installation.md#from-sources):
 
 ```bash
-$ cd /path/to/shaarli
-$ git pull
-
-From github.com:shaarli/Shaarli
- * branch            master     -> FETCH_HEAD
-Updating ebd67c6..521f0e6
-Fast-forward
- application/Url.php   | 1 +
- shaarli_version.php   | 2 +-
- tests/Url/UrlTest.php | 1 +
- 3 files changed, 3 insertions(+), 1 deletion(-)
-```
+# pull new changes from your local clone
+cd /var/www/shaarli.mydomain.org/
+sudo git pull
 
-Shaarli >= `v0.8.x`: install/update third-party PHP dependencies using [Composer](https://getcomposer.org/):
+# update PHP dependencies (Shaarli >= v0.8)
+sudo composer install --no-dev
 
-```bash
-$ composer install --no-dev
+# update translations (Shaarli >= v0.9.2)
+sudo make translate
 
-Loading composer repositories with package information
-Updating dependencies
-  - Installing shaarli/netscape-bookmark-parser (v1.0.1)
-    Downloading: 100%
-```
+# If you use translations in gettext mode (not the default), reload your web server.
+sudo systemctl reload apache
+sudo systemctl reload nginx
 
-Shaarli >= `v0.9.2` supports translations:
+# update front-end dependencies (Shaarli >= v0.10.0)
+sudo make build_frontend
 
-```bash
-$ make translate
-```
+# restore file permissions as described on the installation page
+sudo chown -R root:www-data /var/www/shaarli.mydomain.org
+sudo chmod -R g+rX /var/www/shaarli.mydomain.org
+sudo chmod -R g+rwX /var/www/shaarli.mydomain.org/{cache/,data/,pagecache/,tmp/}
+``` 
 
-If you use translations in gettext mode, reload your web server.
+Access your fresh Shaarli installation from a web browser; the configuration and data store will then be automatically updated, and new settings added to `data/config.json.php` (see [Shaarli configuration](Shaarli-configuration.md) for more details).
 
-Shaarli >= `v0.10.0` manages its front-end dependencies with nodejs. You need to install 
-[yarn](https://yarnpkg.com/lang/en/docs/install/):
+---------------------------------------------------------------
 
-```bash
-$ make build_frontend
-``` 
-
-### Migrating and upgrading from Sebsauvage's repository
+## Migrating and upgrading from Sebsauvage's repository
 
 If you have installed Shaarli from [Sebsauvage's original Git repository](https://github.com/sebsauvage/Shaarli), you can use [Git remotes](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) to update your working copy.
 
@@ -104,7 +91,7 @@ The following guide assumes that:
     - no versioned file has been locally modified
     - no untracked files are present
 
-#### Step 0: show repository information
+### Step 0: show repository information
 
 ```bash
 $ cd /path/to/shaarli
@@ -122,7 +109,7 @@ Your branch is up-to-date with 'origin/master'.
 nothing to commit, working directory clean
 ```
 
-#### Step 1: update Git remotes
+### Step 1: update Git remotes
 
 ```
 $ git remote rename origin sebsauvage
@@ -146,7 +133,7 @@ From https://github.com/shaarli/Shaarli
  * [new tag]         v0.7.0     -> v0.7.0
 ```
 
-#### Step 2: use the stable community branch
+### Step 2: use the stable community branch
 
 ```bash
 $ git checkout origin/stable -b stable
@@ -177,8 +164,7 @@ $ make translate
 
 If you use translations in gettext mode, reload your web server.
 
-Shaarli >= `v0.10.0` manages its front-end dependencies with nodejs. You need to install 
-[yarn](https://yarnpkg.com/lang/en/docs/install/):
+Shaarli >= `v0.10.0` manages its front-end dependencies with nodejs. You need to install [yarn](https://yarnpkg.com/lang/en/docs/install/):
 
 ```bash
 $ make build_frontend
@@ -204,30 +190,14 @@ Writing objects: 100% (3317/3317), done.
 Total 3317 (delta 2050), reused 3301 (delta 2034)to
 ```
 
-#### Step 3: configuration
+### Step 3: configuration
 
 After migrating, access your fresh Shaarli installation from a web browser; the
 configuration will then be automatically updated, and new settings added to
-`data/config.json.php` (see [Shaarli configuration](Shaarli-configuration) for more
+`data/config.json.php` (see [Shaarli configuration](Shaarli-configuration.md) for more
 details).
 
 ## Troubleshooting
 
-If the solutions provided here don't work, please open an issue specifying which version you're upgrading from and to.
-
-### You must specify an integer as a key
-
-In `v0.8.1` we changed how link keys are handled (from timestamps to incremental integers).
-Take a look at `data/updates.txt` content.
-
-#### `updates.txt` contains `updateMethodDatastoreIds`
-
-Try to delete it and refresh your page while being logged in.
-
-#### `updates.txt` doesn't exist or doesn't contain `updateMethodDatastoreIds`
+If the solutions provided here don't work, see [Troubleshooting](Troubleshooting.md) and/or open an issue specifying which version you're upgrading from and to.
 
-1. Create `data/updates.txt` if it doesn't exist
-2. Paste this string in the update file `;updateMethodRenameDashTags;`
-3. Login to Shaarli
-4. Delete the update file
-5. Refresh
diff --git a/doc/md/Usage.md b/doc/md/Usage.md
new file mode 100644 (file)
index 0000000..6dadde0
--- /dev/null
@@ -0,0 +1,111 @@
+## Features
+
+For any item posted to Shaarli (called a _Shaare_), you can customize the following aspects:
+
+- URL to link to
+- Title
+- Free-text description
+- Tags
+- Public/private status
+
+
+### Adding/editing Shaares
+
+While logged in to your Shaarli, you can add, edit or delete Shaares:
+
+- Using the **+Shaare** button: enter the URL you want to share, click `Add link`, fill in the details of your Shaare, and `Save`
+- Using the [Bookmarklet](https://en.wikipedia.org/wiki/Bookmarklet): drag the `✚Shaare link` button from the `Tools` page to your browser's bookmarks bar, click it to share the current page.
+- Using [apps and browser addons](Community-and-related-software.md#mobile-apps)
+- Using the [REST API](https://shaarli.github.io/api-documentation/)
+- Any Shaare can edited by clicking its ![](images/edit_icon.png) `Edit` button.
+
+
+### Tags
+
+Tags can be be used to organize and categorize your Shaares:
+
+- You can rename, merge and delete tags from the _Tools_ menu or the [tag cloud/list](#tag-cloud)
+- Tags are auto-completed (from the list of existing tags) in all dialogs
+- Tags can be combined with text in [search](#search) queries
+
+
+### Public/private Shaares
+
+Additional filter buttons can be found at the top left of the Shaare list **only when logged in**:
+
+- **Only show private Shaares:** Private shares can be searched by clicking the `only show private links` toggle button top left of the Shaares list (only when logged in)
+
+
+### Permalinks
+
+Permalinks are fixed, short links attached to each Shaare. Editing a Shaare will not change it's permalink, each permalink always points to the latest revision of a Shaare.
+
+
+### Text-only (note) Shaares
+
+Shaarli can be used as a minimal blog, notepad, pastebin...: While adding or editing a Shaare, leave the URL field blank to create a text-only ("note") post. This allows you to post any kind of text content, such as blog articles, private or public notes, snippets... There is no character limit! You can access your post from its permalink.
+
+
+### Search
+
+- **Plain text search:** Use `Search text` to search in all fields of all Shaares (Title, URL, Description...). Use double-quotes (example `"exact search"`) to search for the exact expression.
+- **Tags search:** `Filter by tags` allow only displaying Shaares tagged with one or multiple tags (use space to separate tags).
+- **Hidden tags:** tags starting with a dot `.` (example `.secret`) are private. They can only be seen and searched when logged in.
+- **Exclude text/tags:** Use the `-` operator before a word or tag to exclude Shaares matching this word from search results (`NOT` operator).
+- **Untagged links:** Shaares without tags can be searched by clicking the `untagged` toggle button top left of the Shaares list (only when logged in).
+
+Both exclude patterns and exact searches can be combined with normal searches (example `"exact search" term otherterm -notthis "very exact" stuff -notagain`). Only AND (and NOT) search is currrently supported.
+
+Active search terms are displayed on top of the link list. To remove terms/tags from the curent search, click the `x` next to any of them, or simply clear text/tag search fields.
+
+
+### Tag cloud
+
+The `Tag cloud` page diplays a "cloud" or list view of all tags in your Shaarli (most frequently used tags are displayed with a bigger font size)
+
+
+- **Tags list:** click on `Most used` or `Alphabetical` to display tags as a list. You can also edit/delete tags for this page.
+- Click on any tag to search all Shaares matching this tag.
+- **Filtering the tag cloud/list:** Click on the counter next to a tag to show other tags of Shaares with this tag. Repeat this any number of times to further filter the tag cloud. Click `List all links with those tags` to display Shaares matching your current tag filter set.
+
+
+
+### RSS feeds
+
+RSS/ATOM feeds feeds are available (in ATOM with `/feed/atom` and RSS with `/feed/rss`)
+
+- **Filtering RSS feeds:** RSS feeds and picture wall can also be restricted to only return items matching a text/tag search. For example, search for `photography` (text or tags) in Shaarli, then click the `RSS Feed` button. A feed with only matching results is displayed.
+- Add the `&nb` parameter in feed URLs to specify the number of Shaares you want in a feed (default if not specified: `50`). The keyword `all` is available if you want everything.
+- Add the `&permalinks` parameter in feed URLs to point permalinks to the corresponding shaarly entry/link instead of the direct, Shaare URL attribute
+
+![](images/rss-filter-1.png) ![](images/rss-filter-2.png)
+
+```bash
+# examples
+https://shaarli.mydomain.org/feed/atom?permalinks
+https://shaarli.mydomain.org/feed/atom?permalinks&nb=42
+https://shaarli.mydomain.org/feed/atom?permalinks&nb=all
+https://shaarli.mydomain.org/feed/rss?searchtags=nature
+https://shaarli.mydomain.org/links/picture-wall?searchterm=poney
+```
+
+
+### Picture wall
+
+- The picture wall can be filtered by text or tags search in the same way as [RSS feeds](#rss-feeds)
+
+
+### Import/export
+
+To **export Shaares as a HTML file**, under _Tools > Export_, choose:
+
+- `Export all` to export both public and private Shaares
+- `Export public` to export public Shaares only
+- `Export private` to export private Shaares only
+
+Restore by using the `Import` feature.
+
+- These exports contain the full data (URL, title, tags, date, description, public/private status of your Shaares)
+- They can also be imported to your web browser bookmarks.
+
+To **import a HTML bookmarks file** exported from your browser, just use the `Import` feature. For each "folder" in the bookmarks you imported, a new tag will be created (for example a bookmark in `Movies > Sci-fi` folder will be tagged `Movies` `Sci-fi`).
diff --git a/doc/md/dev/Development.md b/doc/md/dev/Development.md
new file mode 100644 (file)
index 0000000..5c085e0
--- /dev/null
@@ -0,0 +1,179 @@
+# Development
+
+Please read [Contributing to Shaarli](https://github.com/shaarli/Shaarli/tree/master/CONTRIBUTING.md)
+
+## Guidelines
+
+
+- [Unit tests](Unit-tests)
+- Javascript linting - Shaarli uses [Airbnb JavaScript Style Guide](https://github.com/airbnb/javascript). 
+Run `make eslint` to check JS style.
+- [GnuPG signature](GnuPG-signature) for tags/releases
+
+
+## Third-party libraries
+
+CSS:
+
+- Yahoo UI [CSS Reset](http://yuilibrary.com/yui/docs/cssreset/) - standardize cross-browser rendering
+
+Javascript:
+
+- [Awesomeplete](https://leaverou.github.io/awesomplete/) ([GitHub](https://github.com/LeaVerou/awesomplete)) - autocompletion in input forms
+- [bLazy](http://dinbror.dk/blazy/) ([GitHub](https://github.com/dinbror/blazy)) - lazy loading for thumbnails
+- [qr.js](http://neocotic.com/qr.js/) ([GitHub](https://github.com/neocotic/qr.js)) - QR code generation
+
+PHP (managed through [`composer.json`](https://github.com/shaarli/Shaarli/blob/master/composer.json)):
+
+- [RainTPL](https://github.com/rainphp/raintpl) - HTML templating for PHP
+- [`shaarli/netscape-bookmark-parser`](https://packagist.org/packages/shaarli/netscape-bookmark-parser) - Import bookmarks from Netscape files
+- [`erusev/parsedown`](https://packagist.org/packages/erusev/parsedown) - Parse MarkDown syntax for the MarkDown plugin
+- [`slim/slim`](https://packagist.org/packages/slim/slim) - Handle routes and middleware for the REST API
+- [`ArthurHoaro/web-thumbnailer`](https://github.com/ArthurHoaro/web-thumbnailer) - PHP library which will retrieve a thumbnail for any given URL
+- [`pubsubhubbub/publisher`](https://github.com/pubsubhubbub/php-publisher) - A PubSubHubbub publisher module for PHP.
+- [`gettext/gettext`](https://github.com/php-gettext/Gettext) - PHP library to collect and manipulate gettext (.po, .mo, .php, .json, etc)
+
+
+## Security
+
+- The password is salted, hashed and stored in the data subdirectory, in a PHP file, and protected by htaccess. Even if the webserver does not support htaccess, the hash is not readable by URL. Even if the .php file is stolen, the password cannot deduced from the hash. The salt prevents rainbow-tables attacks.
+- Directories are protected using `.htaccess` files
+- Forms are protected against [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery):
+    - Forms which act on data (save,delete…) contain a token generated by the server.
+    - Any posted form which does not contain a valid token is rejected.
+    - Any token can only be used once.
+    - Tokens are attached to the session and cannot be reused in another session.
+- Sessions automatically expire after 60 minutes.
+- Sessions are protected against hijacking: the session ID cannot be used from a different IP address.
+- Links are stored as an associative array which is serialized, compressed (with deflate), base64-encoded and saved as a comment in a `.php` file - even if the server does not support `.htaccess` files, the data file will still not be readable by URL.
+- Bruteforce protection: Successful and failed login attempts are logged - IP bans are enforced after a configurable amount of failures. Logs can also be used consumed by [fail2ban](../Server-configuration.md#fail2ban)
+- A pop-up notification is shown when a new release is available.
+
+## Link structure
+
+Every link available through the `LinkDB` object is represented as an array 
+containing the following fields:
+
+  * `id` (integer): Unique identifier.
+  * `title` (string): Title of the link.
+  * `url` (string): URL of the link. Used for displayable links (without redirector, url encoding, etc.).  
+           Can be absolute or relative for Notes.
+  * `real_url` (string): Real destination URL, can be redirected, encoded, etc.
+  * `shorturl` (string): Permalink small hash.
+  * `description` (string): Link text description.
+  * `private` (boolean): whether the link is private or not.
+  * `tags` (string): all link tags separated by a single space
+  * `thumbnail` (string|boolean): relative path of the thumbnail cache file, or false if there isn't any.
+  * `created` (DateTime): link creation date time.
+  * `updated` (DateTime): last modification date time.
+  
+Small hashes are used to make a link to an entry in Shaarli. They are unique: the date of the item (eg. `20110923_150523`) is hashed with CRC32, then converted to base64 and some characters are replaced. They are always 6 characters longs and use only `A-Z a-z 0-9 - _` and `@`.
+
+
+## Directory structure
+
+Here is the directory structure of Shaarli and the purpose of the different files:
+
+```bash
+       index.php        # Main program
+       application/     # Shaarli classes
+               ├── LinkDB.php
+
+        ...
+
+               └── Utils.php
+       tests/           # Shaarli unitary & functional tests
+               ├── LinkDBTest.php
+
+        ...
+
+               ├── utils    # utilities to ease testing
+               │   └── ReferenceLinkDB.php
+               └── UtilsTest.php
+       assets/
+           ├── common/                # Assets shared by multiple themes
+               ├── ...
+        ├── default/               # Assets for the default template, before compilation
+            ├── fonts/                  # Font files
+            ├── img/                    # Images used by the default theme
+            ├── js/                     # JavaScript files in ES6 syntax
+            ├── scss/                   # SASS files
+        └── vintage/               # Assets for the vintage template, before compilation
+            └── ...
+    COPYING          # Shaarli license
+    inc/             # static assets and 3rd party libraries
+        └── rain.tpl.class.php     # RainTPL templating library
+    images/          # Images and icons used in Shaarli
+    data/            # data storage: bookmark database, configuration, logs, banlist...
+        ├── config.json.php        # Shaarli configuration (login, password, timezone, title...)
+        ├── datastore.php          # Your link database (compressed).
+        ├── ipban.php              # IP address ban system data
+        ├── lastupdatecheck.txt    # Update check timestamp file
+        └── log.txt                # login/IPban log.
+    tpl/             # RainTPL templates for Shaarli. They are used to build the pages.
+        ├── default/               # Default Shaarli theme
+            ├── fonts/                  # Font files
+            ├── img/                    # Images
+            ├── js/                     # JavaScript files compiled by Babel and compatible with all browsers
+            ├── css/                    # CSS files compiled with SASS
+        └── vintage/               # Legacy Shaarli theme
+            └── ...
+    cache/           # thumbnails cache
+                     # This directory is automatically created. You can erase it anytime you want.
+    tmp/             # Temporary directory for compiled RainTPL templates.
+                     # This directory is automatically created. You can erase it anytime you want.
+    vendor/          # Third-party dependencies. This directory is created by Composer
+```
+
+Shaarli needs read access to:
+
+- the root index.php file
+- the `application/`, `plugins/` and `inc/` directories (recursively)
+
+Shaarli needs read/write access to the `cache/`, `data/`, `pagecache/`, and `tmp/` directories
+
+
+## Automation
+
+A [`Makefile`](https://github.com/shaarli/Shaarli/blob/master/Makefile) is available to perform project-related operations:
+
+- [Static analysis](#Static-analysis) - check that the code is compliant to PHP conventions
+- [Unit tests](#Unit-tests) - ensure there are no regressions introduced by new commits
+- Documentation - generate a local HTML copy of the markdown documentation
+
+### Continuous Integration
+
+[Travis CI](http://docs.travis-ci.com/) is a Continuous Integration build server, that runs a build:
+
+- each time a commit is merged to the mainline (`master` branch)
+- each time a Pull Request is submitted or updated
+
+After all jobs have finished, Travis returns the results to GitHub:
+
+- a status icon represents the result for the `master` branch: [![](https://api.travis-ci.org/shaarli/Shaarli.svg)](https://travis-ci.org/shaarli/Shaarli)
+- Pull Requests are updated with the Travis build result.
+
+See [`.travis.yml`](https://github.com/shaarli/Shaarli/blob/master/.travis.yml).
+
+
+### Documentation
+
+[mkdocs](https://www.mkdocs.org/) is used to convert markdown documentation to HTML pages. The [public documentation](https://shaarli.readthedocs.io/en/master/) website is rendered and hosted by [readthedocs.org](https://readthedocs.org/). A copy of the documentation is also included in prebuilt [release archives](https://github.com/shaarli/Shaarli/releases) (`doc/html/` path in your Shaarli installation). To generate the HTML documentation locally, install a recent version of Python `setuptools` and run    `make doc`.
+
+
+## Static analysis
+
+Patches should try to stick to the [PHP Standard Recommendations](http://www.php-fig.org/psr/) (PSR), especially:
+
+- [PSR-1](http://www.php-fig.org/psr/psr-1/) - Basic Coding Standard
+- [PSR-2](http://www.php-fig.org/psr/psr-2/) - Coding Style Guide
+
+
+**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)
+
+Static analysis tools can be installed with Composer, and used through Shaarli's [Makefile](https://github.com/shaarli/Shaarli/blob/master/Makefile).
+
+For an overview of the available features, see:
+
+- [Code quality: Makefile to run static code checkers](https://github.com/shaarli/Shaarli/pull/124) (#124)
+- [Run PHPCS against different coding standards](https://github.com/shaarli/Shaarli/pull/276) (#276)
similarity index 66%
rename from doc/md/GnuPG-signature.md
rename to doc/md/dev/GnuPG-signature.md
index d1fc10a59602fd8046504c11c7f3af7b8c84dbbb..255780013a99a13a67a3d62fe482ca49068c3948 100644 (file)
@@ -1,24 +1,16 @@
 ## Introduction
 ### PGP and GPG
-[Gnu Privacy Guard](https://gnupg.org/) (GnuPG) is an Open Source implementation of the
-[Pretty Good Privacy](https://en.wikipedia.org/wiki/Pretty_Good_Privacy#OpenPGP)
-(OpenPGP) specification. Its main purposes are digital authentication, signature and encryption.
+[Gnu Privacy Guard](https://gnupg.org/) (GnuPG) is an Open Source implementation of the [Pretty Good Privacy](https://en.wikipedia.org/wiki/Pretty_Good_Privacy#OpenPGP) (OpenPGP) specification. Its main purposes are digital authentication, signature and encryption. It is often used by the [FLOSS](https://en.wikipedia.org/wiki/Free_and_open-source_software) community to verify:
 
-It is often used by the [FLOSS](https://en.wikipedia.org/wiki/Free_and_open-source_software) community to verify:
+- Linux package signatures: Debian [SecureApt](https://wiki.debian.org/SecureApt), ArchLinux [Master Keys](https://www.archlinux.org/master-keys/)
+- [Version control](https://en.wikipedia.org/wiki/Revision_control) releases & maintainer identity
 
-- Linux package signatures: Debian [SecureApt](https://wiki.debian.org/SecureApt), ArchLinux [Master 
-Keys](https://www.archlinux.org/master-keys/)
-- [SCM](https://en.wikipedia.org/wiki/Revision_control) releases & maintainer identity
+> You MUST understand that presence of data in the keyserver (pools) in no way connotes trust. Anyone can generate a key, with any name or email address, and upload it. All security and trust comes from evaluating security at the “object level”, via PGP [Web of trust](https://en.wikipedia.org/wiki/Web_of_trust) signatures. This keyserver makes it possible to retrieve keys, looking them up via various indices, but the collection of keys in this public pool is KNOWN to contain malicious and fraudulent keys. It is the common expectation of server operators that users understand this and use software which, like all known common OpenPGP implementations, evaluates trust accordingly. This expectation is so common that it is not normally explicitly stated.
 
-### Trust
-To quote Phil Pennock (the author of the [SKS](https://bitbucket.org/skskeyserver/sks-keyserver/wiki/Home) key server - http://sks.spodhuis.org/):
+-- Phil Pennock (author of the [SKS](https://bitbucket.org/skskeyserver/sks-keyserver/wiki/Home) key server - http://sks.spodhuis.org/)
 
-> You MUST understand that presence of data in the keyserver (pools) in no way connotes trust. Anyone can generate a key, with any name or email address, and upload it. All security and trust comes from evaluating security at the “object level”, via PGP Web-Of-Trust signatures. This keyserver makes it possible to retrieve keys, looking them up via various indices, but the collection of keys in this public pool is KNOWN to contain malicious and fraudulent keys. It is the common expectation of server operators that users understand this and use software which, like all known common OpenPGP implementations, evaluates trust accordingly. This expectation is so common that it is not normally explicitly stated.
+Trust can be gained by having your key signed by other people (and signing their key back, too :) ), for instance during [key signing parties](https://en.wikipedia.org/wiki/Key_signing_party): [Keysigning party HOWTO](http://www.cryptnet.net/fdp/crypto/keysigning_party/en/keysigning_party.html),
 
-Trust can be gained by having your key signed by other people (and signing their key back, too :) ), for instance during [key signing parties](https://en.wikipedia.org/wiki/Key_signing_party), see:
-
-- [The Keysigning party HOWTO](http://www.cryptnet.net/fdp/crypto/keysigning_party/en/keysigning_party.html)
-- [Web of trust](https://en.wikipedia.org/wiki/Web_of_trust)
 
 ## Generate a GPG key
 - [Generating a GPG key for Git tagging](http://stackoverflow.com/a/16725717) (StackOverflow)
similarity index 81%
rename from doc/md/Plugin-System.md
rename to doc/md/dev/Plugin-system.md
index d5b16e2d0d2a200129e709b80932ee3a3ceb7b4e..f09fadc2925db027873cd2d788ac6a239a6ffa68 100644 (file)
@@ -1,19 +1,16 @@
-[**I am a developer: ** Developer API](#developer-api)
-
-[**I am a template designer: ** Guide for template designers](#guide-for-template-designer)
-
----
+# Plugin system
 
 ## Developer API
 
 ### What can I do with plugins?
 
-The plugin system let you:
+The plugin system lets you:
 
 - insert content into specific places across templates.
 - alter data before templates rendering.
 - alter data before saving new links.
 
+
 ### How can I create a plugin for Shaarli?
 
 First, chose a plugin name, such as `demo_plugin`.
@@ -30,6 +27,7 @@ You should have the following tree view:
 |   |---| demo_plugin.php
 ```
 
+
 ### Plugin initialization
 
 At the beginning of Shaarli execution, all enabled plugins are loaded. At this point, the plugin system looks for an `init()` function in the <plugin_name>.php to execute and run it if it exists. This function must be named this way, and takes the `ConfigManager` as parameter.
@@ -63,6 +61,7 @@ For example, if my plugin want to add data to the header, this function is neede
 
 If this function is declared, and the plugin enabled, it will be called every time Shaarli is rendering the header.
 
+
 ### Plugin's data
 
 #### Parameters
@@ -73,6 +72,26 @@ Every hook function has a `$data` parameter. Its content differs for each hooks.
 
     return $data;
 
+#### Special data
+
+Special additional data are passed to every hook through the
+`$data` parameter to give you access to additional context, and services.
+
+Complete list:
+
+  * `_PAGE_` (string): if the current hook is used to render a template, its name is passed through this additional parameter.
+  * `_LOGGEDIN_` (bool): whether the user is logged in or not.
+  * `_BASE_PATH_` (string): if Shaarli instance is hosted under a subfolder, contains the subfolder path to `index.php` (e.g. `https://domain.tld/shaarli/` -> `/shaarli/`).
+  * `_BOOKMARK_SERVICE_` (`BookmarkServiceInterface`): bookmark service instance, for advanced usage.
+
+Example:
+
+```php
+if ($data['_PAGE_'] === TemplatePage::LINKLIST && $data['LOGGEDIN'] === true) {
+    // Do something for logged in users when the link list is rendered
+}
+```
+
 #### Filling templates placeholder
 
 Template placeholders are displayed in template in specific places.
@@ -89,13 +108,14 @@ array_push($data['top_placeholder'], 'My', 'content');
 return $data;
 ```
 
+
 #### Data manipulation
 
 When a page is displayed, every variable send to the template engine is passed to plugins before that in `$data`.
 
 The data contained by this array can be altered before template rendering.
 
-For exemple, in linklist, it is possible to alter every title:
+For example, in linklist, it is possible to alter every title:
 
 ```php
 // mind the reference if you want $data to be altered
@@ -119,19 +139,40 @@ Each file contain two keys:
 
 > Note: In PHP, `parse_ini_file()` seems to want strings to be between by quotes `"` in the ini file.
 
+### Understanding relative paths
+
+Because Shaarli is a self-hosted tool, an instance can either be installed at the root directory, or under a subfolder.
+This means that you can *never* use absolute paths (eg `/plugins/mything/file.png`).
+
+If a file needs to be included in server end, use simple relative path:
+`PluginManager::$PLUGINS_PATH . '/mything/template.html'`.
+
+If it needs to be included in front end side (e.g. an image),
+the relative path must be prefixed with special data:
+
+  * if it's a link that will need to be processed by Shaarli, use `_BASE_PATH_`:
+    for e.g. `$data['_BASE_PATH_'] . '/admin/tools`.
+  * if you want to include an asset, you need to add the root URL (base path without `/index.php`, for people using Shaarli without URL rewriting), then use `_ROOT_PATH_`:
+    for e.g
+`$['_ROOT_PATH_'] . '/' . PluginManager::$PLUGINS_PATH . '/mything/picture.png`.
+
+Note that special placeholders for CSS and JS files (respectively `css_files` and `js_files`) are already prefixed
+with the root path in template files.
+
 ### It's not working!
 
 Use `demo_plugin` as a functional example. It covers most of the plugin system features.
 
 If it's still not working, please [open an issue](https://github.com/shaarli/Shaarli/issues/new).
 
+
 ### Hooks
 
 | Hooks         | Description   |
 | ------------- |:-------------:|
 | [render_header](#render_header) | Allow plugin to add content in page headers. |
 | [render_includes](#render_includes) | Allow plugin to include their own CSS files. |
-| [render_footer](#render_footer) | Allow plugin to add content in page footer and include their own JS files. | 
+| [render_footer](#render_footer) | Allow plugin to add content in page footer and include their own JS files. |
 | [render_linklist](#render_linklist) | It allows to add content at the begining and end of the page, after every link displayed and to alter link data. |
 | [render_editlink](#render_editlink) |  Allow to add fields in the form, or display elements. |
 | [render_tools](#render_tools) |  Allow to add content at the end of the page. |
@@ -145,19 +186,16 @@ If it's still not working, please [open an issue](https://github.com/shaarli/Sha
 | [save_plugin_parameters](#save_plugin_parameters) | Allow to manipulate plugin parameters before they're saved. |
 
 
-
 #### render_header
 
-Triggered on every page.
+Triggered on every page - allows plugins to add content in page headers.
 
-Allow plugin to add content in page headers.
 
 ##### Data
 
 `$data` is an array containing:
 
-- `_PAGE_`: current target page (eg: `linklist`, `picwall`, etc.).
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
+  - [Special data](#special-data)
 
 ##### Template placeholders
 
@@ -175,18 +213,16 @@ List of placeholders:
 
 ![fields_toolbar_example](http://i.imgur.com/3GMifI2.png)
 
-#### render_includes
 
-Triggered on every page.
+#### render_includes
 
-Allow plugin to include their own CSS files.
+Triggered on every page - allows plugins to include their own CSS files.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- `_PAGE_`: current target page (eg: `linklist`, `picwall`, etc.).
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
+  - [Special data](#special-data)
 
 ##### Template placeholders
 
@@ -198,18 +234,18 @@ List of placeholders:
 
 > Note: only add the path of the CSS file. E.g: `plugins/demo_plugin/custom_demo.css`.
 
+
 #### render_footer
 
 Triggered on every page.
 
 Allow plugin to add content in page footer and include their own JS files.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- `_PAGE_`: current target page (eg: `linklist`, `picwall`, etc.).
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
+  - [Special data](#special-data)
 
 ##### Template placeholders
 
@@ -226,20 +262,21 @@ List of placeholders:
 
 > Note: only add the path of the JS file. E.g: `plugins/demo_plugin/custom_demo.js`.
 
+
 #### render_linklist
 
 Triggered when `linklist` is displayed (list of links, permalink, search, tag filtered, etc.).
 
 It allows to add content at the begining and end of the page, after every link displayed and to alter link data.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
-- All templates data, including links.
+  - All templates data, including links.
+  - [Special data](#special-data)
 
-##### Template placeholders
+##### template placeholders
 
 Items can be displayed in templates by adding an entry in `$data['<placeholder>']` array.
 
@@ -261,19 +298,21 @@ List of placeholders:
 
 ![plugin_end_zone_example](http://i.imgur.com/6IoRuop.png)
 
+
 #### render_editlink
 
 Triggered when the link edition form is displayed.
 
 Allow to add fields in the form, or display elements.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- All templates data.
+  - All templates data.
+  - [Special data](#special-data)
 
-##### Template placeholders
+##### template placeholders
 
 Items can be displayed in templates by adding an entry in `$data['<placeholder>']` array.
 
@@ -283,19 +322,21 @@ List of placeholders:
 
 ![edit_link_plugin_example](http://i.imgur.com/5u17Ens.png)
 
+
 #### render_tools
 
 Triggered when the "tools" page is displayed.
 
 Allow to add content at the end of the page.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- All templates data.
+  - All templates data.
+  - [Special data](#special-data)
 
-##### Template placeholders
+##### template placeholders
 
 Items can be displayed in templates by adding an entry in `$data['<placeholder>']` array.
 
@@ -305,20 +346,21 @@ List of placeholders:
 
 ![tools_plugin_example](http://i.imgur.com/Bqhu9oQ.png)
 
+
 #### render_picwall
 
 Triggered when picwall is displayed.
 
 Allow to add content at the top and bottom of the page.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
-- All templates data.
+  - All templates data.
+  - [Special data](#special-data)
 
-##### Template placeholders
+##### template placeholders
 
 Items can be displayed in templates by adding an entry in `$data['<placeholder>']` array.
 
@@ -329,18 +371,19 @@ List of placeholders:
 
 ![plugin_start_end_zone_example](http://i.imgur.com/tVTQFER.png)
 
+
 #### render_tagcloud
 
 Triggered when tagcloud is displayed.
 
 Allow to add content at the top and bottom of the page.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
-- All templates data.
+  - All templates data.
+  - [Special data](#special-data)
 
 ##### Template placeholders
 
@@ -360,16 +403,14 @@ For each tag, the following placeholder can be used:
 
 #### render_taglist
 
-Triggered when taglist is displayed.
-
-Allow to add content at the top and bottom of the page.
+Triggered when taglist is displayed - allows to add content at the top and bottom of the page.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
-- All templates data.
+  - All templates data.
+  - [Special data](#special-data)
 
 ##### Template placeholders
 
@@ -390,12 +431,13 @@ Triggered when tagcloud is displayed.
 
 Allow to add content at the top and bottom of the page, the bottom of each link and to alter data.
 
-##### Data
+
+##### data
 
 `$data` is an array containing:
 
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
-- All templates data, including links.
+  - All templates data, including links.
+  - [Special data](#special-data)
 
 ##### Template placeholders
 
@@ -410,19 +452,19 @@ List of placeholders:
 - `plugin_start_zone`: before displaying the template content.
 - `plugin_end_zone`: after displaying the template content.
 
+
 #### render_feed
 
 Triggered when the ATOM or RSS feed is displayed.
 
 Allow to add tags in the feed, either in the header or for each items. Items (links) can also be altered before being rendered.
 
-##### Data
+##### data
 
 `$data` is an array containing:
 
-- `_LOGGEDIN_`: true if user is logged in, false otherwise.
-- `_PAGE_`: containing either `rss` or `atom`.
-- All templates data, including links.
+  - All templates data, including links.
+  - [Special data](#special-data)
 
 ##### Template placeholders
 
@@ -436,13 +478,14 @@ For each links:
 
 - `feed_plugins`: additional tag for every link entry.
 
+
 #### save_link
 
 Triggered when a link is save (new link or edit).
 
 Allow to alter the link being saved in the datastore.
 
-##### Data
+##### data
 
 `$data` is an array containing the link being saved:
 
@@ -456,6 +499,8 @@ Allow to alter the link being saved in the datastore.
 - created
 - updated
 
+Also [special data](#special-data).
+
 
 #### delete_link
 
@@ -463,9 +508,9 @@ Triggered when a link is deleted.
 
 Allow to execute any action before the link is actually removed from the datastore
 
-##### Data
+##### data
 
-`$data` is an array containing the link being saved:
+`$data` is an array containing the link being deleted:
 
 - id
 - title
@@ -477,6 +522,7 @@ Allow to execute any action before the link is actually removed from the datasto
 - created
 - updated
 
+Also [special data](#special-data).
 
 #### save_plugin_parameters
 
@@ -485,15 +531,16 @@ Triggered when the plugin parameters are saved from the plugin administration pa
 Plugins can perform an action every times their settings are updated.
 For example it is used to update the CSS file of the `default_colors` plugins.
 
-##### Data
+##### data
 
 `$data` input contains the `$_POST` array.
 
 So if the plugin has a parameter called `MYPLUGIN_PARAMETER`,
 the array will contain an entry with `MYPLUGIN_PARAMETER` as a key.
 
+Also [special data](#special-data).
 
-## Guide for template designer
+## Guide for template designers
 
 ### Plugin administration
 
@@ -515,7 +562,7 @@ Otherwise, you can use your own JS as long as this field is send by the form:
 
 ### Placeholder system
 
-In order to make plugins work with every custom themes, you need to add variable placeholder in your templates. 
+In order to make plugins work with every custom themes, you need to add variable placeholder in your templates.
 
 It's a RainTPL loop like this:
 
@@ -537,7 +584,7 @@ At the end of the menu:
 
 At the end of file, before clearing floating blocks:
 
-    {if="!empty($plugin_errors) && isLoggedIn()"}
+    {if="!empty($plugin_errors) && $is_logged_in"}
         <ul class="errors">
             {loop="plugin_errors"}
                 <li>{$value}</li>
diff --git a/doc/md/dev/Release-Shaarli.md b/doc/md/dev/Release-Shaarli.md
new file mode 100644 (file)
index 0000000..2c77240
--- /dev/null
@@ -0,0 +1,145 @@
+# Release Shaarli
+
+## Requirements
+
+This guide assumes that you have:
+
+- a GPG key matching your GitHub authentication credentials/email (the email address identified by the GPG key is the same as the one in your `~/.gitconfig`)
+- a GitHub fork of Shaarli
+- a local clone of your Shaarli fork, with the following remotes:
+    - `origin` pointing to your GitHub fork
+    - `upstream` pointing to the main Shaarli repository
+- maintainer permissions on the main Shaarli repository, to:
+    - push the signed tag
+    - create a new release
+- [Composer](https://getcomposer.org/) needs to be installed
+- The [venv](https://docs.python.org/3/library/venv.html) Python 3 module needs to be installed for HTML documentation generation.
+
+## Release notes and `CHANGELOG.md`
+
+GitHub allows drafting the release notes for the upcoming release, from the [Releases](https://github.com/shaarli/Shaarli/releases) page. This way, the release note can be drafted while contributions are merged to `master`. See http://keepachangelog.com/en/0.3.0/ for changelog formatting.
+
+`CHANGELOG.md` should contain the same information as the release note draft for the upcoming version. Update it to:
+
+- add new entries (additions, fixes, etc.)
+- mark the current version as released by setting its date and link
+- add a new section for the future unreleased version
+
+```bash
+## [v0.x.y](https://github.com/shaarli/Shaarli/releases/tag/v0.x.y) - UNRELEASES
+
+### Added
+
+### Changed
+
+### Fixed
+
+### Removed
+
+### Deprecated
+
+### Security
+
+```
+
+
+## Update the list of Git contributors
+
+```bash
+$ make authors
+$ git commit -s -m "Update AUTHORS"
+```
+
+## Create and merge a Pull Request
+
+Create a Pull Request to marge changes from your remote, into `master` in the community Shaarli repository, and have it merged.
+
+
+## Create the release branch and update shaarli_version.php
+
+```bash
+# fetch latest changes from master to your local copy
+git checkout master
+git pull upstream master
+
+# If releasing a new minor version, create a release branch
+$ git checkout -b v0.x
+
+# Bump shaarli_version.php from dev to 0.x.0, **without the v**
+$ vim shaarli_version.php
+$ git add shaarli_version
+$ git commit -s -m "Bump Shaarli version to v0.x.0"
+$ git push upstream v0.x
+```
+
+## Create and push a signed tag
+
+Git [tags](http://git-scm.com/book/en/v2/Distributed-Git-Maintaining-a-Project#Tagging-Your-Releases) are used to identify specific revisions with a unique version number that follows [semantic versioning](https://semver.org/)
+
+```bash
+# update your local copy
+git checkout v0.5
+git pull upstream v0.5
+
+# create a signed tag
+git tag -s -m "Release v0.5.0" v0.5.0
+
+# push the tag to upstream
+git push --tags upstream
+```
+
+Here is how to verify a signed tag. [`v0.5.0`](https://github.com/shaarli/Shaarli/releases/tag/v0.5.0) is the first GPG-signed tag pushed on the Community Shaarli. Let's have a look at its signature!
+
+```bash
+# update the list of available tags
+git fetch upstream
+
+# get the SHA1 reference of the tag
+git show-ref tags/v0.5.0
+# gives: f7762cf803f03f5caf4b8078359a63783d0090c1 refs/tags/v0.5.0
+
+# verify the tag signature information
+git verify-tag f7762cf803f03f5caf4b8078359a63783d0090c1
+# gpg: Signature made Thu 30 Jul 2015 11:46:34 CEST using RSA key ID 4100DF6F
+# gpg: Good signature from "VirtualTam <virtualtam@flibidi.net>" [ultimate]
+```
+
+## Publish the GitHub release
+
+- In the `master` banch, update version badges in `README.md` to point to the newly released Shaarli version
+- Update the previously drafted [release](https://github.com/shaarli/Shaarli/releases) (notes, tag) and publish it
+- Profit!
+
+
+## Generate full release zip archives
+
+Release archives will contain Shaarli code plus all required third-party libraries. They are useful for users who:
+
+- have no SSH access, no possibility to install PHP packages/server extensions, no possibility to run scripts (shared hosting)
+- do not want to install build/dev dependencies on their server
+
+ `git checkout` the appropriate branch, then:
+
+```bash
+# checkout the appropriate branch
+git checkout 0.x.y
+# generate zip archives
+make release_archive
+```
+
+This will create `shaarli-v0.x.y-full.tar`, `shaarli-v0.x.y-full.zip`. These archives need to be manually uploaded on the previously created GitHub [release](https://github.com/shaarli/Shaarli/releases).
+
+
+### Update the `latest` branch
+
+```bash
+# checkout the 'latest' branch
+git checkout latest
+# merge changes from your newly published release branch
+git merge v0.x.y
+# fix eventual conflicts with git mergetool...
+# run tests
+make test
+# push the latest branch
+git push upstream latest
+```
similarity index 95%
rename from doc/md/Theming.md
rename to doc/md/dev/Theming.md
index eb84e11c7ecf40056373fdd2bec539690c654a59..1ad30465b163106938e93b11b2037b120b4f4f15 100644 (file)
@@ -1,3 +1,5 @@
+# Theming
+
 ## Foreword
 
 There are two ways of customizing how Shaarli looks:
@@ -43,6 +45,7 @@ Installation:
 - [kalvn/shaarli-blocks](https://github.com/kalvn/shaarli-blocks) - A template/theme for Shaarli
 - [kalvn/Shaarli-Material](https://github.com/kalvn/Shaarli-Material) - A theme (template) based on Google's Material Design for Shaarli, the superfast delicious clone
 - [ManufacturaInd/shaarli-2004licious-theme](https://github.com/ManufacturaInd/shaarli-2004licious-theme) - A template/theme as a humble homage to the early looks of the del.icio.us site
+- [xfnw/shaarli-default-dark](https://github.com/xfnw/shaarli-default-dark) - The default theme but nice and dark for your eyeballs
 
 ### Shaarli forks
 
similarity index 57%
rename from doc/md/Translations.md
rename to doc/md/dev/Translations.md
index 58b92da387732ef330a54e3ba030c6b0a81ee68d..8f3b8f10c45f23151eb7d21b6ebe12a66aca6ff1 100644 (file)
@@ -7,87 +7,80 @@ Note that only the `default` theme supports translations.
 
 ### Contributing
 
-We encourage the community to contribute to Shaarli's translation either by improving existing
-translations or submitting a new language.
+We encourage the community to contribute to Shaarli translations, either by improving existing translations or submitting a new language.
 
-Contributing to the translation does not require development skill.
+Contributing to the translation does not require software development knowledge.
 
-Please submit a pull request with the `.po` file updated/created. Note that the compiled file (`.mo`)
-is not stored on the repository, and is generated during the release process.
+Please submit a pull request with the `.po` file updated/created. Note that the compiled file (`.mo`) is not stored on the repository, and is generated during the release process.
 
-### How to
-
-First, install [Poedit](https://poedit.net/) tool.
 
-Poedit will extract strings to translate from the PHP source code.
-
-**Important**: due to the usage of a template engine, it's important to generate PHP cache files to extract
-every translatable string.
+### How to
 
-You can either use [this script](https://gist.github.com/ArthurHoaro/5d0323f758ab2401ef444a53f54e9a07)  (recommended)
-or visit every template page in your browser to generate cache files, while logged in.
+Install [Poedit](https://poedit.net/) (used to extract strings to translate from the PHP source code, and generate `.po` files).
 
-Here is a list :
+Due to the usage of a template engine, it's important to generate PHP cache files to extract every translatable string. You can either use [this script](https://gist.github.com/ArthurHoaro/5d0323f758ab2401ef444a53f54e9a07) (recommended) or visit every template page in your browser to generate cache files, while logged in. Here is a list :
 
 ```
 http://<replace_domain>/
-http://<replace_domain>/?nonope
-http://<replace_domain>/?do=addlink
-http://<replace_domain>/?do=changepasswd
-http://<replace_domain>/?do=changetag
-http://<replace_domain>/?do=configure
-http://<replace_domain>/?do=tools
-http://<replace_domain>/?do=daily
-http://<replace_domain>/?post
-http://<replace_domain>/?do=export
-http://<replace_domain>/?do=import
 http://<replace_domain>/login
-http://<replace_domain>/?do=picwall
-http://<replace_domain>/?do=pluginadmin
-http://<replace_domain>/?do=tagcloud
-http://<replace_domain>/?do=taglist
+http://<replace_domain>/daily
+http://<replace_domain>/tags/cloud
+http://<replace_domain>/tags/list
+http://<replace_domain>/picture-wall
+http://<replace_domain>/?nonope
+http://<replace_domain>/admin/add-shaare
+http://<replace_domain>/admin/password
+http://<replace_domain>/admin/tags
+http://<replace_domain>/admin/configure
+http://<replace_domain>/admin/tools
+http://<replace_domain>/admin/shaare
+http://<replace_domain>/admin/export
+http://<replace_domain>/admin/import
+http://<replace_domain>/admin/plugins
 ```
 
-#### Improve existing translation
 
-In Poedit, click on "Edit a Translation", and from Shaarli's directory open
-`inc/languages/<lang>/LC_MESSAGES/shaarli.po`.
+#### Improve existing translations
 
-The existing list of translatable strings should have been loaded, then click on the "Update" button.
-
-You can start editing the translation.
+- In Poedit, click on "Edit a Translation
+- Open `inc/languages/<lang>/LC_MESSAGES/shaarli.po` under Shaarli's directory
+- The existing list of translatable strings should load
+- Click on the "Update" button.
+- Start editing translations.
 
 ![poedit-screenshot](images/poedit-1.jpg)
 
 Save when you're done, then you can submit a pull request containing the updated `shaarli.po`.
 
-#### Add a new language
-
-Open Poedit and select "Create New Translation", then from Shaarli's directory open
-`inc/languages/<lang>/LC_MESSAGES/shaarli.po`.
-
-Then select the language you want to create.
 
-Click on `File > Save as...`, and save your file in `<shaarli directory>/inc/language/<new language>/LC_MESSAGES/shaarli.po`.
-`<new language>` here should be the language code respecting the [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-2)
-format in lowercase (e.g. `de` for German).
+#### Add a new language
 
-Then click on the "Update" button, and you can start to translate every available string.
+- In Poedit select "Create New Translation"
+- Open `inc/languages/<lang>/LC_MESSAGES/shaarli.po` under Shaarli's directory
+- Select the language you want to create.
+- Click on `File > Save as...`, save your file in `<shaarli directory>/inc/language/<new language>/LC_MESSAGES/shaarli.po` (`<new language>` here should be the language code respecting the [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-2) format in lowercase - e.g. `de` for German)
+- Click on the "Update" button
+- Start editing translations.
 
 Save when you're done, then you can submit a pull request containing the new `shaarli.po`.
 
+
 ### Theme translations
 
-Theme translation extensions are loaded automatically if they're present.
+[Theme](Theming) translation extensions are loaded automatically if they're present.
 
 As a theme developer, all you have to do is to add the `.po` and `.mo` compiled file like this:
 
-    tpl/<theme name>/language/<lang>/LC_MESSAGES/<theme name>.po
-    tpl/<theme name>/language/<lang>/LC_MESSAGES/<theme name>.mo
+```
+tpl/<theme name>/language/<lang>/LC_MESSAGES/<theme name>.po
+tpl/<theme name>/language/<lang>/LC_MESSAGES/<theme name>.mo
+```
 
 Where `<lang>` is the ISO 3166-1 alpha-2 language code.
+
 Read the following section "Extend Shaarli's translation" to learn how to generate those files.
 
+
 ### Extend Shaarli's translation
 
 If you're writing a custom theme, or a non official plugin, you might want to use the translation system,
diff --git a/doc/md/dev/Unit-tests.md b/doc/md/dev/Unit-tests.md
new file mode 100644 (file)
index 0000000..fd286bf
--- /dev/null
@@ -0,0 +1,133 @@
+# Unit tests
+
+Shaarli uses the [PHPUnit](https://phpunit.de/) test framework; it can be installed with [Composer](https://getcomposer.org/), which is a dependency management tool.
+
+## Install composer
+
+You can either use:
+
+- a system-wide version, e.g. installed through your distro's package manager
+- a local version, downloadable [here](https://getcomposer.org/download/).
+
+```bash
+# for Debian-based distros
+sudo apt install composer
+```
+
+
+## Install Shaarli dev dependencies
+
+```bash
+$ cd /path/to/shaarli
+$ make composer_dependencies_dev
+```
+
+## Install and enable Xdebug to generate PHPUnit coverage reports
+
+
+[Xdebug](http://xdebug.org/docs/install) is a PHP extension which provides debugging and profiling capabilities. Install Xdebug:
+
+```bash
+# for Debian-based distros:
+sudo apt install php-xdebug
+
+# for ArchLinux:
+pacman -S xdebug
+
+# then add the following line to /etc/php/php.ini
+zend_extension=xdebug.so
+```
+
+## Run unit tests
+
+Ensure tests pass successuflly:
+
+```bash
+make test
+# ...
+# OK (36 tests, 65 assertions)
+```
+
+In case of failure the test suite will point you to actual errors and output a summary:
+
+```bash
+make test
+# ...
+# FAILURES!
+# Tests: 36, Assertions: 63, Errors: 1, Failures: 2.
+```
+
+By default, PHPUnit will run all suitable tests found under the `tests` directory. Each test has 3 possible outcomes:
+
+- `.` - success
+- `F` - failure: the test was run but its results are invalid
+    - the code does not behave as expected
+    - dependencies to external elements: globals, session, cache...
+- `E` - error: something went wrong and the tested code has crashed
+    - typos in the code, or in the test code
+    - dependencies to missing external elements
+
+If Xdebug has been installed and activated, two coverage reports will be generated:
+
+- a summary in the console
+- a detailed HTML report with metrics for tested code
+    - to open it in a web browser: `firefox coverage/index.html &`
+
+
+### Executing specific tests
+
+Add a [`@group`](https://phpunit.de/manual/current/en/appendixes.annotations.html#appendixes.annotations.group) annotation in a test class or method comment:
+
+```php
+/**
+ * Netscape bookmark import
+ * @group WIP
+ */
+class BookmarkImportTest extends PHPUnit_Framework_TestCase
+{
+   [...]
+}
+```
+
+To run all tests annotated with `@group WIP`:
+```bash
+$ vendor/bin/phpunit --group WIP tests/
+```
+
+## Running tests inside Docker containers
+
+Unit tests can be run inside [Docker](../Docker.md) containers.
+
+Test Dockerfiles are located under `tests/docker/<distribution>/Dockerfile`, and can be used to build Docker images to run Shaarli test suites under commonLinux environments. Dockerfiles are provided for the following environments:
+
+- [`alpine36`](https://github.com/shaarli/Shaarli/blob/master/tests/docker/alpine36/Dockerfile) - [Alpine Linux 3.6](https://www.alpinelinux.org/downloads/)
+- [`debian8`](https://github.com/shaarli/Shaarli/blob/master/tests/docker/debian8/Dockerfile) - [Debian 8 Jessie](https://www.debian.org/DebianJessie) (oldoldstable)
+- [`debian9`](https://github.com/shaarli/Shaarli/blob/master/tests/docker/debian9/Dockerfile) - [Debian 9 Stretch](https://wiki.debian.org/DebianStretch) (oldstable)
+- [`ubuntu16`](https://github.com/shaarli/Shaarli/blob/master/tests/docker/ubuntu16/Dockerfile) - [Ubuntu 16.04 Xenial Xerus](http://releases.ubuntu.com/16.04/) (old LTS)
+
+Each image provides:
+- a base Linux OS
+- Shaarli PHP dependencies (OS packages)
+- test PHP dependencies (OS packages)
+- Composer
+- Tests that run inside the conatiner using a standard Linux user account (running tests as `root` would bypass permission checks and may hide issues)
+
+Build a test image:
+
+```bash
+# build the Debian 9 Docker image
+cd /path/to/shaarli/tests/docker/debian9
+docker build -t shaarli-test:debian9 .
+```
+
+Run unit tests in a container:
+
+```bash
+cd /path/to/shaarli
+# install/update 3rd-party test dependencies
+composer install --prefer-dist
+# run tests using the freshly built image
+docker run -v $PWD:/shaarli shaarli-test:debian9 docker_test
+# run the full test campaign
+docker run -v $PWD:/shaarli shaarli-test:debian9 docker_all_tests
+```
similarity index 58%
rename from doc/md/Versioning-and-Branches.md
rename to doc/md/dev/Versioning.md
index 7097ca0a9023c747c93bdd182cb4fdbb32f744f5..32c80a5cee89cb315200b53076bf615b08add415 100644 (file)
@@ -1,6 +1,7 @@
-**WORK IN PROGRESS**
+# Versioning
+
+If you're maintaining a 3rd party tool for Shaarli (theme, plugin, etc.), It's important to understand how Shaarli branches work ensure your tool stays compatible.
 
-It's important to understand how Shaarli branches work, especially if you're maintaining a 3rd party tools for Shaarli (theme, plugin, etc.), to be sure stay compatible.
 
 ## `master` branch
 
@@ -11,39 +12,26 @@ Remarks:
 - This branch shouldn't be used for production as it isn't necessary stable.
 - 3rd party aren't required to be compatible with the latest changes.
 - Official plugins, themes and libraries (contained within Shaarli organization repos) must be compatible with the master branch.
-- The version in this branch is always `dev`.
 
-## `v0.x` branch
 
-This `v0.x` branch, points to the latest `v0.x.y` release.
+## `v0.x` branch
 
-Explanation:
+The `v0.x` branch points to the latest `v0.x.y` release.
 
-When a new version is released, it might contains a major bug which isn't detected right away. For example, a new PHP version is released, containing backward compatibility issue which doesn't work with Shaarli.
+If a major bug affects the original `v0.x.0` release, we may [backport](https://en.wikipedia.org/wiki/Backporting) a fix for this bug from master, to the `v0.x` branch, and create a new bugfix release (eg. `v0.x.1`) from this branch.
 
-In this case, the issue is fixed in the `master` branch, and the fix is backported the to the `v0.x` branch. Then a new release is made from the `v0.x` branch.
+This allows users of the original release to upgrade to the fixed version, without having to upgrade to a completely new minor/major release.
 
-This workflow allow us to fix any major bug detected, without having to release bleeding edge feature too soon.
 
 ## `latest` branch
 
 This branch point the latest release. It recommended to use it to get the latest tested changes.
 
-## `stable` branch
-
-The `stable` branch doesn't contain any major bug, and is one major digit version behind the latest release.
-
-For example, the current latest release is `v0.8.3`, the stable branch is an alias to the latest `v0.7.x` release. When the `v0.9.0` version will be released, the stable will move to the latest `v0.8.x` release.
-
-Remarks:
-
-- Shaarli release pace isn't fast, and the stable branch might be a few months behind the latest release.
 
 ## Releases
 
-Releases are always made from the latest `v0.x` branch.
+For every release, we manually generate a .zip file which contains all Shaarli dependencies, making Shaarli's installation only one step.
 
-Note that for every release, we manually generate a tarball which contains all Shaarli dependencies, making Shaarli's installation only one step.
 
 ## Advices on 3rd party git repos workflow
 
diff --git a/doc/md/docker/docker-101.md b/doc/md/docker/docker-101.md
deleted file mode 100644 (file)
index a9c00b8..0000000
+++ /dev/null
@@ -1,140 +0,0 @@
-## Basics
-Install [Docker](https://www.docker.com/), by following the instructions relevant
-to your OS / distribution, and start the service.
-
-### Search an image on [DockerHub](https://hub.docker.com/)
-
-```bash
-$ docker search debian
-
-NAME            DESCRIPTION                                     STARS   OFFICIAL   AUTOMATED
-ubuntu          Ubuntu is a Debian-based Linux operating s...   2065    [OK]
-debian          Debian is a Linux distribution that's comp...   603     [OK]
-google/debian                                                   47                 [OK]
-```
-
-### Show available tags for a repository
-```bash
-$ curl https://index.docker.io/v1/repositories/debian/tags | python -m json.tool
-
-% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
-Dload  Upload   Total   Spent    Left  Speed
-100  1283    0  1283    0     0    433      0 --:--:--  0:00:02 --:--:--   433
-```
-
-Sample output:
-```json
-[
-    {
-        "layer": "85a02782",
-        "name": "stretch"
-    },
-    {
-        "layer": "59abecbc",
-        "name": "testing"
-    },
-    {
-        "layer": "bf0fd686",
-        "name": "unstable"
-    },
-    {
-        "layer": "60c52dbe",
-        "name": "wheezy"
-    },
-    {
-        "layer": "c5b806fe",
-        "name": "wheezy-backports"
-    }
-]
-
-```
-
-### Pull an image from DockerHub
-```bash
-$ docker pull repository[:tag]
-
-$ docker pull debian:wheezy
-wheezy: Pulling from debian
-4c8cbfd2973e: Pull complete
-60c52dbe9d91: Pull complete
-Digest: sha256:c584131da2ac1948aa3e66468a4424b6aea2f33acba7cec0b631bdb56254c4fe
-Status: Downloaded newer image for debian:wheezy
-```
-
-Docker re-uses layers already downloaded. In other words if you have images based on Alpine or some Ubuntu version for example, those can share disk space.
-
-### Start a container
-A container is an instance created from an image, that can be run and that keeps running until its main process exits. Or until the user stops the container. 
-
-The simplest way to start a container from image is ``docker run``. It also pulls the image for you if it is not locally available. For more advanced use, refer to ``docker create``.
-
-Stopped containers are not destroyed, unless you specify ``--rm``. To view all created, running and stopped containers, enter:
-```bash
-$ docker ps -a
-```
-
-Some containers may be designed or configured to be restarted, others are not. Also remember both network ports and volumes of a container are created on start, and not editable later.
-
-### Access a running container
-A running container is accessible using ``docker exec``, or ``docker copy``. You can use ``exec`` to start a root shell in the Shaarli container:
-```bash
-$ docker exec -ti <container-name-or-id> bash
-```
-Note the names and ID's of containers are listed in ``docker ps``. You can even type only one or two letters of the ID, given they are unique.
-
-Access can also be through one or more network ports, or disk volumes. Both are specified on and fixed on ``docker create`` or ``run``.
-
-You can view the console output of the main container process too:
-```bash
-$ docker logs -f <container-name-or-id>
-```
-
-### Docker disk use
-Trying out different images can fill some gigabytes of disk quickly. Besides images, the docker volumes usually take up most disk space.
-
-If you care only about trying out docker and not about what is running or saved, the following commands should help you out quickly if you run low on disk space:
-
-```bash
-$ docker rmi -f $(docker images -aq) # remove or mark all images for disposal
-$ docker volume rm $(docker volume ls -q) # remove all volumes
-```
-
-### Systemd config
-Systemd is the process manager of choice on Debian-based distributions. Once you have a ``docker`` service installed, you can use the following steps to set up Shaarli to run on system start.
-
-```bash
-systemctl enable /etc/systemd/system/docker.shaarli.service
-systemctl start docker.shaarli
-systemctl status docker.*
-journalctl -f # inspect system log if needed
-```
-
-You will need sudo or a root terminal to perform some or all of the steps above. Here are the contents for the service file:
-```
-[Unit]
-Description=Shaarli Bookmark Manager Container
-After=docker.service
-Requires=docker.service
-
-
-[Service]
-Restart=always
-
-# Put any environment you want in an included file, like $host- or $domainname in this example
-EnvironmentFile=/etc/sysconfig/box-environment
-
-# It's just an example..
-ExecStart=/usr/bin/docker run \
-  -p 28010:80 \
-  --name ${hostname}-shaarli \
-  --hostname shaarli.${domainname} \
-  -v /srv/docker-volumes-local/shaarli-data:/var/www/shaarli/data:rw \
-  -v /etc/localtime:/etc/localtime:ro \
-  shaarli/shaarli:latest
-
-ExecStop=/usr/bin/docker rm -f ${hostname}-shaarli
-
-
-[Install]
-WantedBy=multi-user.target
-```
diff --git a/doc/md/docker/resources.md b/doc/md/docker/resources.md
deleted file mode 100644 (file)
index 082d4a4..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-### Docker
-
-- [Interactive Docker training portal](https://www.katacoda.com/courses/docker/) on [Katakoda](https://www.katacoda.com/)
-- [Where are Docker images stored?](http://blog.thoward37.me/articles/where-are-docker-images-stored/)
-- [Dockerfile reference](https://docs.docker.com/reference/builder/)
-- [Dockerfile best practices](https://docs.docker.com/articles/dockerfile_best-practices/)
-- [Volumes](https://docs.docker.com/userguide/dockervolumes/)
-
-### DockerHub
-
-- [Repositories](https://docs.docker.com/userguide/dockerrepos/)
-- [Teams and organizations](https://docs.docker.com/docker-hub/orgs/)
-- [GitHub automated build](https://docs.docker.com/docker-hub/github/)
-
-### Service management
-
-- [Using supervisord](https://docs.docker.com/articles/using_supervisord/)
-- [Nginx in the foreground](http://nginx.org/en/docs/ngx_core_module.html#daemon)
-- [supervisord](http://supervisord.org/)
diff --git a/doc/md/docker/reverse-proxy-configuration.md b/doc/md/docker/reverse-proxy-configuration.md
deleted file mode 100644 (file)
index e53c942..0000000
+++ /dev/null
@@ -1,123 +0,0 @@
-## Foreword
-
-This guide assumes that:
-
-- Shaarli runs in a Docker container
-- The host's `10080` port is mapped to the container's `80` port
-- Shaarli's Fully Qualified Domain Name (FQDN) is `shaarli.domain.tld`
-- HTTP traffic is redirected to HTTPS
-
-## Apache
-
-- [Apache 2.4 documentation](https://httpd.apache.org/docs/2.4/)
-    - [mod_proxy](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html)
-    - [Reverse Proxy Request Headers](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#x-headers)
-
-The following HTTP headers are set when the `ProxyPass` directive is set:
-
-- `X-Forwarded-For`
-- `X-Forwarded-Host`
-- `X-Forwarded-Server`
-
-The original `SERVER_NAME` can be sent to the proxied host by setting the [`ProxyPreserveHost`](https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#ProxyPreserveHost) directive to `On`.
-
-```apache
-<VirtualHost *:80>
-    ServerName shaarli.domain.tld
-    Redirect permanent / https://shaarli.domain.tld
-</VirtualHost>
-
-<VirtualHost *:443>
-    ServerName shaarli.domain.tld
-
-    SSLEngine on
-    SSLCertificateFile    /path/to/cert
-    SSLCertificateKeyFile /path/to/certkey
-
-    LogLevel warn
-    ErrorLog  /var/log/apache2/shaarli-error.log
-    CustomLog /var/log/apache2/shaarli-access.log combined
-
-    RequestHeader set X-Forwarded-Proto "https"
-    ProxyPreserveHost On
-    
-    ProxyPass        / http://127.0.0.1:10080/
-    ProxyPassReverse / http://127.0.0.1:10080/
-</VirtualHost>
-```
-
-
-## HAProxy
-
-- [HAProxy documentation](https://cbonte.github.io/haproxy-dconv/)
-
-```conf
-global
-    [...]
-
-defaults
-    [...]
-
-frontend http-in
-    bind :80
-       redirect scheme https code 301 if !{ ssl_fc }
-
-       bind :443 ssl crt /path/to/cert.pem
-
-       default_backend shaarli
-
-
-backend shaarli
-    mode http
-    option http-server-close
-    option forwardfor
-    reqadd X-Forwarded-Proto: https
-
-    server shaarli1 127.0.0.1:10080
-```
-
-
-## Nginx
-
-- [Nginx documentation](https://nginx.org/en/docs/)
-
-```nginx
-http {
-    [...]
-
-    index index.html index.php;
-
-    root        /home/john/web;
-    access_log  /var/log/nginx/access.log;
-    error_log   /var/log/nginx/error.log;
-
-       server {
-               listen       80;
-               server_name  shaarli.domain.tld;
-               return       301 https://shaarli.domain.tld$request_uri;
-       }
-
-       server {
-               listen       443 ssl http2;
-               server_name  shaarli.domain.tld;
-
-        ssl_certificate       /path/to/cert
-        ssl_certificate_key   /path/to/certkey
-
-               location / {
-                       proxy_set_header  X-Real-IP         $remote_addr;
-                       proxy_set_header  X-Forwarded-For   $proxy_add_x_forwarded_for;
-                       proxy_set_header  X-Forwarded-Proto $scheme;
-                       proxy_set_header  X-Forwarded-Host  $host;
-
-                       proxy_pass             http://localhost:10080/;
-                       proxy_set_header Host  $host;
-                       proxy_connect_timeout  30s;
-                       proxy_read_timeout     120s;
-
-                       access_log      /var/log/nginx/shaarli.access.log;
-                       error_log       /var/log/nginx/shaarli.error.log;
-               }
-       }
-}
-```
diff --git a/doc/md/docker/shaarli-images.md b/doc/md/docker/shaarli-images.md
deleted file mode 100644 (file)
index 14971d5..0000000
+++ /dev/null
@@ -1,118 +0,0 @@
-A brief guide on getting starting using docker is given in [Docker 101](docker-101.md).
-To learn more about user data and how to keep it across versions, please see [Upgrade and Migration](../Upgrade-and-migration.md).
-
-## Get and run a Shaarli image
-
-### DockerHub repository
-The images can be found in the [`shaarli/shaarli`](https://hub.docker.com/r/shaarli/shaarli/)
-repository.
-
-### Available image tags
-- `latest`: latest branch
-- `master`: master branch
-- `stable`: stable branch
-
-The `latest`, `master` and `stable` images rely on:
-
-- [Alpine Linux](https://www.alpinelinux.org/)
-- [PHP7-FPM](http://php-fpm.org/)
-- [Nginx](http://nginx.org/)
-
-Additional Dockerfiles are provided for the `arm32v7` platform, relying on
-[Linuxserver.io Alpine armhf
-images](https://hub.docker.com/r/lsiobase/alpine.armhf/). These images must be
-built using [`docker
-build`](https://docs.docker.com/engine/reference/commandline/build/) on an
-`arm32v7` machine or using an emulator such as
-[qemu](https://resin.io/blog/building-arm-containers-on-any-x86-machine-even-dockerhub/).
-
-### Download from Docker Hub
-```shell
-$ docker pull shaarli/shaarli
-
-latest: Pulling from shaarli/shaarli
-32716d9fcddb: Pull complete
-84899d045435: Pull complete
-4b6ad7444763: Pull complete
-e0345ef7a3e0: Pull complete
-5c1dd344094f: Pull complete
-6422305a200b: Pull complete
-7d63f861dbef: Pull complete
-3eb97210645c: Pull complete
-869319d746ff: Already exists
-869319d746ff: Pulling fs layer
-902b87aaaec9: Already exists
-Digest: sha256:f836b4627b958b3f83f59c332f22f02fcd495ace3056f2be2c4912bd8704cc98
-Status: Downloaded newer image for shaarli/shaarli:latest
-```
-
-### Create and start a new container from the image
-```shell
-# map the host's :8000 port to the container's :80 port
-$ docker create -p 8000:80 shaarli/shaarli
-d40b7af693d678958adedfb88f87d6ea0237186c23de5c4102a55a8fcb499101
-
-# launch the container in the background
-$ docker start d40b7af693d678958adedfb88f87d6ea0237186c23de5c4102a55a8fcb499101
-d40b7af693d678958adedfb88f87d6ea0237186c23de5c4102a55a8fcb499101
-
-# list active containers
-$ docker ps
-CONTAINER ID  IMAGE            COMMAND               CREATED         STATUS        PORTS                 NAMES
-d40b7af693d6  shaarli/shaarli  /usr/bin/supervisor  15 seconds ago  Up 4 seconds  0.0.0.0:8000->80/tcp  backstabbing_galileo
-```
-
-### Stop and destroy a container
-```shell
-$ docker stop backstabbing_galileo  # those docker guys are really rude to physicists!
-backstabbing_galileo
-
-# check the container is stopped
-$ docker ps
-CONTAINER ID  IMAGE            COMMAND               CREATED         STATUS        PORTS                 NAMES
-
-# list ALL containers
-$ docker ps -a
-CONTAINER ID        IMAGE               COMMAND                CREATED             STATUS                      PORTS               NAMES
-d40b7af693d6        shaarli/shaarli     /usr/bin/supervisor   5 minutes ago       Exited (0) 48 seconds ago                       backstabbing_galileo
-
-# destroy the container
-$ docker rm backstabbing_galileo  # let's put an end to these barbarian practices
-backstabbing_galileo
-
-$ docker ps -a
-CONTAINER ID  IMAGE            COMMAND               CREATED         STATUS        PORTS                 NAMES
-```
-
-### Automatic builds
-Docker users can start a personal instance from an
-[autobuild image](https://hub.docker.com/r/shaarli/shaarli/).
-For example to start a temporary Shaarli at ``localhost:8000``, and keep session
-data (config, storage):
-
-```shell
-MY_SHAARLI_VOLUME=$(cd /path/to/shaarli/data/ && pwd -P)
-docker run -ti --rm \
-         -p 8000:80 \
-         -v $MY_SHAARLI_VOLUME:/var/www/shaarli/data \
-         shaarli/shaarli
-```
-
-### Volumes and data persistence
-Data can be persisted by [using volumes](https://docs.docker.com/storage/volumes/).
-Volumes allow to keep your data when renewing and/or updating container images:
-
-```shell
-# Create data volumes
-$ docker volume create shaarli-data
-$ docker volume create shaarli-cache
-
-# Create and start a Shaarli container using these volumes to persist data
-$ docker create \
-    --name shaarli \
-    -v shaarli-cache:/var/www/shaarli/cache \
-    -v shaarli-data:/var/www/shaarli/data \
-    -p 8000:80 \
-    shaarli/shaarli:master
-$ docker start shaarli
-```
diff --git a/doc/md/guides/backup-restore-import-export.md b/doc/md/guides/backup-restore-import-export.md
deleted file mode 100644 (file)
index bb79007..0000000
+++ /dev/null
@@ -1,64 +0,0 @@
-## Backup and restore the datastore file
-
-Backup the file `data/datastore.php` (by FTP or SSH). Restore by putting the file back in place.
-
-Example command:
-```bash
-rsync -avzP my.server.com:/var/www/shaarli/data/datastore.php datastore-$(date +%Y-%m-%d_%H%M).php
-```
-
-## Export links as...
-
-To export links as an HTML file, under _Tools > Export_, choose:
-
-- _Export all_ to export both public and private links
-- _Export public_ to export public links only
-- _Export private_ to export private links only
-
-Restore by using the `Import` feature.
-
-- This can be done using the [shaarchiver](https://github.com/nodiscc/shaarchiver) tool.
-
-Example command: 
-```bash
-./export-bookmarks.py --url=https://my.server.com/shaarli --username=myusername --password=mysupersecretpassword --download-dir=./ --type=all
-```
-
-## Import links from...
-
-### Diigo
-
-If you export your bookmark from Diigo, make sure you use the Delicious export, not the Netscape export. (Their Netscape export is broken, and they don't seem to be interested in fixing it.)
-
-### Mister Wong
-
-See [this issue](https://github.com/sebsauvage/Shaarli/issues/146) for import tweaks.
-
-### SemanticScuttle
-
-To correctly import the tags from a [SemanticScuttle](http://semanticscuttle.sourceforge.net/) HTML export, edit the HTML file before importing and replace all occurences of `tags=` (lowercase) to `TAGS=` (uppercase).
-
-### Scuttle
-
-Shaarli cannot import data directly from [Scuttle](https://github.com/scronide/scuttle).
-
-However, you can use the third-party [scuttle-to-shaarli](https://github.com/q2apro/scuttle-to-shaarli)
-tool to export the Scuttle database to the Netscape HTML format compatible with the Shaarli importer.
-
-### Refind
-
-You can use the third-party tool [Derefind](https://github.com/ShawnPConroy/Derefind) to convert refind.com bookmark exports to a format that can be imported into Shaarli.
-
-## Import Shaarli links to Firefox
-
-- Export your Shaarli links as described above.
-    - For compatibility reasons, check `Prepend note permalinks with this Shaarli instance's URL (useful to import bookmarks in a web browser)`
-- In Firefox, open the bookmark manager (not the sidebar! `Bookmarks menu > Show all bookmarks` or `Ctrl+Shift+B`)
-- Select `Import and Backup > Import bookmarks in HTML format`
-
-Your bookmarks will be imported in Firefox, ready to use, with tags and descriptions retained. "Self" (notes) shaares will still point to the Shaarli instance you exported them from, but the note text can be viewed directly in the bookmark properties inside your browser. Depending on the number of bookmarks, the import can take some time.
-
-You may be interested in these Firefox addons to manage links imported from Shaarli
-
-- [Bookmark Deduplicator](https://addons.mozilla.org/en-US/firefox/addon/bookmark-deduplicator/) - provides an easy way to deduplicate your bookmarks
-- [TagSieve](https://addons.mozilla.org/en-US/firefox/addon/tagsieve/) - browse your bookmarks by their tags
diff --git a/doc/md/guides/images/01-create-droplet-distro.jpg b/doc/md/guides/images/01-create-droplet-distro.jpg
deleted file mode 100644 (file)
index 63682ba..0000000
Binary files a/doc/md/guides/images/01-create-droplet-distro.jpg and /dev/null differ
diff --git a/doc/md/guides/images/02-create-droplet-region.jpg b/doc/md/guides/images/02-create-droplet-region.jpg
deleted file mode 100644 (file)
index 135a78b..0000000
Binary files a/doc/md/guides/images/02-create-droplet-region.jpg and /dev/null differ
diff --git a/doc/md/guides/images/03-create-droplet-size.jpg b/doc/md/guides/images/03-create-droplet-size.jpg
deleted file mode 100644 (file)
index aa5b2fd..0000000
Binary files a/doc/md/guides/images/03-create-droplet-size.jpg and /dev/null differ
diff --git a/doc/md/guides/images/04-finalize.jpg b/doc/md/guides/images/04-finalize.jpg
deleted file mode 100644 (file)
index 68ec0dc..0000000
Binary files a/doc/md/guides/images/04-finalize.jpg and /dev/null differ
diff --git a/doc/md/guides/images/05-droplet.jpg b/doc/md/guides/images/05-droplet.jpg
deleted file mode 100644 (file)
index 44e93a1..0000000
Binary files a/doc/md/guides/images/05-droplet.jpg and /dev/null differ
diff --git a/doc/md/guides/images/06-domain.jpg b/doc/md/guides/images/06-domain.jpg
deleted file mode 100644 (file)
index 5827dd9..0000000
Binary files a/doc/md/guides/images/06-domain.jpg and /dev/null differ
diff --git a/doc/md/guides/install-shaarli-with-debian9-and-docker.md b/doc/md/guides/install-shaarli-with-debian9-and-docker.md
deleted file mode 100644 (file)
index f1b26d4..0000000
+++ /dev/null
@@ -1,257 +0,0 @@
-_Last updated on 2018-07-01._
-
-## Goals
-- Getting a Virtual Private Server (VPS)
-- Running Shaarli:
-    - as a Docker container,
-    - using the Træfik reverse proxy,
-    - securized with TLS certificates from Let's Encrypt.
-
-
-The following components and tools will be used:
-
-- [Debian](https://www.debian.org/), a GNU/Linux distribution widely used in
-  server environments;
-- [Docker](https://docs.docker.com/engine/docker-overview/), an open platform
-  for developing, shipping, and running applications;
-- [Docker Compose](https://docs.docker.com/compose/), a tool for defining and
-  running multi-container Docker applications.
-
-
-More information can be found in the [Resources](#resources) section at the
-bottom of the guide.
-
-## Getting a Virtual Private Server
-For this guide, I went for the smallest VPS available from DigitalOcean,
-a Droplet with 1 CPU, 1 GiB RAM and 25 GiB SSD storage, which costs
-$5/month ($0.007/hour):
-
-- [Droplets Overview](https://www.digitalocean.com/docs/droplets/overview/)
-- [Pricing](https://www.digitalocean.com/pricing/)
-- [How to Create a Droplet from the DigitalOcean Control Panel](https://www.digitalocean.com/docs/droplets/how-to/create/)
-- [How to Add SSH Keys to Droplets](https://www.digitalocean.com/docs/droplets/how-to/add-ssh-keys/)
-- [Initial Server Setup with Debian 8](https://www.digitalocean.com/community/tutorials/initial-server-setup-with-debian-8) (also applies to Debian 9)
-- [An Introduction to Securing your Linux VPS](https://www.digitalocean.com/community/tutorials/an-introduction-to-securing-your-linux-vps)
-
-### Creating a Droplet
-Select `Debian 9` as the Droplet distribution:
-
-<img src="../images/01-create-droplet-distro.jpg"
-     width="500px"
-     alt="Droplet distribution" />
-
-Choose a region that is geographically close to you:
-
-<img src="../images/02-create-droplet-region.jpg"
-     width="500px"
-     alt="Droplet region" />
-
-Choose a Droplet size that corresponds to your usage and budget:
-
-<img src="../images/03-create-droplet-size.jpg"
-     width="500px"
-     alt="Droplet size" />
-
-Finalize the Droplet creation:
-
-<img src="../images/04-finalize.jpg"
-     width="500px"
-     alt="Droplet finalization" />
-
-Droplet information is displayed on the Control Panel:
-
-<img src="../images/05-droplet.jpg"
-     width="500px"
-     alt="Droplet summary" />
-
-Once your VPS has been created, you will receive an e-mail with connection
-instructions.
-
-## Obtaining a domain name
-After creating your VPS, it will be reachable using its IP address; some hosting
-providers also create a DNS record, e.g. `ns4853142.ip-01-47-127.eu`.
-
-A domain name (DNS record) is required to obtain a certificate and setup HTTPS
-(HTTP with TLS encryption).
-
-Domain names can be obtained from registrars through hosting providers such as
-[Gandi](https://www.gandi.net/en/domain).
-
-Once you have your own domain, you need to create a new DNS record that points
-to your VPS' IP address:
-
-<img src="../images/06-domain.jpg"
-     width="650px"
-     alt="Domain configuration" />
-
-## Host setup
-Now's the time to connect to your freshly created VPS!
-
-```shell
-$ ssh root@188.166.85.8
-
-Linux stretch-shaarli-02 4.9.0-6-amd64 #1 SMP Debian 4.9.88-1+deb9u1 (2018-05-07) x86_64
-
-The programs included with the Debian GNU/Linux system are free software;
-the exact distribution terms for each program are described in the
-individual files in /usr/share/doc/*/copyright.
-
-Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
-permitted by applicable law.
-Last login: Sun Jul  1 11:20:18 2018 from <REDACTED>
-
-root@stretch-shaarli-02:~$
-```
-
-### Updating the system
-```shell
-root@stretch-shaarli-02:~$ apt update && apt upgrade -y
-```
-
-### Setting up Docker
-_The following instructions are from the
-[Get Docker CE for Debian](https://docs.docker.com/install/linux/docker-ce/debian/)
-guide._
-
-Install package dependencies:
-
-```shell
-root@stretch-shaarli-02:~$ apt install -y apt-transport-https ca-certificates curl gnupg2 software-properties-common
-```
-
-Add Docker's package repository GPG key:
-
-```shell
-root@stretch-shaarli-02:~$ curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
-```
-
-Add Docker's package repository:
-
-```shell
-root@stretch-shaarli-02:~$ add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/debian stretch stable"
-```
-
-Update package lists and install Docker:
-
-```shell
-root@stretch-shaarli-02:~$ apt update && apt install -y docker-ce
-```
-
-Verify Docker is properly configured by running the `hello-world` image:
-
-```shell
-root@stretch-shaarli-02:~$ docker run hello-world
-```
-
-### Setting up Docker Compose
-_The following instructions are from the
-[Install Docker Compose](https://docs.docker.com/compose/install/)
-guide._
-
-Download the current version from the release page:
-
-```shell
-root@stretch-shaarli-02:~$ curl -L https://github.com/docker/compose/releases/download/1.21.2/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose
-root@stretch-shaarli-02:~$ chmod +x /usr/local/bin/docker-compose
-```
-
-## Running Shaarli
-Shaarli comes with a configuration file for Docker Compose, that will setup:
-
-- a local Docker network
-- a Docker [volume](https://docs.docker.com/storage/volumes/) to store Shaarli data
-- a Docker [volume](https://docs.docker.com/storage/volumes/) to store Træfik TLS configuration and certificates
-- a [Shaarli](https://hub.docker.com/r/shaarli/shaarli/) instance
-- a [Træfik](https://hub.docker.com/_/traefik/) instance
-
-[Træfik](https://docs.traefik.io/) is a modern HTTP reverse proxy, with native
-support for Docker and [Let's Encrypt](https://letsencrypt.org/).
-
-### Compose configuration
-Create a new directory to store the configuration:
-
-```shell
-root@stretch-shaarli-02:~$ mkdir shaarli && cd shaarli
-root@stretch-shaarli-02:~/shaarli$
-```
-
-Download the current version of Shaarli's `docker-compose.yml`:
-
-```shell
-root@stretch-shaarli-02:~/shaarli$ curl -L https://raw.githubusercontent.com/shaarli/Shaarli/master/docker-compose.yml -o docker-compose.yml
-```
-
-Create the `.env` file and fill in your VPS and domain information (replace
-`<MY_SHAARLI_DOMAIN>` and `<MY_CONTACT_EMAIL>` with your actual information):
-
-```shell
-root@stretch-shaarli-02:~/shaarli$ vim .env
-```
-
-```shell
-SHAARLI_VIRTUAL_HOST=<MY_SHAARLI_DOMAIN>
-SHAARLI_LETSENCRYPT_EMAIL=<MY_CONTACT_EMAIL>
-```
-
-### Pull the Docker images
-```shell
-root@stretch-shaarli-02:~/shaarli$ docker-compose pull
-Pulling shaarli ... done
-Pulling traefik ... done
-```
-
-### Run!
-```shell
-root@stretch-shaarli-02:~/shaarli$ docker-compose up -d
-Creating network "shaarli_http-proxy" with the default driver
-Creating volume "shaarli_traefik-acme" with default driver
-Creating volume "shaarli_shaarli-data" with default driver
-Creating shaarli_shaarli_1 ... done
-Creating shaarli_traefik_1 ... done
-```
-
-## Conclusion
-Congratulations! Your Shaarli instance should be up and running, and available
-at `https://<MY_SHAARLI_DOMAIN>`.
-
-<img src="../images/07-installation.jpg"
-     width="500px"
-     alt="Shaarli installation page" />
-
-## Resources
-### Related Shaarli documentation
-- [Docker 101](../docker/docker-101.md)
-- [Shaarli images](../docker/shaarli-images.md)
-
-### Hosting providers
-- [DigitalOcean](https://www.digitalocean.com/)
-- [Gandi](https://www.gandi.net/en)
-- [OVH](https://www.ovh.co.uk/)
-- [RackSpace](https://www.rackspace.com/)
-- etc.
-
-### Domain Names and Registrars
-- [Introduction to the Domain Name System (DNS)](https://opensource.com/article/17/4/introduction-domain-name-system-dns)
-- [ICANN](https://www.icann.org/)
-- [Domain name registrar](https://en.wikipedia.org/wiki/Domain_name_registrar)
-- [OVH Domain Registration](https://www.ovh.co.uk/domains/)
-- [Gandi Domain Registration](https://www.gandi.net/en/domain)
-
-### HTTPS and Security
-- [Transport Layer Security](https://en.wikipedia.org/wiki/Transport_Layer_Security)
-- [Let's Encrypt](https://letsencrypt.org/)
-
-### Docker
-- [Docker Overview](https://docs.docker.com/engine/docker-overview/)
-- [Docker Documentation](https://docs.docker.com/)
-- [Get Docker CE for Debian](https://docs.docker.com/install/linux/docker-ce/debian/)
-- [docker logs](https://docs.docker.com/engine/reference/commandline/logs/)
-- [Volumes](https://docs.docker.com/storage/volumes/)
-- [Install Docker Compose](https://docs.docker.com/compose/install/)
-- [docker-compose logs](https://docs.docker.com/compose/reference/logs/)
-
-### Træfik
-- [Getting Started](https://docs.traefik.io/)
-- [Docker backend](https://docs.traefik.io/configuration/backends/docker/)
-- [Let's Encrypt and Docker](https://docs.traefik.io/user-guide/docker-and-lets-encrypt/)
-- [traefik](https://hub.docker.com/_/traefik/) Docker image
diff --git a/doc/md/guides/various-hacks.md b/doc/md/guides/various-hacks.md
deleted file mode 100644 (file)
index 0cef99d..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-### Decode datastore content
-
-To display the array representing the data saved in `data/datastore.php`, use the following snippet:
-
-```php
-$data = "tZNdb9MwFIb... <Commented content inside datastore.php>";
-$out = unserialize(gzinflate(base64_decode($data)));
-echo "<pre>"; // Pretty printing is love, pretty printing is life
-print_r($out);
-echo "</pre>";
-exit;
-```
-This will output the internal representation of the datastore, "unobfuscated" (if this can really be considered obfuscation).
-
-Alternatively, you can transform to JSON format (and pretty-print if you have `jq` installed):
-```
-php -r 'print(json_encode(unserialize(gzinflate(base64_decode(preg_replace("!.*/\* (.+) \*/.*!", "$1", file_get_contents("data/datastore.php")))))));' | jq .
-```
-
-### See also
-
-- [Add a new custom field to shaares (example patch)](https://gist.github.com/nodiscc/8b0194921f059d7b9ad89a581ecd482c)
-- [Copy an existing Shaarli installation over SSH, and serve it locally](https://gist.github.com/nodiscc/ed161c66e5b028b5299b0a3733d01c77)
-- [Create multiple Shaarli instances, generate an HTML index of them](https://gist.github.com/nodiscc/52e711cda3bc47717c16065231cf6b20)
diff --git a/doc/md/images/bookmarklet.png b/doc/md/images/bookmarklet.png
deleted file mode 100644 (file)
index 0262578..0000000
Binary files a/doc/md/images/bookmarklet.png and /dev/null differ
diff --git a/doc/md/images/firefoxshare.png b/doc/md/images/firefoxshare.png
deleted file mode 100644 (file)
index 8f8fdba..0000000
Binary files a/doc/md/images/firefoxshare.png and /dev/null differ
diff --git a/doc/md/images/install-shaarli.png b/doc/md/images/install-shaarli.png
deleted file mode 100644 (file)
index d5d5baa..0000000
Binary files a/doc/md/images/install-shaarli.png and /dev/null differ
index 1431f9e12e68a9a504ff5888a7762ac4f792fd5b..2c4995f8acaa875aa1d13795c5818d441b471291 100644 (file)
@@ -2,21 +2,19 @@
 
 The personal, minimalist, super-fast, database free, bookmarking service.
 
-Do you want to share the links you discover?
-Shaarli is a minimalist bookmark manager and link sharing service that you can install on your own server.
-It is designed to be personal (single-user), fast and handy.
-
-<!-- TODO screenshots -->
+Do you want to share the links you discover? Shaarli is a minimalist bookmark manager and link sharing service that you can install on your own server. It is designed to be personal (single-user), fast and handy.
 
 Visit the pages in the sidebar to find information on how to setup, use, configure, tweak and troubleshoot Shaarli.
 
-
 * [GitHub project page](https://github.com/shaarli/Shaarli)
-* [Online documentation](https://shaarli.readthedocs.io/)
-* [Latest releases](https://github.com/shaarli/Shaarli/releases)
+* [Documentation](https://shaarli.readthedocs.io/)
 * [Changelog](https://github.com/shaarli/Shaarli/blob/master/CHANGELOG.md)
 
 
+[![](https://i.imgur.com/8wEBRSG.png)](https://i.imgur.com/WWPfSj0.png) [![](https://i.imgur.com/93PpLLs.png)](https://i.imgur.com/V09kAQt.png) [![](https://i.imgur.com/rrsjWYy.png)](https://i.imgur.com/TZzGHMs.png) [![](https://i.imgur.com/8iRzHfe.png)](https://i.imgur.com/sfJJ6NT.png) [![](https://i.imgur.com/GjZGvIh.png)](https://i.imgur.com/QsedIuJ.png) [![](https://i.imgur.com/TFZ9PEq.png)](https://i.imgur.com/KdtF8Ll.png) [![](https://i.imgur.com/uICDOle.png)](https://i.imgur.com/27wYsbC.png) [![](https://i.imgur.com/tVvD3gH.png)](https://i.imgur.com/zGF4d6L.jpg)
+
+
+
 ## Demo
 
 You can use this [public demo instance of Shaarli](https://demo.shaarli.org).
@@ -25,101 +23,80 @@ It runs the latest development version of Shaarli and is updated/reset daily.
 Login: `demo`; Password: `demo`
 
 
+## Getting started
+
+- [Configure your server](Server-configuration.md)
+- [Install Shaarli](Installation.md)
+- Or install Shaarli using [Docker](Docker.md)
+
+
 ## Features
 
 Shaarli can be used:
 
-- to share, comment and save interesting links and news
+- to share, comment and save interesting links
 - to bookmark useful/frequent links and share them between computers
 - as a minimal blog/microblog/writing platform
-- as a read-it-later list
-- to draft and save articles/posts/ideas
-- to keep notes, documentation and code snippets
-- as a shared clipboard/notepad/pastebin between machines
-- as a todo list
-- to store media playlists
-- to keep extracts/comments from webpages that may disappear.
-- to keep track of ongoing discussions
-- to feed other blogs, aggregators, social networks... using RSS feeds
+- as a read-it-later/todo list
+- as a notepad to draft and save articles/posts/ideas
+- as a knowledge base to keep notes, documentation and code snippets
+- as a shared clipboard/notepad/pastebin between computers
+- as playlist manager for online media
+- to feed other blogs, aggregators, social networks...
 
 ### Edit, view and search your links
 
-- Minimalist design
-- FAST
-- Customizable link titles and descriptions
-- Tags to organize your links (features tag autocompletion, renaming, merging and deletion)
-- Search by tag or using the full-text search
-- Public and private links (visible only to logged-in users)
-- Unique permalinks for easy reference
-- Paginated link list (with image and video thumbnails)
-- Tag cloud and list views
-- Picture wall: image and video thumbnails view (with lazy loading)
-- ATOM and RSS feeds (can also be filtered using tags or text search)
-- Daily: newspaper-like daily digest (and daily RSS feed)
-- URL cleanup: automatic removal of `?utm_source=...`, `fb=...`
-- Extensible through [plugins](https://shaarli.readthedocs.io/en/master/Plugins/#plugin-usage)
-
-### Easy setup
-
-- Dead-simple installation: drop the files, open the page
-- Links are stored in a file (no database required, easy backup: simply copy the datastore file)
-- Import and export links as Netscape bookmarks compatible with most Web browsers
-
-### Accessibility
-
-- Bookmarklet and other tools to share links in one click
-- Support for mobile browsers
-- Degrades gracefully with Javascript disabled
-- Easy page customization through HTML/CSS/RainTPL
-
-### Security
-
-- Discreet pop-up notification when a new release is available
-- Bruteforce protection on the login form
-- Protected against [XSRF](http://en.wikipedia.org/wiki/Cross-site_request_forgery) and session cookie hijacking
+- Editable URL, title, description, tags, private/public status for all your [Shaares](Usage.md)
+- [Tags](Usage.md#tags) to organize your Shaares
+- [Search](Usage.md#search) in all fields
+- Unique [permalinks](Usage.md#permalinks) for easy reference
+- Paginated Shaares list view (with image and video thumbnails)
+- [Tag cloud/list](Usage#tag-cloud) views
+- [Picture wall](Usage#picture-wall)/thumbnails view (with lazy loading)
+- [ATOM and RSS feeds](Usage.md#rss-feeds) (can also be filtered using tags or text search)
+- [Daily](Usage.md#daily): newspaper-like daily digest (and daily RSS feed)
+- URL cleanup: automatic removal of `?utm_source=...`, `fb=...` tracking parameters
+- Extensible through [plugins](Plugins.md)
+- Easily extensible by any client using the [REST API](REST-API.md) exposed by Shaarli
+- Bookmarklet and [other tools](Community-and-related-software.md) to share links in one click
+- Responsive/support for mobile browsers, degrades gracefully with Javascript disabled
 
-<!-- TODO Limitations -->
 
-### REST API
-
-- Easily extensible by any client using the REST API exposed by Shaarli ([API documentation](http://shaarli.github.io/api-documentation/)).
+### Easy setup
 
+- Dead-simple [installation](Installation.md): drop the files on your server, open the page
+- Shaares are stored in a file (no database required, easy [backup](Backup-and-restore.md))
+- [Configurable](Shaarli-configuration.md) from dialog and configuration file
+- Extensible through third-party [plugins and themes](Community-and-related-software.md)
 
 
-## Screenshots
+### Fast
 
-[![](https://i.imgur.com/8wEBRSG.png)](https://i.imgur.com/WWPfSj0.png) [![](https://i.imgur.com/rrsjWYy.png)](https://i.imgur.com/TZzGHMs.png) [![](https://i.imgur.com/uICDOle.png)](https://i.imgur.com/27wYsbC.png) [![](https://i.imgur.com/KNvFGVB.png)](https://i.imgur.com/0f5faqw.png) [![](https://i.imgur.com/tVvD3gH.png)](https://i.imgur.com/zGF4d6L.jpg) [![](https://i.imgur.com/8iRzHfe.png)](https://i.imgur.com/sfJJ6NT.png) [![](https://i.imgur.com/GjZGvIh.png)](https://i.imgur.com/QsedIuJ.png) [![](https://i.imgur.com/TFZ9PEq.png)](https://i.imgur.com/KdtF8Ll.png) [![](https://i.imgur.com/IvlqXXK.png)](https://i.imgur.com/boaaibC.png) [![](https://i.imgur.com/nlETouG.png)](https://i.imgur.com/Ib9O7n3.png)
+- Fast! Small datastore file, write-once/read-many, served most of the time from OS disk caches (no disk I/O)
+- Stays fast with even tens of thousands shaares!
 
 
+### Self-hosted
 
+- Shaarli is an alternative to commercial services such as StumbleUpon, Delicio.us, Diigo...
+- The data is yours, [import and export](Usage#import-export) it to HTML bookmarksformat compatible with most web browser, and from a variety of formats
+- Shaarli does not send any telemetry/metrics/private information to developers
+- Shaarli is Free and Open-Source software, inspect and change how the program works in the [source code](https://github.com/shaarli/Shaarli)
+- Built-in [Security](dev/Development.md#security) features to help you protect your Shaarli instance
 
 
 ## About
 
-### Shaarli community fork
-
-This friendly fork is maintained by the Shaarli community at <https://github.com/shaarli/Shaarli>
-
-This is a community fork of the original [Shaarli](https://github.com/sebsauvage/Shaarli/) project by [Sébastien Sauvage](http://sebsauvage.net/).
-
-The original project is currently unmaintained, and the developer [has informed us](https://github.com/sebsauvage/Shaarli/issues/191) that he would have no time to work on Shaarli in the near future.
+This [community fork](https://github.com/shaarli/Shaarli) of the original [Shaarli](https://github.com/sebsauvage/Shaarli/) project by [Sébastien Sauvage](http://sebsauvage.net/) (now [unmaintained](https://github.com/sebsauvage/Shaarli/issues/191)) has carried on the work to provide [many patches](https://github.com/shaarli/Shaarli/compare/sebsauvage:master...master) for [bug fixes and enhancements](https://github.com/shaarli/Shaarli/issues?q=is%3Aclosed+) in this repository, and will keep maintaining the project for the foreseeable future, while keeping Shaarli simple and efficient.
 
-The Shaarli community has carried on the work to provide [many
-patches](https://github.com/shaarli/Shaarli/compare/sebsauvage:master...master) for
-[bug fixes and enhancements](https://github.com/shaarli/Shaarli/issues?q=is%3Aclosed+)
-in this repository, and will keep maintaining the project for the foreseeable
-future, while keeping Shaarli simple and efficient.
+The original Shaarli instance is still available [here](https://sebsauvage.net/links/) (+25000 shaares!)
 
 
 ### Contributing and getting help
 
-Feedback is very appreciated!
+Feedback is very appreciated! Feel free to propose solutions to existing problems, help us improve the documentation and translations, and submit pull requests :-)
 
-- If you have any questions or ideas, please join the [chat](https://gitter.im/shaarli/Shaarli) (also reachable via [IRC](https://irc.gitter.im/)), post them in our [general discussion](https://github.com/shaarli/Shaarli/issues/308) or read the current [issues](https://github.com/shaarli/Shaarli/issues).
-- Have a look at the open [issues](https://github.com/shaarli/Shaarli/issues) and [pull requests](https://github.com/shaarli/Shaarli/pulls)
-- If you would like a feature added to Shaarli, check the issues labeled [`feature`](https://github.com/shaarli/Shaarli/labels/feature), [`enhancement`](https://github.com/shaarli/Shaarli/labels/enhancement), and [`plugin`](https://github.com/shaarli/Shaarli/labels/plugin).
-- If you've found a bug, please create a [new issue](https://github.com/shaarli/Shaarli/issues/new).
-- Feel free to propose solutions to existing problems, help us improve the documentation and translations, and submit pull requests :-)
+See [Support](Troubleshooting.md#support) to get in touch with the Shaarli community.
 
 
 ### License
index e8ea4271267fb4f02659b78ebf17295ebc9d73b6..a3de4b1c42424a2fcd25fccc17513b11e8556a5d 100644 (file)
@@ -33,7 +33,7 @@ services:
       traefik.frontend.rule: "Host:${SHAARLI_VIRTUAL_HOST}"
 
   traefik:
-    image: traefik
+    image: traefik:1.7-alpine
     command:
       - "--defaultentrypoints=http,https"
       - "--entrypoints=Name:http Address::80 Redirect.EntryPoint:https"
index 026d0101dadd9ce1c89dae7b8e5610ef48d8b55d..60ea7a970660161050c1df4cbd1faa2c11446112 100644 (file)
@@ -1,55 +1,30 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: Shaarli\n"
-"POT-Creation-Date: 2019-07-13 10:45+0200\n"
-"PO-Revision-Date: 2019-07-13 10:49+0200\n"
+"POT-Creation-Date: 2020-10-27 19:44+0100\n"
+"PO-Revision-Date: 2020-10-27 19:44+0100\n"
 "Last-Translator: \n"
 "Language-Team: Shaarli\n"
 "Language: fr_FR\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
 "Content-Transfer-Encoding: 8bit\n"
-"X-Generator: Poedit 2.2.1\n"
+"X-Generator: Poedit 2.3\n"
 "X-Poedit-Basepath: ../../../..\n"
 "Plural-Forms: nplurals=2; plural=(n > 1);\n"
 "X-Poedit-SourceCharset: UTF-8\n"
 "X-Poedit-KeywordsList: t:1,2;t\n"
-"X-Poedit-SearchPath-0: .\n"
-"X-Poedit-SearchPathExcluded-0: node_modules\n"
-"X-Poedit-SearchPathExcluded-1: vendor\n"
+"X-Poedit-SearchPath-0: application\n"
+"X-Poedit-SearchPath-1: tmp\n"
+"X-Poedit-SearchPath-2: index.php\n"
+"X-Poedit-SearchPath-3: init.php\n"
+"X-Poedit-SearchPath-4: plugins\n"
 
-#: application/ApplicationUtils.php:159
-#, php-format
-msgid ""
-"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
-"cannot run. Your PHP version has known security vulnerabilities and should "
-"be updated as soon as possible."
-msgstr ""
-"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
-"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
-"connues et devrait être mise à jour au plus tôt."
-
-#: application/ApplicationUtils.php:189 application/ApplicationUtils.php:201
-msgid "directory is not readable"
-msgstr "le répertoire n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:204
-msgid "directory is not writable"
-msgstr "le répertoire n'est pas accessible en écriture"
-
-#: application/ApplicationUtils.php:222
-msgid "file is not readable"
-msgstr "le fichier n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:225
-msgid "file is not writable"
-msgstr "le fichier n'est pas accessible en écriture"
-
-#: application/History.php:178
+#: application/History.php:180
 msgid "History file isn't readable or writable"
 msgstr "Le fichier d'historique n'est pas accessible en lecture ou en écriture"
 
-#: application/History.php:189
+#: application/History.php:191
 msgid "Could not parse history file"
 msgstr "Format incorrect pour le fichier d'historique"
 
@@ -58,16 +33,20 @@ msgid "Automatic"
 msgstr "Automatique"
 
 #: application/Languages.php:182
+msgid "German"
+msgstr "Allemand"
+
+#: application/Languages.php:183
 msgid "English"
 msgstr "Anglais"
 
-#: application/Languages.php:183
+#: application/Languages.php:184
 msgid "French"
 msgstr "Français"
 
-#: application/Languages.php:184
-msgid "German"
-msgstr "Allemand"
+#: application/Languages.php:185
+msgid "Japanese"
+msgstr "Japonais"
 
 #: application/Thumbnailer.php:62
 msgid ""
@@ -77,50 +56,138 @@ msgstr ""
 "l'extension php-gd doit être chargée pour utiliser les miniatures. Les "
 "miniatures sont désormais désactivées. Rechargez la page."
 
-#: application/Utils.php:379 tests/UtilsTest.php:343
+#: application/Utils.php:402
 msgid "Setting not set"
 msgstr "Paramètre non défini"
 
-#: application/Utils.php:386 tests/UtilsTest.php:341 tests/UtilsTest.php:342
+#: application/Utils.php:409
 msgid "Unlimited"
 msgstr "Illimité"
 
-#: application/Utils.php:389 tests/UtilsTest.php:338 tests/UtilsTest.php:339
-#: tests/UtilsTest.php:353
+#: application/Utils.php:412
 msgid "B"
 msgstr "o"
 
-#: application/Utils.php:389 tests/UtilsTest.php:332 tests/UtilsTest.php:333
-#: tests/UtilsTest.php:340
+#: application/Utils.php:412
 msgid "kiB"
 msgstr "ko"
 
-#: application/Utils.php:389 tests/UtilsTest.php:334 tests/UtilsTest.php:335
-#: tests/UtilsTest.php:351 tests/UtilsTest.php:352
+#: application/Utils.php:412
 msgid "MiB"
 msgstr "Mo"
 
-#: application/Utils.php:389 tests/UtilsTest.php:336 tests/UtilsTest.php:337
+#: application/Utils.php:412
 msgid "GiB"
 msgstr "Go"
 
-#: application/bookmark/LinkDB.php:128
-msgid "You are not authorized to add a link."
-msgstr "Vous n'êtes pas autorisé à ajouter un lien."
+#: application/bookmark/BookmarkFileService.php:183
+#: application/bookmark/BookmarkFileService.php:205
+#: application/bookmark/BookmarkFileService.php:227
+#: application/bookmark/BookmarkFileService.php:241
+msgid "You're not authorized to alter the datastore"
+msgstr "Vous n'êtes pas autorisé à modifier les données"
 
-#: application/bookmark/LinkDB.php:131
-msgid "Internal Error: A link should always have an id and URL."
-msgstr "Erreur interne : un lien devrait toujours avoir un ID et une URL."
+#: application/bookmark/BookmarkFileService.php:208
+msgid "This bookmarks already exists"
+msgstr "Ce marque-page existe déjà"
 
-#: application/bookmark/LinkDB.php:134
-msgid "You must specify an integer as a key."
-msgstr "Vous devez utiliser un entier comme clé."
+#: application/bookmark/BookmarkInitializer.php:39
+msgid "(private bookmark with thumbnail demo)"
+msgstr "(marque page privé avec une miniature)"
 
-#: application/bookmark/LinkDB.php:137
-msgid "Array offset and link ID must be equal."
-msgstr "La clé du tableau et l'ID du lien doivent être identiques."
+#: application/bookmark/BookmarkInitializer.php:42
+msgid ""
+"Shaarli will automatically pick up the thumbnail for links to a variety of "
+"websites.\n"
+"\n"
+"Explore your new Shaarli instance by trying out controls and menus.\n"
+"Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the "
+"documentation](https://shaarli.readthedocs.io/en/master/) to learn more "
+"about Shaarli.\n"
+"\n"
+"Now you can edit or delete the default shaares.\n"
+msgstr ""
+"Shaarli récupérera automatiquement la miniature associée au liens pour de "
+"nombreux sites web.\n"
+"\n"
+"Explorez votre nouvelle instance de Shaarli en essayant les différents "
+"contrôles et menus.\n"
+"Visitez le projet sur [Github](https://github.com/shaarli/Shaarli) ou [la "
+"documentation](https://shaarli.readthedocs.io/en/master/) pour en apprendre "
+"plus sur Shaarli.\n"
+"\n"
+"Maintenant, vous pouvez modifier ou supprimer les shaares créés par défaut.\n"
+
+#: application/bookmark/BookmarkInitializer.php:55
+msgid "Note: Shaare descriptions"
+msgstr "Note : Description des Shaares"
 
-#: application/bookmark/LinkDB.php:243
+#: application/bookmark/BookmarkInitializer.php:57
+msgid ""
+"Adding a shaare without entering a URL creates a text-only \"note\" post "
+"such as this one.\n"
+"This note is private, so you are the only one able to see it while logged "
+"in.\n"
+"\n"
+"You can use this to keep notes, post articles, code snippets, and much "
+"more.\n"
+"\n"
+"The Markdown formatting setting allows you to format your notes and bookmark "
+"description:\n"
+"\n"
+"### Title headings\n"
+"\n"
+"#### Multiple headings levels\n"
+"  * bullet lists\n"
+"  * _italic_ text\n"
+"  * **bold** text\n"
+"  * ~~strike through~~ text\n"
+"  * `code` blocks\n"
+"  * images\n"
+"  * [links](https://en.wikipedia.org/wiki/Markdown)\n"
+"\n"
+"Markdown also supports tables:\n"
+"\n"
+"| Name    | Type      | Color  | Qty   |\n"
+"| ------- | --------- | ------ | ----- |\n"
+"| Orange  | Fruit     | Orange | 126   |\n"
+"| Apple   | Fruit     | Any    | 62    |\n"
+"| Lemon   | Fruit     | Yellow | 30    |\n"
+"| Carrot  | Vegetable | Red    | 14    |\n"
+msgstr ""
+"Ajouter un shaare sans préciser d'URL créé une « note » textuelle, telle que "
+"celle-ci.\n"
+"Cette note est privée, donc vous êtes seul à pouvoir la voir lorsque vous "
+"êtes connecté.\n"
+"\n"
+"Vous pouvez utiliser cette fonctionnalité pour prendre des notes, publier "
+"des articles, des extraits de code, et bien plus.\n"
+"\n"
+"L'option du formatage par Markdown vous permet de formater vos description "
+"de notes et marque-pages :\n"
+"\n"
+"### Titre d'en-tête\n"
+"\n"
+"#### Sur plusieurs niveaux\n"
+"  * liste à puce\n"
+"  * texte en _italique_\n"
+"  * texte en **gras**\n"
+"  * texte ~~barré~~\n"
+"  * blocs de `code`\n"
+"  * images\n"
+"  * [liens](https://en.wikipedia.org/wiki/Markdown)\n"
+"\n"
+"Markdown supporte aussi les tableaux :\n"
+"\n"
+"| Nom    | Type      | Couleur  | Qte   |\n"
+"| ------- | --------- | ------ | ----- |\n"
+"| Orange  | Fruit     | Orange | 126   |\n"
+"| Pomme   | Fruit     | Multiple    | 62    |\n"
+"| Citron   | Fruit     | Jaune | 30    |\n"
+"| Carotte  | Légume | Orange    | 14    |\n"
+
+#: application/bookmark/BookmarkInitializer.php:91
+#: application/legacy/LegacyLinkDB.php:246
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
@@ -131,37 +198,56 @@ msgstr ""
 "Le gestionnaire de marque-pages personnel, minimaliste, et sans base de "
 "données"
 
-#: application/bookmark/LinkDB.php:246
+#: application/bookmark/BookmarkInitializer.php:94
 msgid ""
-"Welcome to Shaarli! This is your first public bookmark. To edit or delete "
-"me, you must first login.\n"
+"Welcome to Shaarli!\n"
 "\n"
-"To learn how to use Shaarli, consult the link \"Documentation\" at the "
-"bottom of this page.\n"
+"Shaarli allows you to bookmark your favorite pages, and share them with "
+"others or store them privately.\n"
+"You can add a description to your bookmarks, such as this one, and tag "
+"them.\n"
 "\n"
-"You use the community supported version of the original Shaarli project, by "
-"Sebastien Sauvage."
-msgstr ""
-"Bienvenue sur Shaarli ! Ceci est votre premier marque-page public. Pour me "
-"modifier ou me supprimer, vous devez d'abord vous connecter.\n"
+"Create a new shaare by clicking the `+Shaare` button, or using any of the "
+"recommended tools (browser extension, mobile app, bookmarklet, REST API, "
+"etc.).\n"
 "\n"
-"Pour apprendre à utiliser Shaarli, consultez le lien « Documentation » en "
-"bas de page.\n"
+"You can easily retrieve your links, even with thousands of them, using the "
+"internal search engine, or search through tags (e.g. this Shaare is tagged "
+"with `shaarli` and `help`).\n"
+"Hashtags such as #shaarli #help are also supported.\n"
+"You can also filter the available [RSS feed](/feed/atom) and picture wall by "
+"tag or plaintext search.\n"
 "\n"
-"Vous utilisez la version supportée par la communauté du projet original "
-"Shaarli de Sébastien Sauvage."
-
-#: application/bookmark/LinkDB.php:263
-msgid "My secret stuff... - Pastebin.com"
-msgstr "Mes trucs secrets... - Pastebin.com"
-
-#: application/bookmark/LinkDB.php:265
-msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
+"We hope that you will enjoy using Shaarli, maintained with ❤️ by the "
+"community!\n"
+"Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if "
+"you have a suggestion or encounter an issue.\n"
 msgstr ""
-"Pssst ! Je suis un lien privé que VOUS êtes le seul à voir. Vous pouvez me "
-"supprimer aussi."
+"Bienvenue sur Shaarli !\n"
+"\n"
+"Shaarli vous permet de sauvegarder des marque-pages de vos pages favorites, "
+"et de les partager avec d'autres, ou de les enregistrer en privé.\n"
+"Vous pouvez ajouter une description à vos marque-pages, comme celle-ci, et y "
+"ajouter des tags.\n"
+"\n"
+"Créez un nouveau shaare en cliquant sur le bouton `+Shaare`, ou en utilisant "
+"l'un des outils recommandés (extension de navigateur, application mobile, "
+"bookmarklet, REST API, etc.).\n"
+"\n"
+"Vous pouvez facilement retrouver vos liens, même parmi des milliers, en "
+"utilisant le moteur de recherche interne, ou en filtrant par tags (par "
+"exemple ce Shaare est taggé avec `shaarli` et `help`).\n"
+"Les hashtags comme #shaarli #help sont aussi supportés.\n"
+"Vous pouvez aussi filtrer les [flux RSS](/feed/atom) et [mur d'images]() par "
+"tag ou par texte brut.\n"
+"\n"
+"Nous espérons que vous apprécierez utiliser Shaarli, maintenu avec ❤️ par la "
+"communauté !\n"
+"N'hésitez pas à ouvrir [un ticket (en)](https://github.com/shaarli/Shaarli/"
+"issues) si vous avez une suggestion ou si vous rencontrez un problème.\n"
+" \n"
 
-#: application/bookmark/exception/LinkNotFoundException.php:13
+#: application/bookmark/exception/BookmarkNotFoundException.php:13
 msgid "The link you are trying to reach does not exist or has been deleted."
 msgstr "Le lien que vous essayez de consulter n'existe pas ou a été supprimé."
 
@@ -173,8 +259,8 @@ msgstr ""
 "Shaarli n'a pas pu créer le fichier de configuration. Merci de vérifier que "
 "Shaarli a les droits d'écriture dans le dossier dans lequel il est installé."
 
-#: application/config/ConfigManager.php:135
-#: application/config/ConfigManager.php:162
+#: application/config/ConfigManager.php:136
+#: application/config/ConfigManager.php:163
 msgid "Invalid setting key parameter. String expected, got: "
 msgstr "Clé de paramétrage invalide. Chaîne de caractères obtenue, attendu : "
 
@@ -196,268 +282,491 @@ msgstr "Vous n'êtes pas autorisé à modifier la configuration."
 msgid "Error accessing"
 msgstr "Une erreur s'est produite en accédant à"
 
-#: application/feed/Cache.php:16
-#, php-format
-msgid "Cannot purge %s: no directory"
-msgstr "Impossible de purger %s : le répertoire n'existe pas"
-
-#: application/feed/FeedBuilder.php:155
+#: application/feed/FeedBuilder.php:179
 msgid "Direct link"
 msgstr "Liens directs"
 
-#: application/feed/FeedBuilder.php:157
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177
+#: application/feed/FeedBuilder.php:181
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
 msgid "Permalink"
 msgstr "Permalien"
 
-#: application/netscape/NetscapeBookmarkUtils.php:42
-msgid "Invalid export selection:"
-msgstr "Sélection d'export invalide :"
+#: application/front/controller/admin/ConfigureController.php:54
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+msgid "Configure"
+msgstr "Configurer"
 
-#: application/netscape/NetscapeBookmarkUtils.php:87
-#, php-format
-msgid "File %s (%d bytes) "
-msgstr "Le fichier %s (%d octets) "
+#: application/front/controller/admin/ConfigureController.php:102
+#: application/legacy/LegacyUpdater.php:537
+msgid "You have enabled or changed thumbnails mode."
+msgstr "Vous avez activé ou changé le mode de miniatures."
 
-#: application/netscape/NetscapeBookmarkUtils.php:89
-msgid "has an unknown file format. Nothing was imported."
-msgstr "a un format inconnu. Rien n'a été importé."
+#: application/front/controller/admin/ConfigureController.php:103
+#: application/front/controller/admin/ServerController.php:68
+#: application/legacy/LegacyUpdater.php:538
+msgid "Please synchronize them."
+msgstr "Merci de les synchroniser."
 
-#: application/netscape/NetscapeBookmarkUtils.php:93
+#: application/front/controller/admin/ConfigureController.php:113
+#: application/front/controller/visitor/InstallController.php:146
+msgid "Error while writing config file after configuration update."
+msgstr ""
+"Une erreur s'est produite lors de la sauvegarde du fichier de configuration."
+
+#: application/front/controller/admin/ConfigureController.php:122
+msgid "Configuration was saved."
+msgstr "La configuration a été sauvegardée."
+
+#: application/front/controller/admin/ExportController.php:26
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
+msgid "Export"
+msgstr "Exporter"
+
+#: application/front/controller/admin/ExportController.php:42
+msgid "Please select an export mode."
+msgstr "Merci de choisir un mode d'export."
+
+#: application/front/controller/admin/ImportController.php:41
+#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+msgid "Import"
+msgstr "Importer"
+
+#: application/front/controller/admin/ImportController.php:55
+msgid "No import file provided."
+msgstr "Aucun fichier à importer n'a été fourni."
+
+#: application/front/controller/admin/ImportController.php:66
 #, php-format
 msgid ""
-"was successfully processed in %d seconds: %d links imported, %d links "
-"overwritten, %d links skipped."
+"The file you are trying to upload is probably bigger than what this "
+"webserver can accept (%s). Please upload in smaller chunks."
 msgstr ""
-"a été importé avec succès en %d secondes : %d liens importés, %d liens "
-"écrasés, %d liens ignorés."
+"Le fichier que vous essayer d'envoyer est probablement plus lourd que ce que "
+"le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
+"légères."
 
-#: application/plugin/exception/PluginFileNotFoundException.php:21
-#, php-format
-msgid "Plugin \"%s\" files not found."
-msgstr "Les fichiers de l'extension \"%s\" sont introuvables."
+#: application/front/controller/admin/ManageShaareController.php:64
+#: application/front/controller/admin/ManageShaareController.php:95
+#: application/front/controller/admin/ManageShaareController.php:193
+#: application/front/controller/admin/ManageShaareController.php:262
+#: application/front/controller/admin/ManageShaareController.php:302
+#: application/front/controller/admin/ManageShaareController.php:181
+#: application/front/controller/admin/ManageShaareController.php:239
+#: application/front/controller/admin/ManageShaareController.php:247
+#: application/front/controller/admin/ManageShaareController.php:378
+#: application/front/controller/admin/ManageShaareController.php:381
+#: application/front/controller/admin/ManageTagController.php:29
+#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+msgid "Manage tags"
+msgstr "Gérer les tags"
 
-#: application/render/PageBuilder.php:209
-msgid "The page you are trying to reach does not exist or has been deleted."
-msgstr "La page que vous essayez de consulter n'existe pas ou a été supprimée."
+#: application/front/controller/admin/ManageTagController.php:48
+msgid "Invalid tags provided."
+msgstr "Les tags fournis ne sont pas valides."
 
-#: application/render/PageBuilder.php:211
-msgid "404 Not Found"
-msgstr "404 Introuvable"
+#: application/front/controller/admin/ManageTagController.php:72
+#, php-format
+msgid "The tag was removed from %d bookmark."
+msgid_plural "The tag was removed from %d bookmarks."
+msgstr[0] "Le tag a été supprimé du %d lien."
+msgstr[1] "Le tag a été supprimé de %d liens."
 
-#: application/updater/Updater.php:99
-#, fuzzy
-#| msgid "Couldn't retrieve Updater class methods."
-msgid "Couldn't retrieve updater class methods."
-msgstr "Impossible de récupérer les méthodes de la classe Updater."
+#: application/front/controller/admin/ManageTagController.php:77
+#, php-format
+msgid "The tag was renamed in %d bookmark."
+msgid_plural "The tag was renamed in %d bookmarks."
+msgstr[0] "Le tag a été renommé dans %d lien."
+msgstr[1] "Le tag a été renommé dans %d liens."
 
-#: application/updater/Updater.php:526 index.php:1034
-msgid ""
-"You have enabled or changed thumbnails mode. <a href=\"?do=thumbs_update"
-"\">Please synchronize them</a>."
+#: application/front/controller/admin/PasswordController.php:28
+#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+msgid "Change password"
+msgstr "Modifier le mot de passe"
+
+#: application/front/controller/admin/PasswordController.php:55
+msgid "You must provide the current and new password to change it."
 msgstr ""
-"Vous avez activé ou changé le mode de miniatures. <a href=\"?do=thumbs_update"
-"\">Merci de les synchroniser</a>."
+"Vous devez fournir les mots de passe actuel et nouveau pour pouvoir le "
+"modifier."
+
+#: application/front/controller/admin/PasswordController.php:71
+msgid "The old password is not correct."
+msgstr "L'ancien mot de passe est incorrect."
+
+#: application/front/controller/admin/PasswordController.php:97
+msgid "Your password has been changed"
+msgstr "Votre mot de passe a été modifié"
+
+#: application/front/controller/admin/PluginsController.php:45
+msgid "Plugin Administration"
+msgstr "Administration des plugins"
+
+#: application/front/controller/admin/PluginsController.php:76
+msgid "Setting successfully saved."
+msgstr "Les paramètres ont été sauvegardés avec succès."
 
-#: application/updater/UpdaterUtils.php:32
-msgid "Updates file path is not set, can't write updates."
+#: application/front/controller/admin/PluginsController.php:79
+msgid "Error while saving plugin configuration: "
 msgstr ""
-"Le chemin vers le fichier de mise à jour n'est pas défini, impossible "
-"d'écrire les mises à jour."
+"Une erreur s'est produite lors de la sauvegarde de la configuration des "
+"plugins : "
 
-#: application/updater/UpdaterUtils.php:37
-msgid "Unable to write updates in "
-msgstr "Impossible d'écrire les mises à jour dans "
+#: application/front/controller/admin/ServerController.php:50
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Server administration"
+msgstr "Administration serveur"
 
-#: application/updater/exception/UpdaterException.php:51
-msgid "An error occurred while running the update "
-msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour "
+#: application/front/controller/admin/ServerController.php:67
+msgid "Thumbnails cache has been cleared."
+msgstr "Le cache des miniatures a été vidé."
 
-#: index.php:145
-msgid "Shared links on "
-msgstr "Liens partagés sur "
+#: application/front/controller/admin/ServerController.php:76
+msgid "Shaarli's cache folder has been cleared!"
+msgstr "Le dossier de cache de Shaarli a été vidé !"
 
-#: index.php:167
-msgid "Insufficient permissions:"
-msgstr "Permissions insuffisantes :"
+#, php-format
+msgid "Bookmark with identifier %s could not be found."
+msgstr "Le lien avec l'identifiant %s n'a pas pu être trouvé."
 
-#: index.php:203
-msgid "I said: NO. You are banned for the moment. Go away."
-msgstr "NON. Vous êtes banni pour le moment. Revenez plus tard."
+#: application/front/controller/admin/ShaareManageController.php:101
+msgid "Invalid visibility provided."
+msgstr "Visibilité du lien non valide."
 
-#: index.php:275
-msgid "Wrong login/password."
-msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
+#: application/front/controller/admin/ShaarePublishController.php:154
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+msgid "Edit"
+msgstr "Modifier"
 
-#: index.php:398 index.php:404
-msgid "Today"
-msgstr "Aujourd'hui"
+#: application/front/controller/admin/ShaarePublishController.php:157
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
+msgid "Shaare"
+msgstr "Shaare"
 
-#: index.php:400
-msgid "Yesterday"
-msgstr "Hier"
+#: application/front/controller/admin/ShaarePublishController.php:184
+msgid "Note: "
+msgstr "Note : "
+
+#: application/front/controller/admin/ThumbnailsController.php:37
+#: tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Thumbnails update"
+msgstr "Mise à jour des miniatures"
 
-#: index.php:484 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:46
+#: application/front/controller/admin/ToolsController.php:31
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:33
+msgid "Tools"
+msgstr "Outils"
+
+#: application/front/controller/visitor/BookmarkListController.php:116
+msgid "Search: "
+msgstr "Recherche : "
+
+#: application/front/controller/visitor/DailyController.php:200
+msgid "day"
+msgstr "jour"
+
+#: application/front/controller/visitor/DailyController.php:200
+#: application/front/controller/visitor/DailyController.php:203
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48
 msgid "Daily"
 msgstr "Quotidien"
 
-#: index.php:593 tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:75
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:99
+#: application/front/controller/visitor/DailyController.php:201
+msgid "week"
+msgstr "semaine"
+
+#: application/front/controller/visitor/DailyController.php:201
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Weekly"
+msgstr "Hebdomadaire"
+
+#: application/front/controller/visitor/DailyController.php:202
+msgid "month"
+msgstr "mois"
+
+#: application/front/controller/visitor/DailyController.php:202
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "Monthly"
+msgstr "Mensuel"
+
+#: application/front/controller/visitor/ErrorController.php:33
+msgid "An unexpected error occurred."
+msgstr "Une erreur inattendue s'est produite."
+
+#: application/front/controller/visitor/ErrorNotFoundController.php:25
+msgid "Requested page could not be found."
+msgstr "La page demandée n'a pas pu être trouvée."
+
+#: application/front/controller/visitor/InstallController.php:64
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
+msgid "Install Shaarli"
+msgstr "Installation de Shaarli"
+
+#: application/front/controller/visitor/InstallController.php:83
+#, php-format
+msgid ""
+"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
+"variable \"session.save_path\" is set correctly in your PHP config, and that "
+"you have write access to it.<br>It currently points to %s.<br>On some "
+"browsers, accessing your server via a hostname like 'localhost' or any "
+"custom hostname without a dot causes cookie storage to fail. We recommend "
+"accessing your server via it's IP address or Fully Qualified Domain Name.<br>"
+msgstr ""
+"<pre>Les sesssions ne semblent pas fonctionner sur ce serveur.<br>Assurez "
+"vous que la variable « session.save_path » est correctement définie dans "
+"votre fichier de configuration PHP, et que vous avez les droits d'écriture "
+"dessus.<br>Ce paramètre pointe actuellement sur %s.<br>Sur certains "
+"navigateurs, accéder à votre serveur depuis un nom d'hôte comme « localhost "
+"» ou autre nom personnalisé sans point '.' entraine l'échec de la sauvegarde "
+"des cookies. Nous vous recommandons d'accéder à votre serveur depuis son "
+"adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>"
+
+#: application/front/controller/visitor/InstallController.php:154
+msgid ""
+"Shaarli is now configured. Please login and start shaaring your bookmarks!"
+msgstr ""
+"Shaarli est maintenant configuré. Vous pouvez vous connecter et commencez à "
+"shaare vos liens !"
+
+#: application/front/controller/visitor/InstallController.php:168
+msgid "Insufficient permissions:"
+msgstr "Permissions insuffisantes :"
+
+#: application/front/controller/visitor/LoginController.php:46
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:77
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:101
 msgid "Login"
 msgstr "Connexion"
 
-#: index.php:608 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:41
+#: application/front/controller/visitor/LoginController.php:77
+msgid "Wrong login/password."
+msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
+
+#: application/front/controller/visitor/PictureWallController.php:29
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:43
 msgid "Picture wall"
 msgstr "Mur d'images"
 
-#: index.php:683 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag cloud"
-msgstr "Nuage de tags"
+#: application/front/controller/visitor/TagCloudController.php:88
+msgid "Tag "
+msgstr "Tag "
 
-#: index.php:715 tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag list"
-msgstr "Liste des tags"
+#: application/front/exceptions/AlreadyInstalledException.php:11
+msgid "Shaarli has already been installed. Login to edit the configuration."
+msgstr ""
+"Shaarli est déjà installé. Connectez-vous pour modifier la configuration."
 
-#: index.php:944 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:31
-msgid "Tools"
-msgstr "Outils"
+#: application/front/exceptions/LoginBannedException.php:11
+msgid ""
+"You have been banned after too many failed login attempts. Try again later."
+msgstr ""
+"Vous avez été banni après trop d'échecs d'authentification. Merci de "
+"réessayer plus tard."
 
-#: index.php:952
+#: application/front/exceptions/OpenShaarliPasswordException.php:16
 msgid "You are not supposed to change a password on an Open Shaarli."
 msgstr ""
 "Vous n'êtes pas censé modifier le mot de passe d'un Shaarli en mode ouvert."
 
-#: index.php:957 index.php:1007 index.php:1094 index.php:1124 index.php:1234
-#: index.php:1281
+#: application/front/exceptions/ThumbnailsDisabledException.php:11
+msgid "Picture wall unavailable (thumbnails are disabled)."
+msgstr ""
+"Le mur d'images n'est pas disponible (les miniatures sont désactivées)."
+
+#: application/front/exceptions/WrongTokenException.php:16
 msgid "Wrong token."
 msgstr "Jeton invalide."
 
-#: index.php:966
-msgid "The old password is not correct."
-msgstr "L'ancien mot de passe est incorrect."
+#: application/helper/ApplicationUtils.php:162
+#, php-format
+msgid ""
+"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
+"cannot run. Your PHP version has known security vulnerabilities and should "
+"be updated as soon as possible."
+msgstr ""
+"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
+"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
+"connues et devrait être mise à jour au plus tôt."
 
-#: index.php:993
-msgid "Your password has been changed"
-msgstr "Votre mot de passe a été modifié"
+#: application/helper/ApplicationUtils.php:195
+#: application/helper/ApplicationUtils.php:215
+msgid "directory is not readable"
+msgstr "le répertoire n'est pas accessible en lecture"
 
-#: index.php:997
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Change password"
-msgstr "Modifier le mot de passe"
+#: application/helper/ApplicationUtils.php:218
+msgid "directory is not writable"
+msgstr "le répertoire n'est pas accessible en écriture"
 
-#: index.php:1054
-msgid "Configuration was saved."
-msgstr "La configuration a été sauvegardée."
+#: application/helper/ApplicationUtils.php:240
+msgid "file is not readable"
+msgstr "le fichier n'est pas accessible en lecture"
 
-#: index.php:1078 tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "Configure"
-msgstr "Configurer"
+#: application/helper/ApplicationUtils.php:243
+msgid "file is not writable"
+msgstr "le fichier n'est pas accessible en écriture"
 
-#: index.php:1088 tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-msgid "Manage tags"
-msgstr "Gérer les tags"
+#: application/helper/ApplicationUtils.php:277
+msgid "Configuration parsing"
+msgstr "Chargement de la configuration"
 
-#: index.php:1107
-#, php-format
-msgid "The tag was removed from %d link."
-msgid_plural "The tag was removed from %d links."
-msgstr[0] "Le tag a été supprimé de %d lien."
-msgstr[1] "Le tag a été supprimé de %d liens."
+#: application/helper/ApplicationUtils.php:278
+msgid "Slim Framework (routing, etc.)"
+msgstr "Slim Framwork (routage, etc.)"
 
-#: index.php:1108
-#, php-format
-msgid "The tag was renamed in %d link."
-msgid_plural "The tag was renamed in %d links."
-msgstr[0] "Le tag a été renommé dans %d lien."
-msgstr[1] "Le tag a été renommé dans %d liens."
+#: application/helper/ApplicationUtils.php:279
+msgid "Multibyte (Unicode) string support"
+msgstr "Support des chaînes de caractère multibytes (Unicode)"
 
-#: index.php:1115 tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-msgid "Shaare a new link"
-msgstr "Partager un nouveau lien"
+#: application/helper/ApplicationUtils.php:280
+msgid "Required to use thumbnails"
+msgstr "Obligatoire pour utiliser les miniatures"
 
-#: index.php:1344 tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-msgid "Edit"
-msgstr "Modifier"
+#: application/helper/ApplicationUtils.php:281
+msgid "Localized text sorting (e.g. e->è->f)"
+msgstr "Tri des textes traduits (ex : e->è->f)"
+
+#: application/helper/ApplicationUtils.php:282
+msgid "Better retrieval of bookmark metadata and thumbnail"
+msgstr "Meilleure récupération des meta-données des marque-pages et minatures"
+
+#: application/helper/ApplicationUtils.php:283
+msgid "Use the translation system in gettext mode"
+msgstr "Utiliser le système de traduction en mode gettext"
+
+#: application/helper/ApplicationUtils.php:284
+msgid "Login using LDAP server"
+msgstr "Authentification via un serveur LDAP"
+
+#: application/helper/DailyPageHelper.php:172
+msgid "Week"
+msgstr "Semaine"
+
+#: application/helper/DailyPageHelper.php:176
+msgid "Today"
+msgstr "Aujourd'hui"
+
+#: application/helper/DailyPageHelper.php:178
+msgid "Yesterday"
+msgstr "Hier"
+
+#: application/helper/FileUtils.php:100
+msgid "Provided path is not a directory."
+msgstr "Le chemin fourni n'est pas un dossier."
+
+#: application/helper/FileUtils.php:104
+msgid "Trying to delete a folder outside of Shaarli path."
+msgstr "Tentative de supprimer un dossier en dehors du chemin de Shaarli."
+
+#: application/legacy/LegacyLinkDB.php:131
+msgid "You are not authorized to add a link."
+msgstr "Vous n'êtes pas autorisé à ajouter un lien."
+
+#: application/legacy/LegacyLinkDB.php:134
+msgid "Internal Error: A link should always have an id and URL."
+msgstr "Erreur interne : un lien devrait toujours avoir un ID et une URL."
+
+#: application/legacy/LegacyLinkDB.php:137
+msgid "You must specify an integer as a key."
+msgstr "Vous devez utiliser un entier comme clé."
+
+#: application/legacy/LegacyLinkDB.php:140
+msgid "Array offset and link ID must be equal."
+msgstr "La clé du tableau et l'ID du lien doivent être identiques."
+
+#: application/legacy/LegacyLinkDB.php:249
+msgid ""
+"Welcome to Shaarli! This is your first public bookmark. To edit or delete "
+"me, you must first login.\n"
+"\n"
+"To learn how to use Shaarli, consult the link \"Documentation\" at the "
+"bottom of this page.\n"
+"\n"
+"You use the community supported version of the original Shaarli project, by "
+"Sebastien Sauvage."
+msgstr ""
+"Bienvenue sur Shaarli ! Ceci est votre premier marque-page public. Pour me "
+"modifier ou me supprimer, vous devez d'abord vous connecter.\n"
+"\n"
+"Pour apprendre à utiliser Shaarli, consultez le lien « Documentation » en "
+"bas de page.\n"
+"\n"
+"Vous utilisez la version supportée par la communauté du projet original "
+"Shaarli de Sébastien Sauvage."
+
+#: application/legacy/LegacyLinkDB.php:266
+msgid "My secret stuff... - Pastebin.com"
+msgstr "Mes trucs secrets... - Pastebin.com"
+
+#: application/legacy/LegacyLinkDB.php:268
+msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
+msgstr ""
+"Pssst ! Je suis un lien privé que VOUS êtes le seul à voir. Vous pouvez me "
+"supprimer aussi."
 
-#: index.php:1344 index.php:1416
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:26
-msgid "Shaare"
-msgstr "Shaare"
+#: application/legacy/LegacyUpdater.php:104
+msgid "Couldn't retrieve updater class methods."
+msgstr "Impossible de récupérer les méthodes de la classe Updater."
 
-#: index.php:1385
-msgid "Note: "
-msgstr "Note : "
+#: application/legacy/LegacyUpdater.php:538
+msgid "<a href=\"./admin/thumbnails\">"
+msgstr "<a href=\"./admin/thumbnails\">"
 
-#: index.php:1424
-msgid "Invalid link ID provided"
-msgstr "ID du lien non valide"
+#: application/netscape/NetscapeBookmarkUtils.php:63
+msgid "Invalid export selection:"
+msgstr "Sélection d'export invalide :"
 
-#: index.php:1444 tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
-msgid "Export"
-msgstr "Exporter"
+#: application/netscape/NetscapeBookmarkUtils.php:215
+#, php-format
+msgid "File %s (%d bytes) "
+msgstr "Le fichier %s (%d octets) "
 
-#: index.php:1506 tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
-msgid "Import"
-msgstr "Importer"
+#: application/netscape/NetscapeBookmarkUtils.php:217
+msgid "has an unknown file format. Nothing was imported."
+msgstr "a un format inconnu. Rien n'a été importé."
 
-#: index.php:1516
+#: application/netscape/NetscapeBookmarkUtils.php:221
 #, php-format
 msgid ""
-"The file you are trying to upload is probably bigger than what this "
-"webserver can accept (%s). Please upload in smaller chunks."
+"was successfully processed in %d seconds: %d bookmarks imported, %d "
+"bookmarks overwritten, %d bookmarks skipped."
 msgstr ""
-"Le fichier que vous essayer d'envoyer est probablement plus lourd que ce que "
-"le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
-"légères."
-
-#: index.php:1561 tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Plugin administration"
-msgstr "Administration des plugins"
+"a été importé avec succès en %d secondes : %d liens importés, %d liens "
+"écrasés, %d liens ignorés."
 
-#: index.php:1616 tmp/thumbnails.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-msgid "Thumbnails update"
-msgstr "Mise à jour des miniatures"
+#: application/plugin/PluginManager.php:124
+msgid " [plugin incompatibility]: "
+msgstr " [incompatibilité de l'extension] : "
 
-#: index.php:1782
-msgid "Search: "
-msgstr "Recherche : "
+#: application/plugin/exception/PluginFileNotFoundException.php:21
+#, php-format
+msgid "Plugin \"%s\" files not found."
+msgstr "Les fichiers de l'extension \"%s\" sont introuvables."
 
-#: index.php:1825
+#: application/render/PageCacheManager.php:32
 #, php-format
-msgid ""
-"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
-"variable \"session.save_path\" is set correctly in your PHP config, and that "
-"you have write access to it.<br>It currently points to %s.<br>On some "
-"browsers, accessing your server via a hostname like 'localhost' or any "
-"custom hostname without a dot causes cookie storage to fail. We recommend "
-"accessing your server via it's IP address or Fully Qualified Domain Name.<br>"
-msgstr ""
-"<pre>Les sesssions ne semblent pas fonctionner sur ce serveur.<br>Assurez "
-"vous que la variable « session.save_path » est correctement définie dans "
-"votre fichier de configuration PHP, et que vous avez les droits d'écriture "
-"dessus.<br>Ce paramètre pointe actuellement sur %s.<br>Sur certains "
-"navigateurs, accéder à votre serveur depuis un nom d'hôte comme « localhost "
-"» ou autre nom personnalisé sans point '.' entraine l'échec de la sauvegarde "
-"des cookies. Nous vous recommandons d'accéder à votre serveur depuis son "
-"adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>"
+msgid "Cannot purge %s: no directory"
+msgstr "Impossible de purger %s : le répertoire n'existe pas"
+
+#: application/updater/exception/UpdaterException.php:51
+msgid "An error occurred while running the update "
+msgstr "Une erreur s'est produite lors de l'exécution de la mise à jour "
 
-#: index.php:1835
-msgid "Click to try again."
-msgstr "Cliquer ici pour réessayer."
+#: index.php:80
+msgid "Shared bookmarks on "
+msgstr "Liens partagés sur "
 
 #: plugins/addlink_toolbar/addlink_toolbar.php:31
 msgid "URI"
@@ -472,15 +781,15 @@ msgstr "Shaare"
 msgid "Adds the addlink input on the linklist page."
 msgstr "Ajoute le formulaire d'ajout de liens sur la page principale."
 
-#: plugins/archiveorg/archiveorg.php:25
+#: plugins/archiveorg/archiveorg.php:28
 msgid "View on archive.org"
 msgstr "Voir sur archive.org"
 
-#: plugins/archiveorg/archiveorg.php:38
+#: plugins/archiveorg/archiveorg.php:41
 msgid "For each link, add an Archive.org icon."
 msgstr "Pour chaque lien, ajoute une icône pour Archive.org."
 
-#: plugins/default_colors/default_colors.php:33
+#: plugins/default_colors/default_colors.php:38
 msgid ""
 "Default colors plugin error: This plugin is active and no custom color is "
 "configured."
@@ -488,25 +797,25 @@ msgstr ""
 "Erreur du plugin default colors : ce plugin est actif et aucune couleur "
 "n'est configurée."
 
-#: plugins/default_colors/default_colors.php:107
+#: plugins/default_colors/default_colors.php:113
 msgid "Override default theme colors. Use any CSS valid color."
 msgstr ""
 "Remplacer les couleurs du thème par défaut. Utiliser n'importe quelle "
 "couleur CSS valide."
 
-#: plugins/default_colors/default_colors.php:108
+#: plugins/default_colors/default_colors.php:114
 msgid "Main color (navbar green)"
 msgstr "Couleur principale (vert de la barre de navigation)"
 
-#: plugins/default_colors/default_colors.php:109
+#: plugins/default_colors/default_colors.php:115
 msgid "Background color (light grey)"
 msgstr "Couleur de fond (gris léger)"
 
-#: plugins/default_colors/default_colors.php:110
+#: plugins/default_colors/default_colors.php:116
 msgid "Dark main color (e.g. visited links)"
 msgstr "Couleur principale sombre (ex : les liens visités)"
 
-#: plugins/demo_plugin/demo_plugin.php:482
+#: plugins/demo_plugin/demo_plugin.php:477
 msgid ""
 "A demo plugin covering all use cases for template designers and plugin "
 "developers."
@@ -514,11 +823,11 @@ msgstr ""
 "Une extension de démonstration couvrant tous les cas d'utilisation pour les "
 "designers de thèmes et les développeurs d'extensions."
 
-#: plugins/demo_plugin/demo_plugin.php:483
+#: plugins/demo_plugin/demo_plugin.php:478
 msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
 msgstr "Ceci est un paramètre dédié au plugin de démo. Il sera suffixé."
 
-#: plugins/demo_plugin/demo_plugin.php:484
+#: plugins/demo_plugin/demo_plugin.php:479
 msgid "Other demo parameter"
 msgstr "Un autre paramètre de démo"
 
@@ -540,36 +849,6 @@ msgstr ""
 msgid "Isso server URL (without 'http://')"
 msgstr "URL du serveur Isso (sans 'http://')"
 
-#: plugins/markdown/markdown.php:163
-msgid "Description will be rendered with"
-msgstr "La description sera générée avec"
-
-#: plugins/markdown/markdown.php:164
-msgid "Markdown syntax documentation"
-msgstr "Documentation sur la syntaxe Markdown"
-
-#: plugins/markdown/markdown.php:165
-msgid "Markdown syntax"
-msgstr "la syntaxe Markdown"
-
-#: plugins/markdown/markdown.php:361
-msgid ""
-"Render shaare description with Markdown syntax.<br><strong>Warning</"
-"strong>:\n"
-"If your shaared descriptions contained HTML tags before enabling the "
-"markdown plugin,\n"
-"enabling it might break your page.\n"
-"See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a>."
-msgstr ""
-"Utilise la syntaxe Markdown pour la description des liens."
-"<br><strong>Attention</strong> :\n"
-"Si vous aviez des descriptions contenant du HTML avant d'activer cette "
-"extension,\n"
-"l'activer pourrait déformer vos pages.\n"
-"Voir le <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a>."
-
 #: plugins/piwik/piwik.php:23
 msgid ""
 "Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
@@ -626,7 +905,7 @@ msgstr "Mauvaise réponse du hub %s"
 msgid "Enable PubSubHubbub feed publishing."
 msgstr "Active la publication de flux vers PubSubHubbub."
 
-#: plugins/qrcode/qrcode.php:72 plugins/wallabag/wallabag.php:68
+#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70
 msgid "For each link, add a QRCode icon."
 msgstr "Pour chaque lien, ajouter une icône de QRCode."
 
@@ -642,24 +921,14 @@ msgstr ""
 msgid "Save to wallabag"
 msgstr "Sauvegarder dans Wallabag"
 
-#: plugins/wallabag/wallabag.php:69
+#: plugins/wallabag/wallabag.php:71
 msgid "Wallabag API URL"
 msgstr "URL de l'API Wallabag"
 
-#: plugins/wallabag/wallabag.php:70
+#: plugins/wallabag/wallabag.php:72
 msgid "Wallabag API version (1 or 2)"
 msgstr "Version de l'API Wallabag (1 ou 2)"
 
-#: tests/LanguagesTest.php:214 tests/LanguagesTest.php:227
-#: tests/languages/fr/LanguagesFrTest.php:159
-#: tests/languages/fr/LanguagesFrTest.php:172
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:85
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:85
-msgid "Search"
-msgid_plural "Search"
-msgstr[0] "Rechercher"
-msgstr[1] "Rechercher"
-
 #: tmp/404.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
 msgid "Sorry, nothing to see here."
 msgstr "Désolé, il y a rien à voir ici."
@@ -668,6 +937,48 @@ msgstr "Désolé, il y a rien à voir ici."
 msgid "URL or leave empty to post a note"
 msgstr "URL ou laisser vide pour créer une note"
 
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+msgid "BULK CREATION"
+msgstr "CRÉATION DE MASSE"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
+msgid "Metadata asynchronous retrieval is disabled."
+msgstr "La récupération asynchrone des meta-données est désactivée."
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+msgid ""
+"We recommend that you enable the setting <em>general > "
+"enable_async_metadata</em> in your configuration file to use bulk link "
+"creation."
+msgstr ""
+"Nous recommandons d'activer le paramètre <em>general > "
+"enable_async_metadata</em> dans votre fichier de configuration pour utiliser "
+"la création de masse."
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+msgid "Shaare multiple new links"
+msgstr "Partagez plusieurs nouveaux liens"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59
+msgid "Add one URL per line to create multiple bookmarks."
+msgstr "Ajouter une URL par ligne pour créer plusieurs marque-pages."
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
+msgid "Tags"
+msgstr "Tags"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
+msgid "Private"
+msgstr "Privé"
+
+#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
+msgid "Add links"
+msgstr "Ajouter des liens"
+
 #: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
 msgid "Current password"
 msgstr "Mot de passe actuel"
@@ -694,16 +1005,13 @@ msgid "Case sensitive"
 msgstr "Sensible à la casse"
 
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-msgid "Rename"
-msgstr "Renommer"
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
+msgid "Rename tag"
+msgstr "Renommer le tag"
 
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:145
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:145
-msgid "Delete"
-msgstr "Supprimer"
+msgid "Delete tag"
+msgstr "Supprimer le tag"
 
 #: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
 msgid "You can also edit tags in the"
@@ -713,33 +1021,6 @@ msgstr "Vous pouvez aussi modifier les tags dans la"
 msgid "tag list"
 msgstr "liste des tags"
 
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:143
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:312
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-msgid "All"
-msgstr "Tous"
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:147
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:316
-msgid "Only common media hosts"
-msgstr "Seulement les hébergeurs de média connus"
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:151
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:320
-msgid "None"
-msgstr "Aucune"
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:158
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:297
-msgid "You need to enable the extension <code>php-gd</code> to use thumbnails."
-msgstr ""
-"Vous devez activer l'extension <code>php-gd</code> pour utiliser les "
-"miniatures."
-
-#: tmp/configure.90100d2eaf5d3705e14b9b4f78ecddc9.rtpl.php:162
-msgid "Synchonize thumbnails"
-msgstr "Synchroniser les miniatures"
-
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "title"
 msgstr "titre"
@@ -756,155 +1037,186 @@ msgstr "Valeur par défaut"
 msgid "Theme"
 msgstr "Thème"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:85
+msgid "Description formatter"
+msgstr "Format des descriptions"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
 msgid "Language"
 msgstr "Langue"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:116
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
 msgid "Timezone"
 msgstr "Fuseau horaire"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "Continent"
 msgstr "Continent"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "City"
 msgstr "Ville"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:164
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:191
 msgid "Disable session cookie hijacking protection"
 msgstr "Désactiver la protection contre le détournement de cookies"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:166
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:193
 msgid "Check this if you get disconnected or if your IP address changes often"
 msgstr ""
 "Cocher cette case si vous êtes souvent déconnecté ou si votre adresse IP "
 "change souvent"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:210
 msgid "Private links by default"
 msgstr "Liens privés par défaut"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:184
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:211
 msgid "All new links are private by default"
 msgstr "Tous les nouveaux liens sont privés par défaut"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:226
 msgid "RSS direct links"
 msgstr "Liens directs dans le flux RSS"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:200
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:227
 msgid "Check this to use direct URL instead of permalink in feeds"
 msgstr ""
 "Cocher cette case pour utiliser des liens directs au lieu des permaliens "
 "dans le flux RSS"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:215
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:242
 msgid "Hide public links"
 msgstr "Cacher les liens publics"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:216
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:243
 msgid "Do not show any links if the user is not logged in"
 msgstr "N'afficher aucun lien sans être connecté"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:231
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:258
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:149
 msgid "Check updates"
 msgstr "Vérifier les mises à jour"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:232
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:259
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
 msgid "Notify me when a new release is ready"
 msgstr "Me notifier lorsqu'une nouvelle version est disponible"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:247
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274
 msgid "Automatically retrieve description for new bookmarks"
 msgstr "Récupérer automatiquement la description"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:248
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:275
 msgid "Shaarli will try to retrieve the description from meta HTML headers"
 msgstr ""
 "Shaarli essaiera de récupérer la description depuis les balises HTML meta "
 "dans les entêtes"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:263
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:290
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
 msgid "Enable REST API"
 msgstr "Activer l'API REST"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:264
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:291
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
 msgid "Allow third party software to use Shaarli such as mobile application"
 msgstr ""
 "Permet aux applications tierces d'utiliser Shaarli, par exemple les "
 "applications mobiles"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:279
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:306
 msgid "API secret"
 msgstr "Clé d'API secrète"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:293
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:320
 msgid "Enable thumbnails"
 msgstr "Activer les miniatures"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:301
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:324
+msgid "You need to enable the extension <code>php-gd</code> to use thumbnails."
+msgstr ""
+"Vous devez activer l'extension <code>php-gd</code> pour utiliser les "
+"miniatures."
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
 msgid "Synchronize thumbnails"
 msgstr "Synchroniser les miniatures"
 
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+msgid "All"
+msgstr "Tous"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
+msgid "Only common media hosts"
+msgstr "Seulement les hébergeurs de média connus"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
+msgid "None"
+msgstr "Aucune"
+
+#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
 msgid "Save"
 msgstr "Enregistrer"
 
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid "The Daily Shaarli"
-msgstr "Le Quotidien Shaarli"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
-msgid "1 RSS entry per day"
-msgstr "1 entrée RSS par jour"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
-msgid "Previous day"
-msgstr "Jour précédent"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "All links of one day in a single page."
-msgstr "Tous les liens d'un jour sur une page."
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
-msgid "Next day"
-msgstr "Jour suivant"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+msgid "1 RSS entry per :type"
+msgid_plural ""
+msgstr[0] "1 entrée RSS par :type"
+msgstr[1] ""
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+msgid "Previous :type"
+msgid_plural ""
+msgstr[0] ":type précédent"
+msgstr[1] "Jour précédent"
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
+msgid "All links of one :type in a single page."
+msgid_plural ""
+msgstr[0] "Tous les liens d'un :type sur une page."
+msgstr[1] "Tous les liens d'un jour sur une page."
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+msgid "Next :type"
+msgid_plural ""
+msgstr[0] ":type suivant"
+msgstr[1] ""
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
 msgid "Edit Shaare"
 msgstr "Modifier le Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
 msgid "New Shaare"
 msgstr "Nouveau Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "Created:"
 msgstr "Création :"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
 msgid "URL"
 msgstr "URL"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
 msgid "Title"
 msgstr "Titre"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
@@ -912,37 +1224,58 @@ msgstr "Titre"
 msgid "Description"
 msgstr "Description"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "Tags"
-msgstr "Tags"
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:80
+msgid "Description will be rendered with"
+msgstr "La description sera générée avec"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:167
-msgid "Private"
-msgstr "Privé"
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
+msgid "Markdown syntax documentation"
+msgstr "Documentation sur la syntaxe Markdown"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
+msgid "Markdown syntax"
+msgstr "la syntaxe Markdown"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+msgid "Cancel"
+msgstr "Annuler"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
 msgid "Apply Changes"
 msgstr "Appliquer les changements"
 
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
+#: tmp/editlink.batch.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+msgid "Save all"
+msgstr "Tout enregistrer"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:107
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
+msgid "Delete"
+msgstr "Supprimer"
+
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
 msgid "Export Database"
 msgstr "Exporter les données"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
 msgid "Selection"
 msgstr "Choisir"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
 msgid "Public"
 msgstr "Publics"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
 msgid "Prepend note permalinks with this Shaarli instance's URL"
 msgstr "Préfixer les liens de note avec l'URL de l'instance de Shaarli"
 
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
+#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
 msgid "Useful to import bookmarks in a web browser"
 msgstr "Utile pour importer les marques-pages dans un navigateur"
 
@@ -983,42 +1316,42 @@ msgstr "Les doublons s'appuient sur les URL"
 msgid "Add default tags"
 msgstr "Ajouter des tags par défaut"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Install Shaarli"
-msgstr "Installation de Shaarli"
-
 #: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
 msgid "It looks like it's the first time you run Shaarli. Please configure it."
 msgstr ""
 "Il semblerait que ça soit la première fois que vous lancez Shaarli. Merci de "
 "le configurer."
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:165
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:167
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:167
 msgid "Username"
 msgstr "Nom d'utilisateur"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:166
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:166
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:168
 msgid "Password"
 msgstr "Mot de passe"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:62
 msgid "Shaarli title"
 msgstr "Titre du Shaarli"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:68
 msgid "My links"
 msgstr "Mes liens"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
 msgid "Install"
 msgstr "Installer"
 
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190
+msgid "Server requirements"
+msgstr "Pré-requis serveur"
+
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
 msgid "shaare"
@@ -1034,21 +1367,31 @@ msgstr[0] "lien privé"
 msgstr[1] "liens privés"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:121
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:123
 msgid "Search text"
 msgstr "Recherche texte"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:128
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:128
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:130
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
 msgid "Filter by tag"
 msgstr "Filtrer par tag"
 
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:87
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:139
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
+msgid "Search"
+msgstr "Rechercher"
+
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
 msgid "Nothing found."
 msgstr "Aucun résultat."
@@ -1069,60 +1412,65 @@ msgid "tagged"
 msgstr "taggé"
 
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:133
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
 msgid "Remove tag"
 msgstr "Retirer le tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:142
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
 msgid "with status"
 msgstr "avec le statut"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:153
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
 msgid "without any tag"
 msgstr "sans tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:173
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
 msgid "Fold"
 msgstr "Replier"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:177
 msgid "Edited: "
 msgstr "Modifié : "
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
 msgid "permalink"
 msgstr "permalien"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:181
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
 msgid "Add tag"
 msgstr "Ajouter un tag"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:185
 msgid "Toggle sticky"
 msgstr "Changer statut épinglé"
 
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:185
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:187
 msgid "Sticky"
 msgstr "Épinglé"
 
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:7
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+msgid "Share a private link"
+msgstr "Partager un lien privé"
+
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5
 msgid "Filters"
 msgstr "Filtres"
 
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:12
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:10
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:10
 msgid "Only display private links"
 msgstr "Afficher uniquement les liens privés"
 
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:15
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:13
 msgid "Only display public links"
 msgstr "Afficher uniquement les liens publics"
 
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:20
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:18
 msgid "Filter untagged links"
 msgstr "Filtrer par liens privés"
 
@@ -1131,30 +1479,23 @@ msgstr "Filtrer par liens privés"
 msgid "Select all"
 msgstr "Tout sélectionner"
 
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:27
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:79
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:89
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:29
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:89
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
 msgid "Fold all"
 msgstr "Replier tout"
 
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:72
+#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
+#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:76
 msgid "Links per page"
 msgstr "Liens par page"
 
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid ""
-"You have been banned after too many failed login attempts. Try again later."
-msgstr ""
-"Vous avez été banni après trop d'échecs d'authentification. Merci de "
-"réessayer plus tard."
-
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:169
+#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:171
 msgid "Remember me"
 msgstr "Rester connecté"
 
@@ -1182,65 +1523,67 @@ msgstr "Déplier tout"
 
 #: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 #: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:47
-msgid "Are you sure you want to delete this link?"
-msgstr "Êtes-vous sûr de vouloir supprimer ce lien ?"
+msgid "Are you sure you want to delete this tag?"
+msgstr "Êtes-vous sûr de vouloir supprimer ce tag ?"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:90
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:65
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:90
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:11
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:11
+msgid "Menu"
+msgstr "Menu"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:38
+#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+msgid "Tag cloud"
+msgstr "Nuage de tags"
+
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:67
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:67
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:92
 msgid "RSS Feed"
 msgstr "Flux RSS"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:70
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:106
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:72
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:108
 msgid "Logout"
 msgstr "Déconnexion"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:150
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:152
 msgid "Set public"
 msgstr "Rendre public"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:155
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:157
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:157
 msgid "Set private"
 msgstr "Rendre privé"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:187
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:187
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:189
 msgid "is available"
 msgstr "est disponible"
 
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:194
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:194
+#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:196
+#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:196
 msgid "Error"
 msgstr "Erreur"
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-msgid "Picture wall unavailable (thumbnails are disabled)."
-msgstr ""
-"Le mur d'images n'est pas disponible (les miniatures sont désactivées)."
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "There is no cached thumbnail."
+msgstr "Il n'y a aucune miniature dans le cache."
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-#, fuzzy
-#| msgid ""
-#| "You don't have any cached thumbnail. Try to <a href=\"?do=thumbs_update"
-#| "\">synchronize them</a>."
-msgid ""
-"There is no cached thumbnail. Try to <a href=\"?do=thumbs_update"
-"\">synchronize them</a>."
-msgstr ""
-"Il n'y a aucune miniature en cache. Essayer de <a href=\"?do=thumbs_update"
-"\">les synchroniser</a>."
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
+msgid "Try to synchronize them."
+msgstr "Essayer de les synchroniser."
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
 msgid "Picture Wall"
 msgstr "Mur d'images"
 
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
 msgid "pics"
 msgstr "images"
 
@@ -1249,6 +1592,11 @@ msgid "You need to enable Javascript to change plugin loading order."
 msgstr ""
 "Vous devez activer Javascript pour pouvoir modifier l'ordre des extensions."
 
+#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
+msgid "Plugin administration"
+msgstr "Administration des plugins"
+
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "Enabled Plugins"
 msgstr "Extensions activées"
@@ -1304,6 +1652,100 @@ msgstr "Configuration des extensions"
 msgid "No parameter available."
 msgstr "Aucun paramètre disponible."
 
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
+msgid "General"
+msgstr "Général"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
+msgid "Index URL"
+msgstr "URL de l'index"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+msgid "Base path"
+msgstr "Chemin de base"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+msgid "Client IP"
+msgstr "IP du client"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
+msgid "Trusted reverse proxies"
+msgstr "Reverse proxies de confiance"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
+msgid "N/A"
+msgstr "N/A"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
+msgid "Visit releases page on Github"
+msgstr "Visiter la page des releases sur Github"
+
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
+msgid "Synchronize all link thumbnails"
+msgstr "Synchroniser toutes les miniatures"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:2
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:2
+msgid "Permissions"
+msgstr "Permissions"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:8
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:8
+msgid "There are permissions that need to be fixed."
+msgstr "Il y a des permissions qui doivent être corrigées."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:23
+msgid "All read/write permissions are properly set."
+msgstr "Toutes les permissions de lecture/écriture sont définies correctement."
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:32
+msgid "Running PHP"
+msgstr "Fonctionnant avec PHP"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:36
+msgid "End of life: "
+msgstr "Fin de vie : "
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:48
+msgid "Extension"
+msgstr "Extension"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:49
+msgid "Usage"
+msgstr "Utilisation"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:50
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:50
+msgid "Status"
+msgstr "Statut"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:51
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:66
+msgid "Loaded"
+msgstr "Chargé"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Required"
+msgstr "Obligatoire"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:60
+msgid "Optional"
+msgstr "Optionnel"
+
+#: tmp/server.requirements.b91ef64efc3688266305ea9b42e5017e.rtpl.php:70
+#: tmp/server.requirements.cedf684561d925457130839629000a81.rtpl.php:70
+msgid "Not loaded"
+msgstr "Non chargé"
+
 #: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 #: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
 msgid "tags"
@@ -1314,6 +1756,10 @@ msgstr "tags"
 msgid "List all links with those tags"
 msgstr "Lister tous les liens avec ces tags"
 
+#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
+msgid "Tag list"
+msgstr "Liste des tags"
+
 #: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3
 #: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3
 msgid "Sort by:"
@@ -1350,51 +1796,43 @@ msgstr "Configurer Shaarli"
 msgid "Enable, disable and configure plugins"
 msgstr "Activer, désactiver et configurer les extensions"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
+msgid "Check instance's server configuration"
+msgstr "Vérifier la configuration serveur de l'instance"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
 msgid "Change your password"
 msgstr "Modifier le mot de passe"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 msgid "Rename or delete a tag in all links"
 msgstr "Renommer ou supprimer un tag dans tous les liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#, fuzzy
-#| msgid ""
-#| "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
-#| "delicious…)"
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 msgid ""
 "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
 "delicious...)"
 msgstr ""
 "Importer des marques pages au format Netscape HTML (comme exportés depuis "
-"Firefox, Chrome, Opera, delicious)"
+"Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 msgid "Import links"
 msgstr "Importer des liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
-#, fuzzy
-#| msgid ""
-#| "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
-#| "Opera, delicious…)"
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
 msgid ""
 "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
 "Opera, delicious...)"
 msgstr ""
 "Exporter les marques pages au format Netscape HTML (comme exportés depuis "
-"Firefox, Chrome, Opera, delicious)"
+"Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54
 msgid "Export database"
 msgstr "Exporter les données"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:55
-msgid "Synchronize all link thumbnails"
-msgstr "Synchroniser toutes les miniatures"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
 msgid ""
 "Drag one of these button to your bookmarks toolbar or right-click it and "
 "\"Bookmark This Link\""
@@ -1402,13 +1840,13 @@ msgstr ""
 "Glisser un de ces boutons dans votre barre de favoris ou cliquer droit "
 "dessus et « Ajouter aux favoris »"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
 msgid "then click on the bookmarklet in any page you want to share."
 msgstr ""
 "puis cliquer sur le marque-page depuis un site que vous souhaitez partager."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
 msgid ""
 "Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
 "Link"
@@ -1416,40 +1854,40 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « "
 "Ajouter aux favoris »"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
 msgid "then click ✚Shaare link button in any page you want to share"
 msgstr "puis cliquer sur ✚Shaare depuis un site que vous souhaitez partager"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:118
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:92
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:114
 msgid "The selected text is too long, it will be truncated."
 msgstr "Le texte sélectionné est trop long, il sera tronqué."
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "Shaare link"
 msgstr "Shaare"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:107
 msgid ""
 "Then click ✚Add Note button anytime to start composing a private Note (text "
 "post) to your Shaarli"
 msgstr ""
 "Puis cliquer sur ✚Add Note pour commencer à rédiger une Note sur Shaarli"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:127
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
 msgid "Add Note"
 msgstr "Ajouter une Note"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:132
 msgid "3rd party"
 msgstr "Applications tierces"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:144
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:135
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:140
 msgid "plugin"
 msgstr "extension"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:165
 msgid ""
 "Drag this link to your bookmarks toolbar, or right-click it and choose "
 "Bookmark This Link"
@@ -1457,6 +1895,74 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et « "
 "Ajouter aux favoris »"
 
+#~ msgid "Display:"
+#~ msgstr "Afficher :"
+
+#~ msgid "The Daily Shaarli"
+#~ msgstr "Le Quotidien Shaarli"
+
+#, fuzzy
+#~| msgid "Selection"
+#~ msgid ".ui-selecting"
+#~ msgstr "Choisir"
+
+#, fuzzy
+#~| msgid "Documentation"
+#~ msgid "document"
+#~ msgstr "Documentation"
+
+#~ msgid "The page you are trying to reach does not exist or has been deleted."
+#~ msgstr ""
+#~ "La page que vous essayez de consulter n'existe pas ou a été supprimée."
+
+#~ msgid "404 Not Found"
+#~ msgstr "404 Introuvable"
+
+#~ msgid "Updates file path is not set, can't write updates."
+#~ msgstr ""
+#~ "Le chemin vers le fichier de mise à jour n'est pas défini, impossible "
+#~ "d'écrire les mises à jour."
+
+#~ msgid "Unable to write updates in "
+#~ msgstr "Impossible d'écrire les mises à jour dans "
+
+#~ msgid "I said: NO. You are banned for the moment. Go away."
+#~ msgstr "NON. Vous êtes banni pour le moment. Revenez plus tard."
+
+#~ msgid "Click to try again."
+#~ msgstr "Cliquer ici pour réessayer."
+
+#~ msgid ""
+#~ "Render shaare description with Markdown syntax.<br><strong>Warning</"
+#~ "strong>:\n"
+#~ "If your shaared descriptions contained HTML tags before enabling the "
+#~ "markdown plugin,\n"
+#~ "enabling it might break your page.\n"
+#~ "See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a>."
+#~ msgstr ""
+#~ "Utilise la syntaxe Markdown pour la description des liens."
+#~ "<br><strong>Attention</strong> :\n"
+#~ "Si vous aviez des descriptions contenant du HTML avant d'activer cette "
+#~ "extension,\n"
+#~ "l'activer pourrait déformer vos pages.\n"
+#~ "Voir le <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a>."
+
+#~ msgid "Synchonize thumbnails"
+#~ msgstr "Synchroniser les miniatures"
+
+#, fuzzy
+#~| msgid ""
+#~| "You don't have any cached thumbnail. Try to <a href=\"?do=thumbs_update"
+#~| "\">synchronize them</a>."
+#~ msgid ""
+#~ "There is no cached thumbnail. Try to <a href=\"?do=thumbs_update"
+#~ "\">synchronize them</a>."
+#~ msgstr ""
+#~ "Il n'y a aucune miniature en cache. Essayer de <a href=\"?do=thumbs_update"
+#~ "\">les synchroniser</a>."
+
 #~ msgid ""
 #~ "You need to browse your Shaarli over <strong>HTTPS</strong> to use this "
 #~ "functionality."
diff --git a/inc/languages/ja/LC_MESSAGES/shaarli.po b/inc/languages/ja/LC_MESSAGES/shaarli.po
deleted file mode 100644 (file)
index b420bb5..0000000
+++ /dev/null
@@ -1,1293 +0,0 @@
-msgid ""
-msgstr ""
-"Project-Id-Version: Shaarli\n"
-"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2020-02-11 09:31+0900\n"
-"PO-Revision-Date: 2020-02-11 10:54+0900\n"
-"Last-Translator: yude <yudesleepy@gmail.com>\n"
-"Language-Team: Shaarli\n"
-"Language: ja\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-"X-Generator: Poedit 2.3\n"
-"X-Poedit-Basepath: ../../../..\n"
-"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-"X-Poedit-SourceCharset: UTF-8\n"
-"X-Poedit-KeywordsList: t:1,2;t\n"
-"X-Poedit-SearchPath-0: .\n"
-"X-Poedit-SearchPathExcluded-0: node_modules\n"
-"X-Poedit-SearchPathExcluded-1: vendor\n"
-
-#: application/ApplicationUtils.php:153
-#, php-format
-msgid ""
-"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
-"cannot run. Your PHP version has known security vulnerabilities and should "
-"be updated as soon as possible."
-msgstr ""
-"使用している PHP のバージョンが古すぎます! Shaarli の実行には最低でも PHP %s "
-"が必要です。 現在使用している PHP のバージョンには脆弱性があり、できるだけ速"
-"やかにアップデートするべきです。"
-
-#: application/ApplicationUtils.php:183 application/ApplicationUtils.php:195
-msgid "directory is not readable"
-msgstr "ディレクトリを読み込めません"
-
-#: application/ApplicationUtils.php:198
-msgid "directory is not writable"
-msgstr "ディレクトリに書き込めません"
-
-#: application/ApplicationUtils.php:216
-msgid "file is not readable"
-msgstr "ファイルを読み取る権限がありません"
-
-#: application/ApplicationUtils.php:219
-msgid "file is not writable"
-msgstr "ファイルを書き込む権限がありません"
-
-#: application/Cache.php:16
-#, php-format
-msgid "Cannot purge %s: no directory"
-msgstr "%s を削除できません: ディレクトリが存在しません"
-
-#: application/FeedBuilder.php:151
-msgid "Direct link"
-msgstr "ダイレクトリンク"
-
-#: application/FeedBuilder.php:153
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:178
-msgid "Permalink"
-msgstr "パーマリンク"
-
-#: application/History.php:174
-msgid "History file isn't readable or writable"
-msgstr "履歴ファイルを読み込む、または書き込むための権限がありません"
-
-#: application/History.php:185
-msgid "Could not parse history file"
-msgstr "履歴ファイルを正常に復元できませんでした"
-
-#: application/Languages.php:177
-msgid "Automatic"
-msgstr "自動"
-
-#: application/Languages.php:178
-msgid "English"
-msgstr "英語"
-
-#: application/Languages.php:179
-msgid "French"
-msgstr "フランス語"
-
-#: application/Languages.php:180
-msgid "German"
-msgstr "ドイツ語"
-
-#: application/LinkDB.php:136
-msgid "You are not authorized to add a link."
-msgstr "リンクを追加するには、ログインする必要があります。"
-
-#: application/LinkDB.php:139
-msgid "Internal Error: A link should always have an id and URL."
-msgstr "エラー: リンクにはIDとURLを登録しなければなりません。"
-
-#: application/LinkDB.php:142
-msgid "You must specify an integer as a key."
-msgstr "正常なキーの値ではありません。"
-
-#: application/LinkDB.php:145
-msgid "Array offset and link ID must be equal."
-msgstr "Array オフセットとリンクのIDは同じでなければなりません。"
-
-#: application/LinkDB.php:251
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
-msgid ""
-"The personal, minimalist, super-fast, database free, bookmarking service"
-msgstr ""
-"個人向けの、ミニマムで高速でかつデータベースのいらないブックマークサービス"
-
-#: application/LinkDB.php:253
-msgid ""
-"Welcome to Shaarli! This is your first public bookmark. To edit or delete "
-"me, you must first login.\n"
-"\n"
-"To learn how to use Shaarli, consult the link \"Documentation\" at the "
-"bottom of this page.\n"
-"\n"
-"You use the community supported version of the original Shaarli project, by "
-"Sebastien Sauvage."
-msgstr ""
-"Shaarli へようこそ! これはあなたの最初の公開ブックマークです。これを編集した"
-"り削除したりするには、ログインする必要があります。\n"
-"\n"
-"Shaarli の使い方を知るには、このページの下にある「ドキュメント」のリンクを開"
-"いてください。\n"
-"\n"
-"あなたは Sebastien Sauvage による、コミュニティーサポートのあるバージョンのオ"
-"リジナルのShaarli プロジェクトを使用しています。"
-
-#: application/LinkDB.php:267
-msgid "My secret stuff... - Pastebin.com"
-msgstr "わたしのひ💗み💗つ💗 - Pastebin.com"
-
-#: application/LinkDB.php:269
-msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
-msgstr ""
-"シーッ! これはあなたしか見られないプライベートリンクです。消すこともできま"
-"す。"
-
-#: application/LinkFilter.php:452
-msgid "The link you are trying to reach does not exist or has been deleted."
-msgstr "開こうとしたリンクは存在しないか、削除されています。"
-
-#: application/NetscapeBookmarkUtils.php:35
-msgid "Invalid export selection:"
-msgstr "不正なエクスポートの選択:"
-
-#: application/NetscapeBookmarkUtils.php:81
-#, php-format
-msgid "File %s (%d bytes) "
-msgstr "ファイル %s (%d バイト) "
-
-#: application/NetscapeBookmarkUtils.php:83
-msgid "has an unknown file format. Nothing was imported."
-msgstr "は不明なファイル形式です。インポートは中止されました。"
-
-#: application/NetscapeBookmarkUtils.php:86
-#, php-format
-msgid ""
-"was successfully processed in %d seconds: %d links imported, %d links "
-"overwritten, %d links skipped."
-msgstr ""
-"が %d 秒で処理され、%d 件のリンクがインポートされ、%d 件のリンクが上書きさ"
-"れ、%d 件のリンクがスキップされました。"
-
-#: application/PageBuilder.php:168
-msgid "The page you are trying to reach does not exist or has been deleted."
-msgstr "あなたが開こうとしたページは存在しないか、削除されています。"
-
-#: application/PageBuilder.php:170
-msgid "404 Not Found"
-msgstr "404 ページが存在しません"
-
-#: application/PluginManager.php:243
-#, php-format
-msgid "Plugin \"%s\" files not found."
-msgstr "プラグイン「%s」のファイルが存在しません。"
-
-#: application/Updater.php:76
-msgid "Couldn't retrieve Updater class methods."
-msgstr "アップデーターのクラスメゾットを受信できませんでした。"
-
-#: application/Updater.php:532
-msgid "An error occurred while running the update "
-msgstr "更新中に問題が発生しました "
-
-#: application/Updater.php:572
-msgid "Updates file path is not set, can't write updates."
-msgstr "更新するファイルのパスが指定されていないため、更新を書き込めません。"
-
-#: application/Updater.php:577
-msgid "Unable to write updates in "
-msgstr "更新を次の項目に書き込めませんでした: "
-
-#: application/Utils.php:376 tests/UtilsTest.php:340
-msgid "Setting not set"
-msgstr "未設定"
-
-#: application/Utils.php:383 tests/UtilsTest.php:338 tests/UtilsTest.php:339
-msgid "Unlimited"
-msgstr "無制限"
-
-#: application/Utils.php:386 tests/UtilsTest.php:335 tests/UtilsTest.php:336
-#: tests/UtilsTest.php:350
-msgid "B"
-msgstr "B"
-
-#: application/Utils.php:386 tests/UtilsTest.php:329 tests/UtilsTest.php:330
-#: tests/UtilsTest.php:337
-msgid "kiB"
-msgstr "kiB"
-
-#: application/Utils.php:386 tests/UtilsTest.php:331 tests/UtilsTest.php:332
-#: tests/UtilsTest.php:348 tests/UtilsTest.php:349
-msgid "MiB"
-msgstr "MiB"
-
-#: application/Utils.php:386 tests/UtilsTest.php:333 tests/UtilsTest.php:334
-msgid "GiB"
-msgstr "GiB"
-
-#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:121
-msgid ""
-"Shaarli could not create the config file. Please make sure Shaarli has the "
-"right to write in the folder is it installed in."
-msgstr ""
-"Shaarli は設定ファイルを作成できませんでした。Shaarli が正しい権限下に置かれ"
-"ていて、インストールされているディレクトリに書き込みできることを確認してくだ"
-"さい。"
-
-#: application/config/ConfigManager.php:135
-msgid "Invalid setting key parameter. String expected, got: "
-msgstr ""
-"不正なキーの値です。文字列が想定されていますが、次のように入力されました: "
-
-#: application/config/exception/MissingFieldConfigException.php:21
-#, php-format
-msgid "Configuration value is required for %s"
-msgstr "%s には設定が必要です"
-
-#: application/config/exception/PluginConfigOrderException.php:15
-msgid "An error occurred while trying to save plugins loading order."
-msgstr "プラグインの読込順を変更する際にエラーが発生しました。"
-
-#: application/config/exception/UnauthorizedConfigException.php:16
-msgid "You are not authorized to alter config."
-msgstr "設定を変更する権限がありません。"
-
-#: application/exceptions/IOException.php:19
-msgid "Error accessing"
-msgstr "読込中にエラーが発生しました"
-
-#: index.php:142
-msgid "Shared links on "
-msgstr "次において共有されたリンク:"
-
-#: index.php:164
-msgid "Insufficient permissions:"
-msgstr "権限がありません:"
-
-#: index.php:303
-msgid "I said: NO. You are banned for the moment. Go away."
-msgstr "あなたはこのサーバーからBANされています。"
-
-#: index.php:368
-msgid "Wrong login/password."
-msgstr "不正なユーザー名、またはパスワードです。"
-
-#: index.php:576 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:42
-msgid "Daily"
-msgstr "デイリー"
-
-#: index.php:681 tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:95
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:71
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:95
-msgid "Login"
-msgstr "ログイン"
-
-#: index.php:722 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:39
-msgid "Picture wall"
-msgstr "ピクチャウォール"
-
-#: index.php:770 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag cloud"
-msgstr "タグクラウド"
-
-#: index.php:803 tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Tag list"
-msgstr "タグ一覧"
-
-#: index.php:1028 tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:31
-msgid "Tools"
-msgstr "ツール"
-
-#: index.php:1037
-msgid "You are not supposed to change a password on an Open Shaarli."
-msgstr ""
-"公開されている Shaarli において、パスワードを変更することは想定されていませ"
-"ん。"
-
-#: index.php:1042 index.php:1084 index.php:1160 index.php:1191 index.php:1291
-msgid "Wrong token."
-msgstr "不正なトークンです。"
-
-#: index.php:1047
-msgid "The old password is not correct."
-msgstr "元のパスワードが正しくありません。"
-
-#: index.php:1067
-msgid "Your password has been changed"
-msgstr "あなたのパスワードは変更されました"
-
-#: index.php:1072
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Change password"
-msgstr "パスワードを変更"
-
-#: index.php:1120
-msgid "Configuration was saved."
-msgstr "設定は保存されました。"
-
-#: index.php:1143 tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "Configure"
-msgstr "設定"
-
-#: index.php:1154 tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-msgid "Manage tags"
-msgstr "タグを設定"
-
-#: index.php:1172
-#, php-format
-msgid "The tag was removed from %d link."
-msgid_plural "The tag was removed from %d links."
-msgstr[0] "%d 件のリンクからタグが削除されました。"
-msgstr[1] "The tag was removed from %d links."
-
-#: index.php:1173
-#, php-format
-msgid "The tag was renamed in %d link."
-msgid_plural "The tag was renamed in %d links."
-msgstr[0] "タグが %d 件のリンクにおいて、名前が変更されました。"
-msgstr[1] "タグが %d 件のリンクにおいて、名前が変更されました。"
-
-#: index.php:1181 tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-msgid "Shaare a new link"
-msgstr "新しいリンクを追加"
-
-#: index.php:1351 tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170
-msgid "Edit"
-msgstr "共有"
-
-#: index.php:1351 index.php:1421
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:26
-msgid "Shaare"
-msgstr "Shaare"
-
-#: index.php:1390
-msgid "Note: "
-msgstr "注: "
-
-#: index.php:1430 tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:65
-msgid "Export"
-msgstr "エクスポート"
-
-#: index.php:1492 tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
-msgid "Import"
-msgstr "インポート"
-
-#: index.php:1502
-#, php-format
-msgid ""
-"The file you are trying to upload is probably bigger than what this "
-"webserver can accept (%s). Please upload in smaller chunks."
-msgstr ""
-"あなたがアップロードしようとしているファイルは、サーバーが許可しているファイ"
-"ルサイズ (%s) よりも大きいです。もう少し小さいものをアップロードしてくださ"
-"い。"
-
-#: index.php:1541 tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Plugin administration"
-msgstr "プラグイン管理"
-
-#: index.php:1706
-msgid "Search: "
-msgstr "検索: "
-
-#: index.php:1933
-#, php-format
-msgid ""
-"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
-"variable \"session.save_path\" is set correctly in your PHP config, and that "
-"you have write access to it.<br>It currently points to %s.<br>On some "
-"browsers, accessing your server via a hostname like 'localhost' or any "
-"custom hostname without a dot causes cookie storage to fail. We recommend "
-"accessing your server via it's IP address or Fully Qualified Domain Name.<br>"
-msgstr ""
-"<pre>セッションが正常にあなたのサーバー上で稼働していないようです。<br>PHP の"
-"設定ファイル内にて、正しく \"session.save_path\" の値が設定されていることと、"
-"権限が間違っていないことを確認してください。<br>現在 %s からPHPの設定ファイル"
-"を読み込んでいます。<br>一部のブラウザーにおいて、localhost や他のドットを含"
-"まないホスト名にてサーバーにアクセスする際に、クッキーを保存できないことがあ"
-"ります。IP アドレスや完全なドメイン名でサーバーにアクセスすることをおすすめし"
-"ます。<br>"
-
-#: index.php:1943
-msgid "Click to try again."
-msgstr "クリックして再度試します。"
-
-#: plugins/addlink_toolbar/addlink_toolbar.php:29
-msgid "URI"
-msgstr "URI"
-
-#: plugins/addlink_toolbar/addlink_toolbar.php:33
-#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "Add link"
-msgstr "リンクを追加"
-
-#: plugins/addlink_toolbar/addlink_toolbar.php:50
-msgid "Adds the addlink input on the linklist page."
-msgstr "リンク一覧のページに、リンクを追加するためのフォームを表示する。"
-
-#: plugins/archiveorg/archiveorg.php:23
-msgid "View on archive.org"
-msgstr "archive.org 上で表示する"
-
-#: plugins/archiveorg/archiveorg.php:36
-msgid "For each link, add an Archive.org icon."
-msgstr "それぞれのリンクに、Archive.org のアイコンを追加する。"
-
-#: plugins/demo_plugin/demo_plugin.php:465
-msgid ""
-"A demo plugin covering all use cases for template designers and plugin "
-"developers."
-msgstr ""
-"テンプレートのデザイナーや、プラグインの開発者のためのすべての状況に対応でき"
-"るデモプラグインです。"
-
-#: plugins/isso/isso.php:20
-msgid ""
-"Isso plugin error: Please define the \"ISSO_SERVER\" setting in the plugin "
-"administration page."
-msgstr ""
-"Isso プラグインエラー: \"ISSO_SERVER\" の値をプラグイン管理ページにて指定して"
-"ください。"
-
-#: plugins/isso/isso.php:63
-msgid "Let visitor comment your shaares on permalinks with Isso."
-msgstr ""
-"Isso を使って、あなたのパーマリンク上のリンクに第三者がコメントを残すことがで"
-"きます。"
-
-#: plugins/isso/isso.php:64
-msgid "Isso server URL (without 'http://')"
-msgstr "Isso server URL ('http://' 抜き)"
-
-#: plugins/markdown/markdown.php:158
-msgid "Description will be rendered with"
-msgstr "説明は次の方法で描画されます:"
-
-#: plugins/markdown/markdown.php:159
-msgid "Markdown syntax documentation"
-msgstr "マークダウン形式のドキュメント"
-
-#: plugins/markdown/markdown.php:160
-msgid "Markdown syntax"
-msgstr "マークダウン形式"
-
-#: plugins/markdown/markdown.php:339
-msgid ""
-"Render shaare description with Markdown syntax.<br><strong>Warning</"
-"strong>:\n"
-"If your shaared descriptions contained HTML tags before enabling the "
-"markdown plugin,\n"
-"enabling it might break your page.\n"
-"See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a>."
-msgstr ""
-"リンクの説明をマークダウン形式で表示します。<br><strong>警告</strong>:\n"
-"リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n"
-"正常にページを表示できなくなるかもしれません。\n"
-"詳しくは <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
-"markdown#html-rendering\">README</a> をご覧ください。"
-
-#: plugins/piwik/piwik.php:21
-msgid ""
-"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
-"administration page."
-msgstr ""
-"Piwik プラグインエラー: PIWIK_URL と PIWIK_SITEID の値をプラグイン管理ページ"
-"で指定してください。"
-
-#: plugins/piwik/piwik.php:70
-msgid "A plugin that adds Piwik tracking code to Shaarli pages."
-msgstr "Piwik のトラッキングコードをShaarliに追加するプラグインです。"
-
-#: plugins/piwik/piwik.php:71
-msgid "Piwik URL"
-msgstr "Piwik URL"
-
-#: plugins/piwik/piwik.php:72
-msgid "Piwik site ID"
-msgstr "Piwik サイトID"
-
-#: plugins/playvideos/playvideos.php:22
-msgid "Video player"
-msgstr "動画プレイヤー"
-
-#: plugins/playvideos/playvideos.php:25
-msgid "Play Videos"
-msgstr "動画を再生"
-
-#: plugins/playvideos/playvideos.php:56
-msgid "Add a button in the toolbar allowing to watch all videos."
-msgstr "すべての動画を閲覧するボタンをツールバーに追加します。"
-
-#: plugins/playvideos/youtube_playlist.js:214
-msgid "plugins/playvideos/jquery-1.11.2.min.js"
-msgstr "plugins/playvideos/jquery-1.11.2.min.js"
-
-#: plugins/pubsubhubbub/pubsubhubbub.php:69
-#, php-format
-msgid "Could not publish to PubSubHubbub: %s"
-msgstr "PubSubHubbub に登録できませんでした: %s"
-
-#: plugins/pubsubhubbub/pubsubhubbub.php:95
-#, php-format
-msgid "Could not post to %s"
-msgstr "%s に登録できませんでした"
-
-#: plugins/pubsubhubbub/pubsubhubbub.php:99
-#, php-format
-msgid "Bad response from the hub %s"
-msgstr "ハブ %s からの不正なレスポンス"
-
-#: plugins/pubsubhubbub/pubsubhubbub.php:110
-msgid "Enable PubSubHubbub feed publishing."
-msgstr "PubSubHubbub へのフィードを公開する。"
-
-#: plugins/qrcode/qrcode.php:69 plugins/wallabag/wallabag.php:68
-msgid "For each link, add a QRCode icon."
-msgstr "それぞれのリンクについて、QRコードのアイコンを追加する。"
-
-#: plugins/wallabag/wallabag.php:21
-msgid ""
-"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the "
-"plugin administration page."
-msgstr ""
-"Wallabag プラグインエラー: \"WALLABAG_URL\" の値をプラグイン管理ページにおい"
-"て指定してください。"
-
-#: plugins/wallabag/wallabag.php:47
-msgid "Save to wallabag"
-msgstr "Wallabag に保存"
-
-#: plugins/wallabag/wallabag.php:69
-msgid "Wallabag API URL"
-msgstr "Wallabag のAPIのURL"
-
-#: plugins/wallabag/wallabag.php:70
-msgid "Wallabag API version (1 or 2)"
-msgstr "Wallabag のAPIのバージョン (1 または 2)"
-
-#: tests/LanguagesTest.php:214 tests/LanguagesTest.php:227
-#: tests/languages/fr/LanguagesFrTest.php:160
-#: tests/languages/fr/LanguagesFrTest.php:173
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:81
-msgid "Search"
-msgid_plural "Search"
-msgstr[0] "検索"
-msgstr[1] "検索"
-
-#: tmp/404.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
-msgid "Sorry, nothing to see here."
-msgstr "すみませんが、ここには何もありません。"
-
-#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "URL or leave empty to post a note"
-msgstr "URL を入力するか、空欄にするとノートを投稿します"
-
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Current password"
-msgstr "現在のパスワード"
-
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "New password"
-msgstr "新しいパスワード"
-
-#: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
-msgid "Change"
-msgstr "変更"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
-msgid "Tag"
-msgstr "タグ"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "New name"
-msgstr "変更先の名前"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-msgid "Case sensitive"
-msgstr "大文字と小文字を区別"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-msgid "Rename"
-msgstr "名前を変更"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:172
-msgid "Delete"
-msgstr "削除"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
-msgid "You can also edit tags in the"
-msgstr "次に含まれるタグを編集することもできます:"
-
-#: tmp/changetag.b91ef64efc3688266305ea9b42e5017e.rtpl.php:39
-msgid "tag list"
-msgstr "タグ一覧"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "title"
-msgstr "タイトル"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-msgid "Home link"
-msgstr "ホームのリンク先"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "Default value"
-msgstr "既定の値"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
-msgid "Theme"
-msgstr "テーマ"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:87
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:78
-msgid "Language"
-msgstr "言語"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:116
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
-msgid "Timezone"
-msgstr "タイムゾーン"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
-msgid "Continent"
-msgstr "大陸"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
-msgid "City"
-msgstr "町"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:164
-msgid "Disable session cookie hijacking protection"
-msgstr "不正ログイン防止のためのセッションクッキーを無効化"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:166
-msgid "Check this if you get disconnected or if your IP address changes often"
-msgstr ""
-"あなたが切断されたり、IPアドレスが頻繁に変わる環境下であるならチェックを入れ"
-"てください"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:183
-msgid "Private links by default"
-msgstr "既定でプライベートリンク"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:184
-msgid "All new links are private by default"
-msgstr "すべての新規リンクをプライベートで作成"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
-msgid "RSS direct links"
-msgstr "RSS 直リンク"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:200
-msgid "Check this to use direct URL instead of permalink in feeds"
-msgstr "フィードでパーマリンクの代わりに直リンクを使う"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:215
-msgid "Hide public links"
-msgstr "公開リンクを隠す"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:216
-msgid "Do not show any links if the user is not logged in"
-msgstr "ログインしていないユーザーには何のリンクも表示しない"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:231
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
-msgid "Check updates"
-msgstr "更新を確認"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:232
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:152
-msgid "Notify me when a new release is ready"
-msgstr "新しいバージョンがリリースされたときに通知"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:247
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-msgid "Enable REST API"
-msgstr "REST API を有効化"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:248
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:170
-msgid "Allow third party software to use Shaarli such as mobile application"
-msgstr ""
-"モバイルアプリといったサードパーティーのソフトウェアにShaarliを使用することを"
-"許可"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:263
-msgid "API secret"
-msgstr "API シークレット"
-
-#: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:274
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:139
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:199
-msgid "Save"
-msgstr "保存"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid "The Daily Shaarli"
-msgstr "デイリーSharli"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
-msgid "1 RSS entry per day"
-msgstr "各日1つずつのRSS項目"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:37
-msgid "Previous day"
-msgstr "前日"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-msgid "All links of one day in a single page."
-msgstr "1日に作成されたすべてのリンクです。"
-
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:51
-msgid "Next day"
-msgstr "翌日"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
-msgid "Created:"
-msgstr "作成:"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-msgid "URL"
-msgstr "URL"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-msgid "Title"
-msgstr "タイトル"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124
-msgid "Description"
-msgstr "説明"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-msgid "Tags"
-msgstr "タグ"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:59
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:168
-msgid "Private"
-msgstr "プライベート"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-msgid "Apply Changes"
-msgstr "変更を適用"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Export Database"
-msgstr "データベースをエクスポート"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "Selection"
-msgstr "選択済み"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-msgid "All"
-msgstr "すべて"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-msgid "Public"
-msgstr "公開"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:52
-msgid "Prepend note permalinks with this Shaarli instance's URL"
-msgstr "この Shaarli のインスタンスのURL にノートへのパーマリンクを付け加える"
-
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
-msgid "Useful to import bookmarks in a web browser"
-msgstr "ウェブブラウザーのリンクをインポートするのに有効です"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Import Database"
-msgstr "データベースをインポート"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:23
-msgid "Maximum size allowed:"
-msgstr "最大サイズ:"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Visibility"
-msgstr "可視性"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-msgid "Use values from the imported file, default to public"
-msgstr "インポート元のファイルの値を使用 (既定は公開リンクとなります)"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-msgid "Import all bookmarks as private"
-msgstr "すべてのブックマーク項目をプライベートリンクとしてインポート"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-msgid "Import all bookmarks as public"
-msgstr "すべてのブックマーク項目を公開リンクとしてインポート"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:57
-msgid "Overwrite existing bookmarks"
-msgstr "既に存在しているブックマークを上書き"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:58
-msgid "Duplicates based on URL"
-msgstr "URL による重複"
-
-#: tmp/import.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
-msgid "Add default tags"
-msgstr "既定のタグを追加"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Install Shaarli"
-msgstr "Shaarli をインストール"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
-msgid "It looks like it's the first time you run Shaarli. Please configure it."
-msgstr "どうやら Shaarli を初めて起動しているようです。設定してください。"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:33
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:147
-msgid "Username"
-msgstr "ユーザー名"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:148
-msgid "Password"
-msgstr "パスワード"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
-msgid "Shaarli title"
-msgstr "Shaarli のタイトル"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
-msgid "My links"
-msgstr "自分のリンク"
-
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182
-msgid "Install"
-msgstr "インストール"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:80
-msgid "shaare"
-msgid_plural "shaares"
-msgstr[0] "共有"
-msgstr[1] "共有"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:84
-msgid "private link"
-msgid_plural "private links"
-msgstr[0] "プライベートリンク"
-msgstr[1] "プライベートリンク"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:31
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:117
-msgid "Search text"
-msgstr "文字列で検索"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:124
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:124
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:64
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:36
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-msgid "Filter by tag"
-msgstr "タグによって分類"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:111
-msgid "Nothing found."
-msgstr "何も見つかりませんでした。"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:119
-#, php-format
-msgid "%s result"
-msgid_plural "%s results"
-msgstr[0] "%s 件の結果"
-msgstr[1] "%s 件の結果"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
-msgid "for"
-msgstr "for"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:130
-msgid "tagged"
-msgstr "タグ付けされた"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
-msgid "Remove tag"
-msgstr "タグを削除"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:143
-msgid "with status"
-msgstr "with status"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154
-msgid "without any tag"
-msgstr "タグなし"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:174
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:42
-msgid "Fold"
-msgstr "畳む"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176
-msgid "Edited: "
-msgstr "編集済み: "
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:180
-msgid "permalink"
-msgstr "パーマリンク"
-
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:182
-msgid "Add tag"
-msgstr "タグを追加"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:7
-msgid "Filters"
-msgstr "分類"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:12
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:12
-msgid "Only display private links"
-msgstr "プライベートリンクのみを表示"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:15
-msgid "Only display public links"
-msgstr "公開リンクのみを表示"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:20
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:20
-msgid "Filter untagged links"
-msgstr "タグ付けされていないリンクで分類"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:24
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:76
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:43
-msgid "Fold all"
-msgstr "すべて畳む"
-
-#: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
-#: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:69
-msgid "Links per page"
-msgstr "各ページをリンク"
-
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid ""
-"You have been banned after too many failed login attempts. Try again later."
-msgstr "複数回に渡るログインへの失敗を検出しました。後でまた試してください。"
-
-#: tmp/loginform.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:151
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:151
-msgid "Remember me"
-msgstr "パスワードを保存"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:14
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:48
-msgid "by the Shaarli community"
-msgstr "by Shaarli コミュニティ"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:15
-msgid "Documentation"
-msgstr "ドキュメント"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:44
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:44
-msgid "Expand"
-msgstr "展開する"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:45
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:45
-msgid "Expand all"
-msgstr "すべて展開する"
-
-#: tmp/page.footer.b91ef64efc3688266305ea9b42e5017e.rtpl.php:46
-#: tmp/page.footer.cedf684561d925457130839629000a81.rtpl.php:46
-msgid "Are you sure you want to delete this link?"
-msgstr "本当にこのリンクを削除しますか?"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:61
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:61
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:86
-msgid "RSS Feed"
-msgstr "RSS フィード"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:66
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:102
-msgid "Logout"
-msgstr "ログアウト"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:169
-msgid "is available"
-msgstr "が利用可能"
-
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:176
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:176
-msgid "Error"
-msgstr "エラー"
-
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Picture Wall"
-msgstr "ピクチャーウォール"
-
-#: tmp/picwall.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "pics"
-msgstr "画像"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
-msgid "You need to enable Javascript to change plugin loading order."
-msgstr ""
-"プラグインを読み込む順番を変更するには、Javascriptを有効にする必要がありま"
-"す。"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
-msgid "Enabled Plugins"
-msgstr "有効なプラグイン"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:155
-msgid "No plugin enabled."
-msgstr "有効なプラグインはありません。"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:40
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:73
-msgid "Disable"
-msgstr "無効化"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:74
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:98
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:123
-msgid "Name"
-msgstr "名前"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:43
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
-msgid "Order"
-msgstr "順序"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-msgid "Disabled Plugins"
-msgstr "無効なプラグイン"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:91
-msgid "No plugin disabled."
-msgstr "無効なプラグインはありません。"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:97
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
-msgid "Enable"
-msgstr "有効化"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
-msgid "More plugins available"
-msgstr "さらに利用できるプラグインがあります"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:136
-msgid "in the documentation"
-msgstr "ドキュメント内"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:150
-msgid "Plugin configuration"
-msgstr "プラグイン設定"
-
-#: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:195
-msgid "No parameter available."
-msgstr "利用可能な設定項目はありません。"
-
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:19
-msgid "tags"
-msgstr "タグ"
-
-#: tmp/tag.cloud.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-#: tmp/tag.list.b91ef64efc3688266305ea9b42e5017e.rtpl.php:24
-msgid "List all links with those tags"
-msgstr "このタグが付いているリンクをリスト化する"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:3
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:3
-msgid "Sort by:"
-msgstr "分類:"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:5
-msgid "Cloud"
-msgstr "クラウド"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:6
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:6
-msgid "Most used"
-msgstr "もっとも使われた"
-
-#: tmp/tag.sort.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
-#: tmp/tag.sort.cedf684561d925457130839629000a81.rtpl.php:7
-msgid "Alphabetical"
-msgstr "アルファベット順"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
-msgid "Settings"
-msgstr "設定"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:16
-msgid "Change Shaarli settings: title, timezone, etc."
-msgstr "Shaarli の設定を変更: タイトル、タイムゾーンなど。"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:17
-msgid "Configure your Shaarli"
-msgstr "あなたの Shaarli を設定"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
-msgid "Enable, disable and configure plugins"
-msgstr "プラグインを有効化、無効化、設定する"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-msgid "Change your password"
-msgstr "パスワードを変更"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-msgid "Rename or delete a tag in all links"
-msgstr "すべてのリンクのタグの名前を変更する、または削除する"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
-msgid ""
-"Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
-"delicious...)"
-msgstr ""
-"Netscape HTML 形式のブックマークをインポートする (Firefox、Chrome、Operaと"
-"いったブラウザーが含まれます)"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
-msgid "Import links"
-msgstr "リンクをインポート"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
-msgid ""
-"Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
-"Opera, delicious...)"
-msgstr ""
-"Netscape HTML 形式のブックマークをエクスポートする (Firefox、Chrome、Operaと"
-"いったブラウザーが含まれます)"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
-msgid "Export database"
-msgstr "リンクをエクスポート"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:71
-msgid ""
-"Drag one of these button to your bookmarks toolbar or right-click it and "
-"\"Bookmark This Link\""
-msgstr ""
-"これらのボタンのうち1つををブックマークバーにドラッグするか、右クリックして"
-"「このリンクをブックマークに追加」してください"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:72
-msgid "then click on the bookmarklet in any page you want to share."
-msgstr "共有したいページでブックマークレットをクリックしてください。"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:76
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:100
-msgid ""
-"Drag this link to your bookmarks toolbar or right-click it and Bookmark This "
-"Link"
-msgstr ""
-"このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
-"ブックマークに追加」してください"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
-msgid "then click ✚Shaare link button in any page you want to share"
-msgstr "✚リンクを共有 ボタンをクリックすることで、どこでもリンクを共有できます"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:86
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:108
-msgid "The selected text is too long, it will be truncated."
-msgstr "選択された文字列は長すぎるので、一部が切り捨てられます。"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
-msgid "Shaare link"
-msgstr "共有リンク"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:101
-msgid ""
-"Then click ✚Add Note button anytime to start composing a private Note (text "
-"post) to your Shaarli"
-msgstr ""
-"✚ノートを追加 ボタンをクリックすることで、いつでもプライベートノート(テキスト"
-"形式)をShaarli上に作成できます"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:117
-msgid "Add Note"
-msgstr "ノートを追加"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:129
-msgid ""
-"You need to browse your Shaarli over <strong>HTTPS</strong> to use this "
-"functionality."
-msgstr ""
-"この機能を使用するには、<strong>HTTPS</strong> 経由でShaarliに接続してくださ"
-"い。"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:134
-msgid "Add to"
-msgstr "次に追加:"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:145
-msgid "3rd party"
-msgstr "サードパーティー"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:147
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:153
-msgid "Plugin"
-msgstr "プラグイン"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:148
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:154
-msgid "plugin"
-msgstr "プラグイン"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:175
-msgid ""
-"Drag this link to your bookmarks toolbar, or right-click it and choose "
-"Bookmark This Link"
-msgstr ""
-"このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
-"ブックマークに追加」してください"
diff --git a/inc/languages/jp/LC_MESSAGES/shaarli.po b/inc/languages/jp/LC_MESSAGES/shaarli.po
new file mode 100644 (file)
index 0000000..57f42fc
--- /dev/null
@@ -0,0 +1,1333 @@
+msgid ""
+msgstr ""
+"Project-Id-Version: Shaarli\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2020-10-19 10:19+0900\n"
+"PO-Revision-Date: 2020-10-19 10:25+0900\n"
+"Last-Translator: yude <yudesleepy@gmail.com>\n"
+"Language-Team: Shaarli\n"
+"Language: ja\n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+"X-Generator: Poedit 2.2.3\n"
+"X-Poedit-Basepath: ../../../..\n"
+"Plural-Forms: nplurals=2; plural=(n != 1);\n"
+"X-Poedit-SourceCharset: UTF-8\n"
+"X-Poedit-KeywordsList: t:1,2;t\n"
+"X-Poedit-SearchPath-0: .\n"
+"X-Poedit-SearchPathExcluded-0: node_modules\n"
+"X-Poedit-SearchPathExcluded-1: vendor\n"
+
+#: application/ApplicationUtils.php:161
+#, php-format
+msgid ""
+"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
+"cannot run. Your PHP version has known security vulnerabilities and should "
+"be updated as soon as possible."
+msgstr ""
+"使用している PHP のバージョンが古すぎます! Shaarli の実行には最低でも PHP %s "
+"が必要です。 現在使用している PHP のバージョンには脆弱性があり、できるだけ速"
+"やかにアップデートするべきです。"
+
+#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204
+msgid "directory is not readable"
+msgstr "ディレクトリを読み込めません"
+
+#: application/ApplicationUtils.php:207
+msgid "directory is not writable"
+msgstr "ディレクトリに書き込めません"
+
+#: application/ApplicationUtils.php:225
+msgid "file is not readable"
+msgstr "ファイルを読み取る権限がありません"
+
+#: application/ApplicationUtils.php:228
+msgid "file is not writable"
+msgstr "ファイルを書き込む権限がありません"
+
+#: application/History.php:179
+msgid "History file isn't readable or writable"
+msgstr "履歴ファイルを読み込む、または書き込むための権限がありません"
+
+#: application/History.php:190
+msgid "Could not parse history file"
+msgstr "履歴ファイルを正常に復元できませんでした"
+
+#: application/Languages.php:181
+msgid "Automatic"
+msgstr "自動"
+
+#: application/Languages.php:182
+msgid "German"
+msgstr "ドイツ語"
+
+#: application/Languages.php:183
+msgid "English"
+msgstr "英語"
+
+#: application/Languages.php:184
+msgid "French"
+msgstr "フランス語"
+
+#: application/Languages.php:185
+msgid "Japanese"
+msgstr "日本語"
+
+#: application/Thumbnailer.php:62
+msgid ""
+"php-gd extension must be loaded to use thumbnails. Thumbnails are now "
+"disabled. Please reload the page."
+msgstr ""
+"サムネイルを使用するには、php-gd エクステンションが読み込まれている必要があり"
+"ます。サムネイルは無効化されました。ページを再読込してください。"
+
+#: application/Utils.php:383 tests/UtilsTest.php:343
+msgid "Setting not set"
+msgstr "未設定"
+
+#: application/Utils.php:390 tests/UtilsTest.php:341 tests/UtilsTest.php:342
+msgid "Unlimited"
+msgstr "無制限"
+
+#: application/Utils.php:393 tests/UtilsTest.php:338 tests/UtilsTest.php:339
+#: tests/UtilsTest.php:353
+msgid "B"
+msgstr "B"
+
+#: application/Utils.php:393 tests/UtilsTest.php:332 tests/UtilsTest.php:333
+#: tests/UtilsTest.php:340
+msgid "kiB"
+msgstr "kiB"
+
+#: application/Utils.php:393 tests/UtilsTest.php:334 tests/UtilsTest.php:335
+#: tests/UtilsTest.php:351 tests/UtilsTest.php:352
+msgid "MiB"
+msgstr "MiB"
+
+#: application/Utils.php:393 tests/UtilsTest.php:336 tests/UtilsTest.php:337
+msgid "GiB"
+msgstr "GiB"
+
+#: application/bookmark/BookmarkFileService.php:180
+#: application/bookmark/BookmarkFileService.php:202
+#: application/bookmark/BookmarkFileService.php:224
+#: application/bookmark/BookmarkFileService.php:238
+msgid "You're not authorized to alter the datastore"
+msgstr "設定を変更する権限がありません"
+
+#: application/bookmark/BookmarkFileService.php:205
+msgid "This bookmarks already exists"
+msgstr "このブックマークは既に存在します。"
+
+#: application/bookmark/BookmarkInitializer.php:39
+msgid "(private bookmark with thumbnail demo)"
+msgstr "(サムネイルデモが付属しているプライベートブックマーク)"
+
+#: application/bookmark/BookmarkInitializer.php:42
+msgid ""
+"Shaarli will automatically pick up the thumbnail for links to a variety of "
+"websites.\n"
+"\n"
+"Explore your new Shaarli instance by trying out controls and menus.\n"
+"Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the "
+"documentation](https://shaarli.readthedocs.io/en/master/) to learn more "
+"about Shaarli.\n"
+"\n"
+"Now you can edit or delete the default shaares.\n"
+msgstr ""
+"Shaarli は自動的に多様なウェブサイトのサムネイルを取得します。\n"
+"\n"
+"あなたの新しい Shaarli インスタンスをコントロールやメニューを試したりして、探"
+"検してください。\n"
+" [Github](https://github.com/shaarli/Shaarli) または [the documentation]"
+"(https://shaarli.readthedocs.io/en/master/) でプロジェクトを訪問して、"
+"Shaarli をもっとよく知ることができます。\n"
+"\n"
+"今から、既定の shaares を編集したり、削除したりすることができます。\n"
+
+#: application/bookmark/BookmarkInitializer.php:55
+msgid "Note: Shaare descriptions"
+msgstr "説明: Shaare の概要"
+
+#: application/bookmark/BookmarkInitializer.php:57
+msgid ""
+"Adding a shaare without entering a URL creates a text-only \"note\" post "
+"such as this one.\n"
+"This note is private, so you are the only one able to see it while logged "
+"in.\n"
+"\n"
+"You can use this to keep notes, post articles, code snippets, and much "
+"more.\n"
+"\n"
+"The Markdown formatting setting allows you to format your notes and bookmark "
+"description:\n"
+"\n"
+"### Title headings\n"
+"\n"
+"#### Multiple headings levels\n"
+"  * bullet lists\n"
+"  * _italic_ text\n"
+"  * **bold** text\n"
+"  * ~~strike through~~ text\n"
+"  * `code` blocks\n"
+"  * images\n"
+"  * [links](https://en.wikipedia.org/wiki/Markdown)\n"
+"\n"
+"Markdown also supports tables:\n"
+"\n"
+"| Name    | Type      | Color  | Qty   |\n"
+"| ------- | --------- | ------ | ----- |\n"
+"| Orange  | Fruit     | Orange | 126   |\n"
+"| Apple   | Fruit     | Any    | 62    |\n"
+"| Lemon   | Fruit     | Yellow | 30    |\n"
+"| Carrot  | Vegetable | Red    | 14    |\n"
+msgstr ""
+"URL を追加せずに shaare を作成すると、テキストのみのこのような \"ノート\" が"
+"作成されます。\n"
+"このノートはプライベートなので、ログイン中のあなたしか見ることはできませ"
+"ん。\n"
+"\n"
+"あなたはこれをメモ帳として使ったり、記事を投稿したり、コード スニペットとした"
+"りするなどといったことに使えます。\n"
+"\n"
+"Markdown フォーマットの設定により、ノートやブックマークの概要を以下のように"
+"フォーマットできます:\n"
+"\n"
+"### タイトル ヘッダー\n"
+"\n"
+"#### 複数の見出し\n"
+" * 箇条書きリスト\n"
+" * _イタリック_ 文字\n"
+" * **ボールド** 文字\n"
+" * ~~打ち消し~~ 文字\n"
+" * `コード` ブロック\n"
+" * 画像\n"
+" * [リンク](https://en.wikipedia.org/wiki/Markdown)\n"
+"\n"
+"Markdown は表もサポートします:\n"
+"\n"
+"| 名前    | 種類      | 色  | 数量   |\n"
+"| ------- | --------- | ------ | ----- |\n"
+"| オレンジ  | 果物     | 橙 | 126   |\n"
+"| リンゴ   | 果物     | 任意    | 62    |\n"
+"| レモン   | 果物     | 黄 | 30    |\n"
+"| 人参  | 野菜 | 赤    | 14    |\n"
+
+#: application/bookmark/BookmarkInitializer.php:91
+#: application/legacy/LegacyLinkDB.php:246
+msgid ""
+"The personal, minimalist, super-fast, database free, bookmarking service"
+msgstr ""
+"個人向けの、ミニマムで高速でかつデータベースのいらないブックマークサービス"
+
+#: application/bookmark/BookmarkInitializer.php:94
+msgid ""
+"Welcome to Shaarli!\n"
+"\n"
+"Shaarli allows you to bookmark your favorite pages, and share them with "
+"others or store them privately.\n"
+"You can add a description to your bookmarks, such as this one, and tag "
+"them.\n"
+"\n"
+"Create a new shaare by clicking the `+Shaare` button, or using any of the "
+"recommended tools (browser extension, mobile app, bookmarklet, REST API, "
+"etc.).\n"
+"\n"
+"You can easily retrieve your links, even with thousands of them, using the "
+"internal search engine, or search through tags (e.g. this Shaare is tagged "
+"with `shaarli` and `help`).\n"
+"Hashtags such as #shaarli #help are also supported.\n"
+"You can also filter the available [RSS feed](/feed/atom) and picture wall by "
+"tag or plaintext search.\n"
+"\n"
+"We hope that you will enjoy using Shaarli, maintained with ❤️ by the "
+"community!\n"
+"Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if "
+"you have a suggestion or encounter an issue.\n"
+msgstr ""
+"Shaarli へようこそ!\n"
+"\n"
+"Shaarli では、あなたのお気に入りのページをブックマークしたり、それを他の人と"
+"共有するか、またはプライベートなものとして保管することができます。\n"
+"加えて、あなたのブックマークにこの項目のように概要を追加したり、タグ付けした"
+"りすることができます。\n"
+"\n"
+"`+Shaare` ボタンをクリックすることで新しい shaare を作成できます。また、推奨"
+"されたツールを使うこともできます (ブラウザー 拡張機能、モバイル アプリ、ブッ"
+"クマークレット、REST API など...)。\n"
+"\n"
+"また、簡単にあなたのリンクを取得できます。それが何千と登る数であっても、内部"
+"の検索エンジンや、タグを使って検索できます (例えば、この Shaare は `shaarli` "
+"と `help` というタグが付いています)。\n"
+"#shaarli や #help といったハッシュタグもサポートされています。\n"
+"タグやテキスト検索による [RSS フィード](/feed/atom) や ピクチャー ウォール で"
+"項目を絞ることもできます。\n"
+"\n"
+"私たちはあなたが Shaarli を楽しんでくれることを願っています。Shaarli はコミュ"
+"ニティーによって ♡ と共にメンテナンスされています!\n"
+"何か問題に遭遇したり、提案があれば、気軽に  [Issue](https://github.com/"
+"shaarli/Shaarli/issues) を開いてください。\n"
+
+#: application/bookmark/exception/BookmarkNotFoundException.php:13
+msgid "The link you are trying to reach does not exist or has been deleted."
+msgstr "開こうとしたリンクは存在しないか、削除されています。"
+
+#: application/config/ConfigJson.php:52 application/config/ConfigPhp.php:129
+msgid ""
+"Shaarli could not create the config file. Please make sure Shaarli has the "
+"right to write in the folder is it installed in."
+msgstr ""
+"Shaarli は設定ファイルを作成できませんでした。Shaarli が正しい権限下に置かれ"
+"ていて、インストールされているディレクトリに書き込みできることを確認してくだ"
+"さい。"
+
+#: application/config/ConfigManager.php:136
+#: application/config/ConfigManager.php:163
+msgid "Invalid setting key parameter. String expected, got: "
+msgstr ""
+"不正なキーの値です。文字列が想定されていますが、次のように入力されました: "
+
+#: application/config/exception/MissingFieldConfigException.php:21
+#, php-format
+msgid "Configuration value is required for %s"
+msgstr "%s には設定が必要です"
+
+#: application/config/exception/PluginConfigOrderException.php:15
+msgid "An error occurred while trying to save plugins loading order."
+msgstr "プラグインの読込順を変更する際にエラーが発生しました。"
+
+#: application/config/exception/UnauthorizedConfigException.php:16
+msgid "You are not authorized to alter config."
+msgstr "設定を変更する権限がありません。"
+
+#: application/exceptions/IOException.php:22
+msgid "Error accessing"
+msgstr "読込中にエラーが発生しました"
+
+#: application/feed/FeedBuilder.php:179
+msgid "Direct link"
+msgstr "ダイレクトリンク"
+
+#: application/feed/FeedBuilder.php:181
+msgid "Permalink"
+msgstr "パーマリンク"
+
+#: application/front/controller/admin/ConfigureController.php:54
+msgid "Configure"
+msgstr "設定"
+
+#: application/front/controller/admin/ConfigureController.php:102
+#: application/legacy/LegacyUpdater.php:537
+msgid "You have enabled or changed thumbnails mode."
+msgstr "サムネイルのモードを有効化、または変更しました。"
+
+#: application/front/controller/admin/ConfigureController.php:103
+#: application/legacy/LegacyUpdater.php:538
+msgid "Please synchronize them."
+msgstr "それらを同期してください。"
+
+#: application/front/controller/admin/ConfigureController.php:113
+#: application/front/controller/visitor/InstallController.php:136
+msgid "Error while writing config file after configuration update."
+msgstr "設定ファイルを更新した後の書き込みに失敗しました。"
+
+#: application/front/controller/admin/ConfigureController.php:122
+msgid "Configuration was saved."
+msgstr "設定は保存されました。"
+
+#: application/front/controller/admin/ExportController.php:26
+msgid "Export"
+msgstr "エクスポート"
+
+#: application/front/controller/admin/ExportController.php:42
+msgid "Please select an export mode."
+msgstr "エクスポート モードを指定してください。"
+
+#: application/front/controller/admin/ImportController.php:41
+msgid "Import"
+msgstr "インポート"
+
+#: application/front/controller/admin/ImportController.php:55
+msgid "No import file provided."
+msgstr "何のインポート元ファイルも指定されませんでした。"
+
+#: application/front/controller/admin/ImportController.php:66
+#, php-format
+msgid ""
+"The file you are trying to upload is probably bigger than what this "
+"webserver can accept (%s). Please upload in smaller chunks."
+msgstr ""
+"あなたがアップロードしようとしているファイルは、サーバーが許可しているファイ"
+"ルサイズ (%s) よりも大きいです。もう少し小さいものをアップロードしてくださ"
+"い。"
+
+#: application/front/controller/admin/ManageShaareController.php:29
+msgid "Shaare a new link"
+msgstr "新しいリンクを追加"
+
+#: application/front/controller/admin/ManageShaareController.php:78
+msgid "Note: "
+msgstr "注: "
+
+#: application/front/controller/admin/ManageShaareController.php:109
+#: application/front/controller/admin/ManageShaareController.php:206
+#: application/front/controller/admin/ManageShaareController.php:275
+#: application/front/controller/admin/ManageShaareController.php:315
+#, php-format
+msgid "Bookmark with identifier %s could not be found."
+msgstr "%s という識別子を持ったブックマークは見つかりませんでした。"
+
+#: application/front/controller/admin/ManageShaareController.php:194
+#: application/front/controller/admin/ManageShaareController.php:252
+msgid "Invalid bookmark ID provided."
+msgstr "不正なブックマーク ID が入力されました。"
+
+#: application/front/controller/admin/ManageShaareController.php:260
+msgid "Invalid visibility provided."
+msgstr "不正な公開設定が入力されました。"
+
+#: application/front/controller/admin/ManageShaareController.php:363
+msgid "Edit"
+msgstr "共有"
+
+#: application/front/controller/admin/ManageShaareController.php:366
+msgid "Shaare"
+msgstr "Shaare"
+
+#: application/front/controller/admin/ManageTagController.php:29
+msgid "Manage tags"
+msgstr "タグを設定"
+
+#: application/front/controller/admin/ManageTagController.php:48
+msgid "Invalid tags provided."
+msgstr "不正なタグが入力されました。"
+
+#: application/front/controller/admin/ManageTagController.php:72
+#, php-format
+msgid "The tag was removed from %d bookmark."
+msgid_plural "The tag was removed from %d bookmarks."
+msgstr[0] "%d 件のリンクからタグが削除されました。"
+msgstr[1] "%d 件のリンクからタグが削除されました。"
+
+#: application/front/controller/admin/ManageTagController.php:77
+#, php-format
+msgid "The tag was renamed in %d bookmark."
+msgid_plural "The tag was renamed in %d bookmarks."
+msgstr[0] "このタグを持つ %d 件のリンクにおいて、名前が変更されました。"
+msgstr[1] "このタグを持つ %d 件のリンクにおいて、名前が変更されました。"
+
+#: application/front/controller/admin/PasswordController.php:28
+msgid "Change password"
+msgstr "パスワードを変更"
+
+#: application/front/controller/admin/PasswordController.php:55
+msgid "You must provide the current and new password to change it."
+msgstr ""
+"パスワードを変更するには、現在のパスワードと、新しいパスワードを入力する必要"
+"があります。"
+
+#: application/front/controller/admin/PasswordController.php:71
+msgid "The old password is not correct."
+msgstr "元のパスワードが正しくありません。"
+
+#: application/front/controller/admin/PasswordController.php:97
+msgid "Your password has been changed"
+msgstr "あなたのパスワードは変更されました"
+
+#: application/front/controller/admin/PluginsController.php:45
+msgid "Plugin Administration"
+msgstr "プラグイン管理"
+
+#: application/front/controller/admin/PluginsController.php:76
+msgid "Setting successfully saved."
+msgstr "設定が正常に保存されました。"
+
+#: application/front/controller/admin/PluginsController.php:79
+msgid "Error while saving plugin configuration: "
+msgstr "プラグインの設定ファイルを保存するときにエラーが発生しました: "
+
+#: application/front/controller/admin/ThumbnailsController.php:37
+msgid "Thumbnails update"
+msgstr "サムネイルの更新"
+
+#: application/front/controller/admin/ToolsController.php:31
+msgid "Tools"
+msgstr "ツール"
+
+#: application/front/controller/visitor/BookmarkListController.php:116
+msgid "Search: "
+msgstr "検索: "
+
+#: application/front/controller/visitor/DailyController.php:45
+msgid "Today"
+msgstr "今日"
+
+#: application/front/controller/visitor/DailyController.php:47
+msgid "Yesterday"
+msgstr "昨日"
+
+#: application/front/controller/visitor/DailyController.php:85
+msgid "Daily"
+msgstr "デイリー"
+
+#: application/front/controller/visitor/ErrorController.php:36
+msgid "An unexpected error occurred."
+msgstr "予期しないエラーが発生しました。"
+
+#: application/front/controller/visitor/ErrorNotFoundController.php:25
+msgid "Requested page could not be found."
+msgstr "リクエストされたページは存在しません。"
+
+#: application/front/controller/visitor/InstallController.php:73
+#, php-format
+msgid ""
+"<pre>Sessions do not seem to work correctly on your server.<br>Make sure the "
+"variable \"session.save_path\" is set correctly in your PHP config, and that "
+"you have write access to it.<br>It currently points to %s.<br>On some "
+"browsers, accessing your server via a hostname like 'localhost' or any "
+"custom hostname without a dot causes cookie storage to fail. We recommend "
+"accessing your server via it's IP address or Fully Qualified Domain Name.<br>"
+msgstr ""
+"<pre>セッションが正常にあなたのサーバー上で稼働していないようです。<br>PHP の"
+"設定ファイル内にて、正しく \"session.save_path\" の値が設定されていることと、"
+"権限が間違っていないことを確認してください。<br>現在 %s からPHPの設定ファイル"
+"を読み込んでいます。<br>一部のブラウザーにおいて、localhost や他のドットを含"
+"まないホスト名にてサーバーにアクセスする際に、クッキーを保存できないことがあ"
+"ります。IP アドレスや完全なドメイン名でサーバーにアクセスすることをおすすめし"
+"ます。<br>"
+
+#: application/front/controller/visitor/InstallController.php:144
+msgid ""
+"Shaarli is now configured. Please login and start shaaring your bookmarks!"
+msgstr ""
+"Shaarli の設定が完了しました。ログインして、あなたのブックマークを登録しま"
+"しょう!"
+
+#: application/front/controller/visitor/InstallController.php:158
+msgid "Insufficient permissions:"
+msgstr "権限がありません:"
+
+#: application/front/controller/visitor/LoginController.php:46
+msgid "Login"
+msgstr "ログイン"
+
+#: application/front/controller/visitor/LoginController.php:78
+msgid "Wrong login/password."
+msgstr "不正なユーザー名、またはパスワードです。"
+
+#: application/front/controller/visitor/PictureWallController.php:29
+msgid "Picture wall"
+msgstr "ピクチャウォール"
+
+#: application/front/controller/visitor/TagCloudController.php:88
+msgid "Tag "
+msgstr "タグ "
+
+#: application/front/exceptions/AlreadyInstalledException.php:11
+msgid "Shaarli has already been installed. Login to edit the configuration."
+msgstr "Shaarli がインストールされました。ログインして設定を変更できます。"
+
+#: application/front/exceptions/LoginBannedException.php:11
+msgid ""
+"You have been banned after too many failed login attempts. Try again later."
+msgstr "複数回に渡るログインへの失敗を検出しました。後でまた試してください。"
+
+#: application/front/exceptions/OpenShaarliPasswordException.php:16
+msgid "You are not supposed to change a password on an Open Shaarli."
+msgstr ""
+"公開されている Shaarli において、パスワードを変更することは想定されていませ"
+"ん。"
+
+#: application/front/exceptions/ThumbnailsDisabledException.php:11
+msgid "Picture wall unavailable (thumbnails are disabled)."
+msgstr "ピクチャ ウォールは利用できません (サムネイルが無効化されています)。"
+
+#: application/front/exceptions/WrongTokenException.php:16
+msgid "Wrong token."
+msgstr "不正なトークンです。"
+
+#: application/legacy/LegacyLinkDB.php:131
+msgid "You are not authorized to add a link."
+msgstr "リンクを追加するには、ログインする必要があります。"
+
+#: application/legacy/LegacyLinkDB.php:134
+msgid "Internal Error: A link should always have an id and URL."
+msgstr "エラー: リンクにはIDとURLを登録しなければなりません。"
+
+#: application/legacy/LegacyLinkDB.php:137
+msgid "You must specify an integer as a key."
+msgstr "正常なキーの値ではありません。"
+
+#: application/legacy/LegacyLinkDB.php:140
+msgid "Array offset and link ID must be equal."
+msgstr "Array オフセットとリンクのIDは同じでなければなりません。"
+
+#: application/legacy/LegacyLinkDB.php:249
+msgid ""
+"Welcome to Shaarli! This is your first public bookmark. To edit or delete "
+"me, you must first login.\n"
+"\n"
+"To learn how to use Shaarli, consult the link \"Documentation\" at the "
+"bottom of this page.\n"
+"\n"
+"You use the community supported version of the original Shaarli project, by "
+"Sebastien Sauvage."
+msgstr ""
+"Shaarli へようこそ! これはあなたの最初の公開ブックマークです。これを編集した"
+"り削除したりするには、ログインする必要があります。\n"
+"\n"
+"Shaarli の使い方を知るには、このページの下にある「ドキュメント」のリンクを開"
+"いてください。\n"
+"\n"
+"あなたは Sebastien Sauvage による、コミュニティーサポートのあるバージョンのオ"
+"リジナルのShaarli プロジェクトを使用しています。"
+
+#: application/legacy/LegacyLinkDB.php:266
+msgid "My secret stuff... - Pastebin.com"
+msgstr "わたしのひ💗み💗つ💗 - Pastebin.com"
+
+#: application/legacy/LegacyLinkDB.php:268
+msgid "Shhhh! I'm a private link only YOU can see. You can delete me too."
+msgstr ""
+"シーッ! これはあなたしか見られないプライベートリンクです。消すこともできま"
+"す。"
+
+#: application/legacy/LegacyUpdater.php:104
+#, fuzzy
+#| msgid "Couldn't retrieve Updater class methods."
+msgid "Couldn't retrieve updater class methods."
+msgstr "アップデーターのクラスメゾットを受信できませんでした。"
+
+#: application/legacy/LegacyUpdater.php:538
+msgid "<a href=\"./admin/thumbnails\">"
+msgstr "<a href=\"./admin/thumbnails\">"
+
+#: application/netscape/NetscapeBookmarkUtils.php:63
+msgid "Invalid export selection:"
+msgstr "不正なエクスポートの選択:"
+
+#: application/netscape/NetscapeBookmarkUtils.php:215
+#, php-format
+msgid "File %s (%d bytes) "
+msgstr "ファイル %s (%d バイト) "
+
+#: application/netscape/NetscapeBookmarkUtils.php:217
+msgid "has an unknown file format. Nothing was imported."
+msgstr "は不明なファイル形式です。インポートは中止されました。"
+
+#: application/netscape/NetscapeBookmarkUtils.php:221
+#, fuzzy, php-format
+#| msgid ""
+#| "was successfully processed in %d seconds: %d links imported, %d links "
+#| "overwritten, %d links skipped."
+msgid ""
+"was successfully processed in %d seconds: %d bookmarks imported, %d "
+"bookmarks overwritten, %d bookmarks skipped."
+msgstr ""
+"が %d 秒で処理され、%d 件のリンクがインポートされ、%d 件のリンクが上書きさ"
+"れ、%d 件のリンクがスキップされました。"
+
+#: application/plugin/PluginManager.php:124
+msgid " [plugin incompatibility]: "
+msgstr "[非対応のプラグイン]: "
+
+#: application/plugin/exception/PluginFileNotFoundException.php:21
+#, php-format
+msgid "Plugin \"%s\" files not found."
+msgstr "プラグイン「%s」のファイルが存在しません。"
+
+#: application/render/PageCacheManager.php:32
+#, php-format
+msgid "Cannot purge %s: no directory"
+msgstr "%s を削除できません: ディレクトリが存在しません"
+
+#: application/updater/exception/UpdaterException.php:51
+msgid "An error occurred while running the update "
+msgstr "更新中に問題が発生しました "
+
+#: index.php:65
+msgid "Shared bookmarks on "
+msgstr "次において共有されたリンク "
+
+#: plugins/addlink_toolbar/addlink_toolbar.php:31
+msgid "URI"
+msgstr "URI"
+
+#: plugins/addlink_toolbar/addlink_toolbar.php:35
+msgid "Add link"
+msgstr "リンクを追加"
+
+#: plugins/addlink_toolbar/addlink_toolbar.php:52
+msgid "Adds the addlink input on the linklist page."
+msgstr "リンク一覧のページに、リンクを追加するためのフォームを表示する。"
+
+#: plugins/archiveorg/archiveorg.php:28
+msgid "View on archive.org"
+msgstr "archive.org 上で表示する"
+
+#: plugins/archiveorg/archiveorg.php:41
+msgid "For each link, add an Archive.org icon."
+msgstr "それぞれのリンクに、Archive.org のアイコンを追加する。"
+
+#: plugins/default_colors/default_colors.php:38
+msgid ""
+"Default colors plugin error: This plugin is active and no custom color is "
+"configured."
+msgstr ""
+"既定の色のプラグインにおけるエラー: このプラグインは有効なので、カスタム カ"
+"ラーは適用されません。"
+
+#: plugins/default_colors/default_colors.php:113
+msgid "Override default theme colors. Use any CSS valid color."
+msgstr ""
+"既定のテーマの色を上書きします。どのような CSS カラーコードでも使えます。"
+
+#: plugins/default_colors/default_colors.php:114
+msgid "Main color (navbar green)"
+msgstr "メイン カラー (ナビバーの緑)"
+
+#: plugins/default_colors/default_colors.php:115
+msgid "Background color (light grey)"
+msgstr "背景色 (灰色)"
+
+#: plugins/default_colors/default_colors.php:116
+msgid "Dark main color (e.g. visited links)"
+msgstr "暗い方の メイン カラー (例: 閲覧済みリンク)"
+
+#: plugins/demo_plugin/demo_plugin.php:477
+msgid ""
+"A demo plugin covering all use cases for template designers and plugin "
+"developers."
+msgstr ""
+"テンプレートのデザイナーや、プラグインの開発者のためのすべての状況に対応でき"
+"るデモプラグインです。"
+
+#: plugins/demo_plugin/demo_plugin.php:478
+msgid "This is a parameter dedicated to the demo plugin. It'll be suffixed."
+msgstr "これはデモプラグイン専用のパラメーターです。末尾に追加されます。"
+
+#: plugins/demo_plugin/demo_plugin.php:479
+msgid "Other demo parameter"
+msgstr "他のデモ パラメーター"
+
+#: plugins/isso/isso.php:22
+msgid ""
+"Isso plugin error: Please define the \"ISSO_SERVER\" setting in the plugin "
+"administration page."
+msgstr ""
+"Isso プラグインエラー: \"ISSO_SERVER\" の値をプラグイン管理ページにて指定して"
+"ください。"
+
+#: plugins/isso/isso.php:92
+msgid "Let visitor comment your shaares on permalinks with Isso."
+msgstr ""
+"Isso を使って、あなたのパーマリンク上のリンクに第三者がコメントを残すことがで"
+"きます。"
+
+#: plugins/isso/isso.php:93
+msgid "Isso server URL (without 'http://')"
+msgstr "Isso server URL ('http://' 抜き)"
+
+#: plugins/piwik/piwik.php:23
+msgid ""
+"Piwik plugin error: Please define PIWIK_URL and PIWIK_SITEID in the plugin "
+"administration page."
+msgstr ""
+"Piwik プラグインエラー: PIWIK_URL と PIWIK_SITEID の値をプラグイン管理ページ"
+"で指定してください。"
+
+#: plugins/piwik/piwik.php:72
+msgid "A plugin that adds Piwik tracking code to Shaarli pages."
+msgstr "Piwik のトラッキングコードをShaarliに追加するプラグインです。"
+
+#: plugins/piwik/piwik.php:73
+msgid "Piwik URL"
+msgstr "Piwik URL"
+
+#: plugins/piwik/piwik.php:74
+msgid "Piwik site ID"
+msgstr "Piwik サイトID"
+
+#: plugins/playvideos/playvideos.php:25
+msgid "Video player"
+msgstr "動画プレイヤー"
+
+#: plugins/playvideos/playvideos.php:28
+msgid "Play Videos"
+msgstr "動画を再生"
+
+#: plugins/playvideos/playvideos.php:59
+msgid "Add a button in the toolbar allowing to watch all videos."
+msgstr "すべての動画を閲覧するボタンをツールバーに追加します。"
+
+#: plugins/playvideos/youtube_playlist.js:214
+msgid "plugins/playvideos/jquery-1.11.2.min.js"
+msgstr "plugins/playvideos/jquery-1.11.2.min.js"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:72
+#, php-format
+msgid "Could not publish to PubSubHubbub: %s"
+msgstr "PubSubHubbub に登録できませんでした: %s"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:99
+#, php-format
+msgid "Could not post to %s"
+msgstr "%s に登録できませんでした"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:103
+#, php-format
+msgid "Bad response from the hub %s"
+msgstr "ハブ %s からの不正なレスポンス"
+
+#: plugins/pubsubhubbub/pubsubhubbub.php:114
+msgid "Enable PubSubHubbub feed publishing."
+msgstr "PubSubHubbub へのフィードを公開する。"
+
+#: plugins/qrcode/qrcode.php:73 plugins/wallabag/wallabag.php:70
+msgid "For each link, add a QRCode icon."
+msgstr "それぞれのリンクについて、QRコードのアイコンを追加する。"
+
+#: plugins/wallabag/wallabag.php:21
+msgid ""
+"Wallabag plugin error: Please define the \"WALLABAG_URL\" setting in the "
+"plugin administration page."
+msgstr ""
+"Wallabag プラグインエラー: \"WALLABAG_URL\" の値をプラグイン管理ページにおい"
+"て指定してください。"
+
+#: plugins/wallabag/wallabag.php:47
+msgid "Save to wallabag"
+msgstr "Wallabag に保存"
+
+#: plugins/wallabag/wallabag.php:71
+msgid "Wallabag API URL"
+msgstr "Wallabag のAPIのURL"
+
+#: plugins/wallabag/wallabag.php:72
+msgid "Wallabag API version (1 or 2)"
+msgstr "Wallabag のAPIのバージョン (1 または 2)"
+
+#: tests/LanguagesTest.php:214 tests/LanguagesTest.php:227
+#: tests/languages/fr/LanguagesFrTest.php:159
+#: tests/languages/fr/LanguagesFrTest.php:172
+msgid "Search"
+msgid_plural "Search"
+msgstr[0] "検索"
+msgstr[1] "検索"
+
+#~ msgid "The page you are trying to reach does not exist or has been deleted."
+#~ msgstr "あなたが開こうとしたページは存在しないか、削除されています。"
+
+#~ msgid "404 Not Found"
+#~ msgstr "404 ページが存在しません"
+
+#~ msgid "Updates file path is not set, can't write updates."
+#~ msgstr ""
+#~ "更新するファイルのパスが指定されていないため、更新を書き込めません。"
+
+#~ msgid "Unable to write updates in "
+#~ msgstr "更新を次の項目に書き込めませんでした: "
+
+#~ msgid "I said: NO. You are banned for the moment. Go away."
+#~ msgstr "あなたはこのサーバーからBANされています。"
+
+#~ msgid "Tag cloud"
+#~ msgstr "タグクラウド"
+
+#~ msgid "Click to try again."
+#~ msgstr "クリックして再度試します。"
+
+#~ msgid "Description will be rendered with"
+#~ msgstr "説明は次の方法で描画されます:"
+
+#~ msgid "Markdown syntax documentation"
+#~ msgstr "マークダウン形式のドキュメント"
+
+#~ msgid "Markdown syntax"
+#~ msgstr "マークダウン形式"
+
+#~ msgid ""
+#~ "Render shaare description with Markdown syntax.<br><strong>Warning</"
+#~ "strong>:\n"
+#~ "If your shaared descriptions contained HTML tags before enabling the "
+#~ "markdown plugin,\n"
+#~ "enabling it might break your page.\n"
+#~ "See the <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a>."
+#~ msgstr ""
+#~ "リンクの説明をマークダウン形式で表示します。<br><strong>警告</strong>:\n"
+#~ "リンクの説明にHTMLタグがこのプラグインを有効にする前に含まれていた場合、\n"
+#~ "正常にページを表示できなくなるかもしれません。\n"
+#~ "詳しくは <a href=\"https://github.com/shaarli/Shaarli/tree/master/plugins/"
+#~ "markdown#html-rendering\">README</a> をご覧ください。"
+
+#~ msgid "Sorry, nothing to see here."
+#~ msgstr "すみませんが、ここには何もありません。"
+
+#~ msgid "URL or leave empty to post a note"
+#~ msgstr "URL を入力するか、空欄にするとノートを投稿します"
+
+#~ msgid "Current password"
+#~ msgstr "現在のパスワード"
+
+#~ msgid "New password"
+#~ msgstr "新しいパスワード"
+
+#~ msgid "Change"
+#~ msgstr "変更"
+
+#~ msgid "Tag"
+#~ msgstr "タグ"
+
+#~ msgid "New name"
+#~ msgstr "変更先の名前"
+
+#~ msgid "Case sensitive"
+#~ msgstr "大文字と小文字を区別"
+
+#~ msgid "Rename"
+#~ msgstr "名前を変更"
+
+#~ msgid "Delete"
+#~ msgstr "削除"
+
+#~ msgid "You can also edit tags in the"
+#~ msgstr "次に含まれるタグを編集することもできます:"
+
+#~ msgid "tag list"
+#~ msgstr "タグ一覧"
+
+#~ msgid "title"
+#~ msgstr "タイトル"
+
+#~ msgid "Home link"
+#~ msgstr "ホームのリンク先"
+
+#~ msgid "Default value"
+#~ msgstr "既定の値"
+
+#~ msgid "Theme"
+#~ msgstr "テーマ"
+
+#~ msgid "Language"
+#~ msgstr "言語"
+
+#~ msgid "Timezone"
+#~ msgstr "タイムゾーン"
+
+#~ msgid "Continent"
+#~ msgstr "大陸"
+
+#~ msgid "City"
+#~ msgstr "町"
+
+#~ msgid "Disable session cookie hijacking protection"
+#~ msgstr "不正ログイン防止のためのセッションクッキーを無効化"
+
+#~ msgid ""
+#~ "Check this if you get disconnected or if your IP address changes often"
+#~ msgstr ""
+#~ "あなたが切断されたり、IPアドレスが頻繁に変わる環境下であるならチェックを入"
+#~ "れてください"
+
+#~ msgid "Private links by default"
+#~ msgstr "既定でプライベートリンク"
+
+#~ msgid "All new links are private by default"
+#~ msgstr "すべての新規リンクをプライベートで作成"
+
+#~ msgid "RSS direct links"
+#~ msgstr "RSS 直リンク"
+
+#~ msgid "Check this to use direct URL instead of permalink in feeds"
+#~ msgstr "フィードでパーマリンクの代わりに直リンクを使う"
+
+#~ msgid "Hide public links"
+#~ msgstr "公開リンクを隠す"
+
+#~ msgid "Do not show any links if the user is not logged in"
+#~ msgstr "ログインしていないユーザーには何のリンクも表示しない"
+
+#~ msgid "Check updates"
+#~ msgstr "更新を確認"
+
+#~ msgid "Notify me when a new release is ready"
+#~ msgstr "新しいバージョンがリリースされたときに通知"
+
+#~ msgid "Enable REST API"
+#~ msgstr "REST API を有効化"
+
+#~ msgid "Allow third party software to use Shaarli such as mobile application"
+#~ msgstr ""
+#~ "モバイルアプリといったサードパーティーのソフトウェアにShaarliを使用するこ"
+#~ "とを許可"
+
+#~ msgid "API secret"
+#~ msgstr "API シークレット"
+
+#~ msgid "Save"
+#~ msgstr "保存"
+
+#~ msgid "The Daily Shaarli"
+#~ msgstr "デイリーSharli"
+
+#~ msgid "1 RSS entry per day"
+#~ msgstr "各日1つずつのRSS項目"
+
+#~ msgid "Previous day"
+#~ msgstr "前日"
+
+#~ msgid "All links of one day in a single page."
+#~ msgstr "1日に作成されたすべてのリンクです。"
+
+#~ msgid "Next day"
+#~ msgstr "翌日"
+
+#~ msgid "Created:"
+#~ msgstr "作成:"
+
+#~ msgid "URL"
+#~ msgstr "URL"
+
+#~ msgid "Title"
+#~ msgstr "タイトル"
+
+#~ msgid "Description"
+#~ msgstr "説明"
+
+#~ msgid "Tags"
+#~ msgstr "タグ"
+
+#~ msgid "Private"
+#~ msgstr "プライベート"
+
+#~ msgid "Apply Changes"
+#~ msgstr "変更を適用"
+
+#~ msgid "Export Database"
+#~ msgstr "データベースをエクスポート"
+
+#~ msgid "Selection"
+#~ msgstr "選択済み"
+
+#~ msgid "All"
+#~ msgstr "すべて"
+
+#~ msgid "Public"
+#~ msgstr "公開"
+
+#~ msgid "Prepend note permalinks with this Shaarli instance's URL"
+#~ msgstr ""
+#~ "この Shaarli のインスタンスのURL にノートへのパーマリンクを付け加える"
+
+#~ msgid "Useful to import bookmarks in a web browser"
+#~ msgstr "ウェブブラウザーのリンクをインポートするのに有効です"
+
+#~ msgid "Import Database"
+#~ msgstr "データベースをインポート"
+
+#~ msgid "Maximum size allowed:"
+#~ msgstr "最大サイズ:"
+
+#~ msgid "Visibility"
+#~ msgstr "可視性"
+
+#~ msgid "Use values from the imported file, default to public"
+#~ msgstr "インポート元のファイルの値を使用 (既定は公開リンクとなります)"
+
+#~ msgid "Import all bookmarks as public"
+#~ msgstr "すべてのブックマーク項目を公開リンクとしてインポート"
+
+#~ msgid "Overwrite existing bookmarks"
+#~ msgstr "既に存在しているブックマークを上書き"
+
+#~ msgid "Duplicates based on URL"
+#~ msgstr "URL による重複"
+
+#~ msgid "Add default tags"
+#~ msgstr "既定のタグを追加"
+
+#~ msgid "Install Shaarli"
+#~ msgstr "Shaarli をインストール"
+
+#~ msgid ""
+#~ "It looks like it's the first time you run Shaarli. Please configure it."
+#~ msgstr "どうやら Shaarli を初めて起動しているようです。設定してください。"
+
+#~ msgid "Username"
+#~ msgstr "ユーザー名"
+
+#~ msgid "Password"
+#~ msgstr "パスワード"
+
+#~ msgid "Shaarli title"
+#~ msgstr "Shaarli のタイトル"
+
+#~ msgid "My links"
+#~ msgstr "自分のリンク"
+
+#~ msgid "Install"
+#~ msgstr "インストール"
+
+#~ msgid "shaare"
+#~ msgid_plural "shaares"
+#~ msgstr[0] "共有"
+#~ msgstr[1] "共有"
+
+#~ msgid "private link"
+#~ msgid_plural "private links"
+#~ msgstr[0] "プライベートリンク"
+#~ msgstr[1] "プライベートリンク"
+
+#~ msgid "Search text"
+#~ msgstr "文字列で検索"
+
+#~ msgid "Filter by tag"
+#~ msgstr "タグによって分類"
+
+#~ msgid "Nothing found."
+#~ msgstr "何も見つかりませんでした。"
+
+#~ msgid "%s result"
+#~ msgid_plural "%s results"
+#~ msgstr[0] "%s 件の結果"
+#~ msgstr[1] "%s 件の結果"
+
+#~ msgid "for"
+#~ msgstr "for"
+
+#~ msgid "tagged"
+#~ msgstr "タグ付けされた"
+
+#~ msgid "Remove tag"
+#~ msgstr "タグを削除"
+
+#~ msgid "with status"
+#~ msgstr "with status"
+
+#~ msgid "without any tag"
+#~ msgstr "タグなし"
+
+#~ msgid "Fold"
+#~ msgstr "畳む"
+
+#~ msgid "Edited: "
+#~ msgstr "編集済み: "
+
+#~ msgid "permalink"
+#~ msgstr "パーマリンク"
+
+#~ msgid "Add tag"
+#~ msgstr "タグを追加"
+
+#~ msgid "Filters"
+#~ msgstr "分類"
+
+#~ msgid "Only display private links"
+#~ msgstr "プライベートリンクのみを表示"
+
+#~ msgid "Only display public links"
+#~ msgstr "公開リンクのみを表示"
+
+#~ msgid "Filter untagged links"
+#~ msgstr "タグ付けされていないリンクで分類"
+
+#~ msgid "Fold all"
+#~ msgstr "すべて畳む"
+
+#~ msgid "Links per page"
+#~ msgstr "各ページをリンク"
+
+#~ msgid "Remember me"
+#~ msgstr "パスワードを保存"
+
+#~ msgid "by the Shaarli community"
+#~ msgstr "by Shaarli コミュニティ"
+
+#~ msgid "Documentation"
+#~ msgstr "ドキュメント"
+
+#~ msgid "Expand"
+#~ msgstr "展開する"
+
+#~ msgid "Expand all"
+#~ msgstr "すべて展開する"
+
+#~ msgid "Are you sure you want to delete this link?"
+#~ msgstr "本当にこのリンクを削除しますか?"
+
+#~ msgid "RSS Feed"
+#~ msgstr "RSS フィード"
+
+#~ msgid "Logout"
+#~ msgstr "ログアウト"
+
+#~ msgid "is available"
+#~ msgstr "が利用可能"
+
+#~ msgid "Error"
+#~ msgstr "エラー"
+
+#~ msgid "Picture Wall"
+#~ msgstr "ピクチャーウォール"
+
+#~ msgid "pics"
+#~ msgstr "画像"
+
+#~ msgid "You need to enable Javascript to change plugin loading order."
+#~ msgstr ""
+#~ "プラグインを読み込む順番を変更するには、Javascriptを有効にする必要がありま"
+#~ "す。"
+
+#~ msgid "Enabled Plugins"
+#~ msgstr "有効なプラグイン"
+
+#~ msgid "No plugin enabled."
+#~ msgstr "有効なプラグインはありません。"
+
+#~ msgid "Disable"
+#~ msgstr "無効化"
+
+#~ msgid "Name"
+#~ msgstr "名前"
+
+#~ msgid "Order"
+#~ msgstr "順序"
+
+#~ msgid "Disabled Plugins"
+#~ msgstr "無効なプラグイン"
+
+#~ msgid "No plugin disabled."
+#~ msgstr "無効なプラグインはありません。"
+
+#~ msgid "Enable"
+#~ msgstr "有効化"
+
+#~ msgid "More plugins available"
+#~ msgstr "さらに利用できるプラグインがあります"
+
+#~ msgid "in the documentation"
+#~ msgstr "ドキュメント内"
+
+#~ msgid "No parameter available."
+#~ msgstr "利用可能な設定項目はありません。"
+
+#~ msgid "tags"
+#~ msgstr "タグ"
+
+#~ msgid "List all links with those tags"
+#~ msgstr "このタグが付いているリンクをリスト化する"
+
+#~ msgid "Sort by:"
+#~ msgstr "分類:"
+
+#~ msgid "Cloud"
+#~ msgstr "クラウド"
+
+#~ msgid "Most used"
+#~ msgstr "もっとも使われた"
+
+#~ msgid "Alphabetical"
+#~ msgstr "アルファベット順"
+
+#~ msgid "Settings"
+#~ msgstr "設定"
+
+#~ msgid "Change Shaarli settings: title, timezone, etc."
+#~ msgstr "Shaarli の設定を変更: タイトル、タイムゾーンなど。"
+
+#~ msgid "Configure your Shaarli"
+#~ msgstr "あなたの Shaarli を設定"
+
+#~ msgid "Enable, disable and configure plugins"
+#~ msgstr "プラグインを有効化、無効化、設定する"
+
+#~ msgid "Change your password"
+#~ msgstr "パスワードを変更"
+
+#~ msgid "Rename or delete a tag in all links"
+#~ msgstr "すべてのリンクのタグの名前を変更する、または削除する"
+
+#~ msgid ""
+#~ "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
+#~ "delicious...)"
+#~ msgstr ""
+#~ "Netscape HTML 形式のブックマークをインポートする (Firefox、Chrome、Operaと"
+#~ "いったブラウザーが含まれます)"
+
+#~ msgid "Import links"
+#~ msgstr "リンクをインポート"
+
+#~ msgid ""
+#~ "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
+#~ "Opera, delicious...)"
+#~ msgstr ""
+#~ "Netscape HTML 形式のブックマークをエクスポートする (Firefox、Chrome、Opera"
+#~ "といったブラウザーが含まれます)"
+
+#~ msgid "Export database"
+#~ msgstr "リンクをエクスポート"
+
+#~ msgid ""
+#~ "Drag one of these button to your bookmarks toolbar or right-click it and "
+#~ "\"Bookmark This Link\""
+#~ msgstr ""
+#~ "これらのボタンのうち1つををブックマークバーにドラッグするか、右クリックし"
+#~ "て「このリンクをブックマークに追加」してください"
+
+#~ msgid "then click on the bookmarklet in any page you want to share."
+#~ msgstr "共有したいページでブックマークレットをクリックしてください。"
+
+#~ msgid ""
+#~ "Drag this link to your bookmarks toolbar or right-click it and Bookmark "
+#~ "This Link"
+#~ msgstr ""
+#~ "このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
+#~ "ブックマークに追加」してください"
+
+#~ msgid "then click ✚Shaare link button in any page you want to share"
+#~ msgstr ""
+#~ "✚リンクを共有 ボタンをクリックすることで、どこでもリンクを共有できます"
+
+#~ msgid "The selected text is too long, it will be truncated."
+#~ msgstr "選択された文字列は長すぎるので、一部が切り捨てられます。"
+
+#~ msgid "Shaare link"
+#~ msgstr "共有リンク"
+
+#~ msgid ""
+#~ "Then click ✚Add Note button anytime to start composing a private Note "
+#~ "(text post) to your Shaarli"
+#~ msgstr ""
+#~ "✚ノートを追加 ボタンをクリックすることで、いつでもプライベートノート(テキ"
+#~ "スト形式)をShaarli上に作成できます"
+
+#~ msgid "Add Note"
+#~ msgstr "ノートを追加"
+
+#~ msgid ""
+#~ "You need to browse your Shaarli over <strong>HTTPS</strong> to use this "
+#~ "functionality."
+#~ msgstr ""
+#~ "この機能を使用するには、<strong>HTTPS</strong> 経由でShaarliに接続してくだ"
+#~ "さい。"
+
+#~ msgid "Add to"
+#~ msgstr "次に追加:"
+
+#~ msgid "3rd party"
+#~ msgstr "サードパーティー"
+
+#~ msgid "Plugin"
+#~ msgstr "プラグイン"
+
+#~ msgid "plugin"
+#~ msgstr "プラグイン"
+
+#~ msgid ""
+#~ "Drag this link to your bookmarks toolbar, or right-click it and choose "
+#~ "Bookmark This Link"
+#~ msgstr ""
+#~ "このリンクをブックマークバーにドラッグするか、右クリックして「このリンクを"
+#~ "ブックマークに追加」してください"
index b53b16fefb383400f90eb1b99374b24c907daf68..4b5602ac8b364c10a73890a9f342a8165197290a 100644 (file)
--- a/index.php
+++ b/index.php
  * Licence: http://www.opensource.org/licenses/zlib-license.php
  */
 
-// Set 'UTC' as the default timezone if it is not defined in php.ini
-// See http://php.net/manual/en/datetime.configuration.php#ini.date.timezone
-if (date_default_timezone_get() == '') {
-    date_default_timezone_set('UTC');
-}
-
-/*
- * PHP configuration
- */
-
-// http://server.com/x/shaarli --> /shaarli/
-define('WEB_PATH', substr($_SERVER['REQUEST_URI'], 0, 1+strrpos($_SERVER['REQUEST_URI'], '/', 0)));
-
-// High execution time in case of problematic imports/exports.
-ini_set('max_input_time', '60');
-
-// Try to set max upload file size and read
-ini_set('memory_limit', '128M');
-ini_set('post_max_size', '16M');
-ini_set('upload_max_filesize', '16M');
-
-// See all error except warnings
-error_reporting(E_ALL^E_WARNING);
-
-// 3rd-party libraries
-if (! file_exists(__DIR__ . '/vendor/autoload.php')) {
-    header('Content-Type: text/plain; charset=utf-8');
-    echo "Error: missing Composer configuration\n\n"
-        ."If you installed Shaarli through Git or using the development branch,\n"
-        ."please refer to the installation documentation to install PHP"
-        ." dependencies using Composer:\n"
-        ."- https://shaarli.readthedocs.io/en/master/Server-configuration/\n"
-        ."- https://shaarli.readthedocs.io/en/master/Download-and-Installation/";
-    exit;
-}
 require_once 'inc/rain.tpl.class.php';
 require_once __DIR__ . '/vendor/autoload.php';
 
 // Shaarli library
 require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/config/ConfigPlugin.php';
-require_once 'application/feed/Cache.php';
 require_once 'application/http/HttpUtils.php';
 require_once 'application/http/UrlUtils.php';
-require_once 'application/updater/UpdaterUtils.php';
-require_once 'application/FileUtils.php';
 require_once 'application/TimeZone.php';
 require_once 'application/Utils.php';
 
-use Shaarli\ApplicationUtils;
-use Shaarli\Bookmark\Bookmark;
-use Shaarli\Bookmark\BookmarkFileService;
-use Shaarli\Bookmark\BookmarkFilter;
-use Shaarli\Bookmark\BookmarkServiceInterface;
-use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+require_once __DIR__ . '/init.php';
+
+use Katzgrau\KLogger\Logger;
+use Psr\Log\LogLevel;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Container\ContainerBuilder;
-use Shaarli\Feed\CachedPage;
-use Shaarli\Feed\FeedBuilder;
-use Shaarli\Formatter\BookmarkMarkdownFormatter;
-use Shaarli\Formatter\FormatterFactory;
-use Shaarli\History;
 use Shaarli\Languages;
-use Shaarli\Netscape\NetscapeBookmarkUtils;
-use Shaarli\Plugin\PluginManager;
-use Shaarli\Render\PageBuilder;
-use Shaarli\Render\ThemeUtils;
-use Shaarli\Router;
+use Shaarli\Security\BanManager;
+use Shaarli\Security\CookieManager;
 use Shaarli\Security\LoginManager;
 use Shaarli\Security\SessionManager;
-use Shaarli\Thumbnailer;
-use Shaarli\Updater\Updater;
-use Shaarli\Updater\UpdaterUtils;
 use Slim\App;
 
-// Ensure the PHP version is supported
-try {
-    ApplicationUtils::checkPHPVersion('7.1', PHP_VERSION);
-} catch (Exception $exc) {
-    header('Content-Type: text/plain; charset=utf-8');
-    echo $exc->getMessage();
-    exit;
-}
-
-define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
-
-// Force cookie path (but do not change lifetime)
-$cookie = session_get_cookie_params();
-$cookiedir = '';
-if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
-    $cookiedir = dirname($_SERVER["SCRIPT_NAME"]).'/';
-}
-// Set default cookie expiration and path.
-session_set_cookie_params($cookie['lifetime'], $cookiedir, $_SERVER['SERVER_NAME']);
-// Set session parameters on server side.
-// Use cookies to store session.
-ini_set('session.use_cookies', 1);
-// Force cookies for session (phpsessionID forbidden in URL).
-ini_set('session.use_only_cookies', 1);
-// Prevent PHP form using sessionID in URL if cookies are disabled.
-ini_set('session.use_trans_sid', false);
-
-session_name('shaarli');
-// Start session if needed (Some server auto-start sessions).
-if (session_status() == PHP_SESSION_NONE) {
-    session_start();
-}
-
-// Regenerate session ID if invalid or not defined in cookie.
-if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) {
-    session_regenerate_id(true);
-    $_COOKIE['shaarli'] = session_id();
-}
-
 $conf = new ConfigManager();
 
+// Manually override root URL for complex server configurations
+define('SHAARLI_ROOT_URL', $conf->get('general.root_url', null));
+
 // In dev mode, throw exception on any warning
 if ($conf->get('dev.debug', false)) {
     // See all errors (for debugging only)
     error_reporting(-1);
 
-    set_error_handler(function($errno, $errstr, $errfile, $errline, array $errcontext) {
+    set_error_handler(function ($errno, $errstr, $errfile, $errline, array $errcontext) {
         throw new ErrorException($errstr, 0, $errno, $errfile, $errline);
     });
 }
 
-$sessionManager = new SessionManager($_SESSION, $conf);
-$loginManager = new LoginManager($conf, $sessionManager);
+$logger = new Logger(
+    dirname($conf->get('resource.log')),
+    !$conf->get('dev.debug') ? LogLevel::INFO : LogLevel::DEBUG,
+    ['filename' => basename($conf->get('resource.log'))]
+);
+$sessionManager = new SessionManager($_SESSION, $conf, session_save_path());
+$sessionManager->initialize();
+$cookieManager = new CookieManager($_COOKIE);
+$banManager = new BanManager(
+    $conf->get('security.trusted_proxies', []),
+    $conf->get('security.ban_after'),
+    $conf->get('security.ban_duration'),
+    $conf->get('resource.ban_file', 'data/ipbans.php'),
+    $logger
+);
+$loginManager = new LoginManager($conf, $sessionManager, $cookieManager, $banManager, $logger);
 $loginManager->generateStaySignedInToken($_SERVER['REMOTE_ADDR']);
-$clientIpId = client_ip_id($_SERVER);
-
-// LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead.
-if (! defined('LC_MESSAGES')) {
-    define('LC_MESSAGES', LC_COLLATE);
-}
 
 // Sniff browser language and set date format accordingly.
 if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
@@ -157,1773 +78,80 @@ new Languages(setlocale(LC_MESSAGES, 0), $conf);
 
 $conf->setEmpty('general.timezone', date_default_timezone_get());
 $conf->setEmpty('general.title', t('Shared bookmarks on '). escape(index_url($_SERVER)));
+
 RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory
 RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory
 
-$pluginManager = new PluginManager($conf);
-$pluginManager->load($conf->get('general.enabled_plugins'));
-
 date_default_timezone_set($conf->get('general.timezone', 'UTC'));
 
-ob_start();  // Output buffering for the page cache.
-
-// Prevent caching on client side or proxy: (yes, it's ugly)
-header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
-header("Cache-Control: no-store, no-cache, must-revalidate");
-header("Cache-Control: post-check=0, pre-check=0", false);
-header("Pragma: no-cache");
-
-if (! is_file($conf->getConfigFileExt())) {
-    // Ensure Shaarli has proper access to its resources
-    $errors = ApplicationUtils::checkResourcePermissions($conf);
-
-    if ($errors != array()) {
-        $message = '<p>'. t('Insufficient permissions:') .'</p><ul>';
-
-        foreach ($errors as $error) {
-            $message .= '<li>'.$error.'</li>';
-        }
-        $message .= '</ul>';
-
-        header('Content-Type: text/html; charset=utf-8');
-        echo $message;
-        exit;
-    }
-
-    // Display the installation form if no existing config is found
-    install($conf, $sessionManager, $loginManager);
-}
-
-$loginManager->checkLoginState($_COOKIE, $clientIpId);
-
-/**
- * Adapter function to ensure compatibility with third-party templates
- *
- * @see https://github.com/shaarli/Shaarli/pull/1086
- *
- * @return bool true when the user is logged in, false otherwise
- */
-function isLoggedIn()
-{
-    global $loginManager;
-    return $loginManager->isLoggedIn();
-}
-
-
-// ------------------------------------------------------------------------------------------
-// Process login form: Check if login/password is correct.
-if (isset($_POST['login'])) {
-    if (! $loginManager->canLogin($_SERVER)) {
-        die(t('I said: NO. You are banned for the moment. Go away.'));
-    }
-    if (isset($_POST['password'])
-        && $sessionManager->checkToken($_POST['token'])
-        && $loginManager->checkCredentials($_SERVER['REMOTE_ADDR'], $clientIpId, $_POST['login'], $_POST['password'])
-    ) {
-        $loginManager->handleSuccessfulLogin($_SERVER);
-
-        $cookiedir = '';
-        if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
-            // Note: Never forget the trailing slash on the cookie path!
-            $cookiedir = dirname($_SERVER["SCRIPT_NAME"]) . '/';
-        }
-
-        if (!empty($_POST['longlastingsession'])) {
-            // Keep the session cookie even after the browser closes
-            $sessionManager->setStaySignedIn(true);
-            $expirationTime = $sessionManager->extendSession();
-
-            setcookie(
-                $loginManager::$STAY_SIGNED_IN_COOKIE,
-                $loginManager->getStaySignedInToken(),
-                $expirationTime,
-                WEB_PATH
-            );
-        } else {
-            // Standard session expiration (=when browser closes)
-            $expirationTime = 0;
-        }
-
-        // Send cookie with the new expiration date to the browser
-        session_destroy();
-        session_set_cookie_params($expirationTime, $cookiedir, $_SERVER['SERVER_NAME']);
-        session_start();
-        session_regenerate_id(true);
-
-        // Optional redirect after login:
-        if (isset($_GET['post'])) {
-            $uri = './?post='. urlencode($_GET['post']);
-            foreach (array('description', 'source', 'title', 'tags') as $param) {
-                if (!empty($_GET[$param])) {
-                    $uri .= '&'.$param.'='.urlencode($_GET[$param]);
-                }
-            }
-            header('Location: '. $uri);
-            exit;
-        }
-
-        if (isset($_GET['edit_link'])) {
-            header('Location: ./?edit_link='. escape($_GET['edit_link']));
-            exit;
-        }
-
-        if (isset($_POST['returnurl'])) {
-            // Prevent loops over login screen.
-            if (strpos($_POST['returnurl'], '/login') === false) {
-                header('Location: '. generateLocation($_POST['returnurl'], $_SERVER['HTTP_HOST']));
-                exit;
-            }
-        }
-        header('Location: ./?');
-        exit;
-    } else {
-        $loginManager->handleFailedLogin($_SERVER);
-        $redir = '?username='. urlencode($_POST['login']);
-        if (isset($_GET['post'])) {
-            $redir .= '&post=' . urlencode($_GET['post']);
-            foreach (array('description', 'source', 'title', 'tags') as $param) {
-                if (!empty($_GET[$param])) {
-                    $redir .= '&' . $param . '=' . urlencode($_GET[$param]);
-                }
-            }
-        }
-        // Redirect to login screen.
-        echo '<script>alert("'. t("Wrong login/password.") .'");document.location=\'./login'.$redir.'\';</script>';
-        exit;
-    }
-}
-
-// ------------------------------------------------------------------------------------------
-// Token management for XSRF protection
-// Token should be used in any form which acts on data (create,update,delete,import...).
-if (!isset($_SESSION['tokens'])) {
-    $_SESSION['tokens']=array();  // Token are attached to the session.
-}
-
-/**
- * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day.
- * Gives the last 7 days (which have bookmarks).
- * This RSS feed cannot be filtered.
- *
- * @param BookmarkServiceInterface $bookmarkService
- * @param ConfigManager            $conf            Configuration Manager instance
- * @param LoginManager             $loginManager    LoginManager instance
- */
-function showDailyRSS($bookmarkService, $conf, $loginManager)
-{
-    // Cache system
-    $query = $_SERVER['QUERY_STRING'];
-    $cache = new CachedPage(
-        $conf->get('config.PAGE_CACHE'),
-        page_url($_SERVER),
-        startsWith($query, 'do=dailyrss') && !$loginManager->isLoggedIn()
-    );
-    $cached = $cache->cachedVersion();
-    if (!empty($cached)) {
-        echo $cached;
-        exit;
-    }
-
-    /* Some Shaarlies may have very few bookmarks, so we need to look
-       back in time until we have enough days ($nb_of_days).
-    */
-    $nb_of_days = 7; // We take 7 days.
-    $today = date('Ymd');
-    $days = array();
-
-    foreach ($bookmarkService->search() as $bookmark) {
-        $day = $bookmark->getCreated()->format('Ymd'); // Extract day (without time)
-        if (strcmp($day, $today) < 0) {
-            if (empty($days[$day])) {
-                $days[$day] = array();
-            }
-            $days[$day][] = $bookmark;
-        }
-
-        if (count($days) > $nb_of_days) {
-            break; // Have we collected enough days?
-        }
-    }
-
-    // Build the RSS feed.
-    header('Content-Type: application/rss+xml; charset=utf-8');
-    $pageaddr = escape(index_url($_SERVER));
-    echo '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0">';
-    echo '<channel>';
-    echo '<title>Daily - '. $conf->get('general.title') . '</title>';
-    echo '<link>'. $pageaddr .'</link>';
-    echo '<description>Daily shared bookmarks</description>';
-    echo '<language>en-en</language>';
-    echo '<copyright>'. $pageaddr .'</copyright>'. PHP_EOL;
-
-    $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-    $formatter = $factory->getFormatter();
-    $formatter->addContextData('index_url', index_url($_SERVER));
-    // For each day.
-    /** @var Bookmark[] $bookmarks */
-    foreach ($days as $day => $bookmarks) {
-        $formattedBookmarks = [];
-        $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
-        $absurl = escape(index_url($_SERVER).'?do=daily&day='.$day);  // Absolute URL of the corresponding "Daily" page.
-
-        // We pre-format some fields for proper output.
-        foreach ($bookmarks as $key => $bookmark) {
-            $formattedBookmarks[$key] = $formatter->format($bookmark);
-            // This page is a bit specific, we need raw description to calculate the length
-            $formattedBookmarks[$key]['formatedDescription'] = $formattedBookmarks[$key]['description'];
-            $formattedBookmarks[$key]['description'] = $bookmark->getDescription();
-
-            if ($bookmark->isNote()) {
-                $link['url'] = index_url($_SERVER) . $bookmark->getUrl();  // make permalink URL absolute
-            }
-        }
-
-        // Then build the HTML for this day:
-        $tpl = new RainTPL();
-        $tpl->assign('title', $conf->get('general.title'));
-        $tpl->assign('daydate', $dayDate->getTimestamp());
-        $tpl->assign('absurl', $absurl);
-        $tpl->assign('links', $formattedBookmarks);
-        $tpl->assign('rssdate', escape($dayDate->format(DateTime::RSS)));
-        $tpl->assign('hide_timestamps', $conf->get('privacy.hide_timestamps', false));
-        $tpl->assign('index_url', $pageaddr);
-        $html = $tpl->draw('dailyrss', true);
-
-        echo $html . PHP_EOL;
-    }
-    echo '</channel></rss><!-- Cached version of '. escape(page_url($_SERVER)) .' -->';
-
-    $cache->cache(ob_get_contents());
-    ob_end_flush();
-    exit;
-}
-
-/**
- * Show the 'Daily' page.
- *
- * @param PageBuilder              $pageBuilder     Template engine wrapper.
- * @param BookmarkServiceInterface $bookmarkService instance.
- * @param ConfigManager            $conf            Configuration Manager instance.
- * @param PluginManager            $pluginManager   Plugin Manager instance.
- * @param LoginManager             $loginManager    Login Manager instance
- */
-function showDaily($pageBuilder, $bookmarkService, $conf, $pluginManager, $loginManager)
-{
-    if (isset($_GET['day'])) {
-        $day = $_GET['day'];
-        if ($day === date('Ymd', strtotime('now'))) {
-            $pageBuilder->assign('dayDesc', t('Today'));
-        } elseif ($day === date('Ymd', strtotime('-1 days'))) {
-            $pageBuilder->assign('dayDesc', t('Yesterday'));
-        }
-    } else {
-        $day = date('Ymd', strtotime('now')); // Today, in format YYYYMMDD.
-        $pageBuilder->assign('dayDesc', t('Today'));
-    }
-
-    $days = $bookmarkService->days();
-    $i = array_search($day, $days);
-    if ($i === false && count($days)) {
-        // no bookmarks for day, but at least one day with bookmarks
-        $i = count($days) - 1;
-        $day = $days[$i];
-    }
-    $previousday = '';
-    $nextday = '';
-
-    if ($i !== false) {
-        if ($i >= 1) {
-             $previousday = $days[$i - 1];
-        }
-        if ($i < count($days) - 1) {
-            $nextday = $days[$i + 1];
-        }
-    }
-    try {
-        $linksToDisplay = $bookmarkService->filterDay($day);
-    } catch (Exception $exc) {
-        error_log($exc);
-        $linksToDisplay = [];
-    }
-
-    $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-    $formatter = $factory->getFormatter();
-    // We pre-format some fields for proper output.
-    foreach ($linksToDisplay as $key => $bookmark) {
-        $linksToDisplay[$key] = $formatter->format($bookmark);
-        // This page is a bit specific, we need raw description to calculate the length
-        $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description'];
-        $linksToDisplay[$key]['description'] = $bookmark->getDescription();
-    }
-
-    $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
-    $data = array(
-        'pagetitle' => $conf->get('general.title') .' - '. format_date($dayDate, false),
-        'linksToDisplay' => $linksToDisplay,
-        'day' => $dayDate->getTimestamp(),
-        'dayDate' => $dayDate,
-        'previousday' => $previousday,
-        'nextday' => $nextday,
-    );
-
-    /* Hook is called before column construction so that plugins don't have
-       to deal with columns. */
-    $pluginManager->executeHooks('render_daily', $data, array('loggedin' => $loginManager->isLoggedIn()));
-
-    /* We need to spread the articles on 3 columns.
-       I did not want to use a JavaScript lib like http://masonry.desandro.com/
-       so I manually spread entries with a simple method: I roughly evaluate the
-       height of a div according to title and description length.
-    */
-    $columns = array(array(), array(), array()); // Entries to display, for each column.
-    $fill = array(0, 0, 0);  // Rough estimate of columns fill.
-    foreach ($data['linksToDisplay'] as $key => $bookmark) {
-        // Roughly estimate length of entry (by counting characters)
-        // Title: 30 chars = 1 line. 1 line is 30 pixels height.
-        // Description: 836 characters gives roughly 342 pixel height.
-        // This is not perfect, but it's usually OK.
-        $length = strlen($bookmark['title']) + (342 * strlen($bookmark['description'])) / 836;
-        if (! empty($bookmark['thumbnail'])) {
-            $length += 100; // 1 thumbnails roughly takes 100 pixels height.
-        }
-        // Then put in column which is the less filled:
-        $smallest = min($fill); // find smallest value in array.
-        $index = array_search($smallest, $fill); // find index of this smallest value.
-        array_push($columns[$index], $bookmark); // Put entry in this column.
-        $fill[$index] += $length;
-    }
-
-    $data['cols'] = $columns;
-
-    foreach ($data as $key => $value) {
-        $pageBuilder->assign($key, $value);
-    }
-
-    $pageBuilder->assign('pagetitle', t('Daily') .' - '. $conf->get('general.title', 'Shaarli'));
-    $pageBuilder->renderPage('daily');
-    exit;
-}
-
-/**
- * Renders the linklist
- *
- * @param pageBuilder              $PAGE          pageBuilder instance.
- * @param BookmarkServiceInterface $linkDb        instance.
- * @param ConfigManager            $conf          Configuration Manager instance.
- * @param PluginManager            $pluginManager Plugin Manager instance.
- */
-function showLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager)
-{
-    buildLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager);
-    $PAGE->renderPage('linklist');
-}
-
-/**
- * Render HTML page (according to URL parameters and user rights)
- *
- * @param ConfigManager            $conf           Configuration Manager instance.
- * @param PluginManager            $pluginManager  Plugin Manager instance,
- * @param BookmarkServiceInterface $bookmarkService
- * @param History                  $history        instance
- * @param SessionManager           $sessionManager SessionManager instance
- * @param LoginManager             $loginManager   LoginManager instance
- */
-function renderPage($conf, $pluginManager, $bookmarkService, $history, $sessionManager, $loginManager)
-{
-    $updater = new Updater(
-        UpdaterUtils::read_updates_file($conf->get('resource.updates')),
-        $bookmarkService,
-        $conf,
-        $loginManager->isLoggedIn()
-    );
-    try {
-        $newUpdates = $updater->update();
-        if (! empty($newUpdates)) {
-            UpdaterUtils::write_updates_file(
-                $conf->get('resource.updates'),
-                $updater->getDoneUpdates()
-            );
-        }
-    } catch (Exception $e) {
-        die($e->getMessage());
-    }
-
-    $PAGE = new PageBuilder($conf, $_SESSION, $bookmarkService, $sessionManager->generateToken(), $loginManager->isLoggedIn());
-    $PAGE->assign('linkcount', $bookmarkService->count(BookmarkFilter::$ALL));
-    $PAGE->assign('privateLinkcount', $bookmarkService->count(BookmarkFilter::$PRIVATE));
-    $PAGE->assign('plugin_errors', $pluginManager->getErrors());
-
-    // Determine which page will be rendered.
-    $query = (isset($_SERVER['QUERY_STRING'])) ? $_SERVER['QUERY_STRING'] : '';
-    $targetPage = Router::findPage($query, $_GET, $loginManager->isLoggedIn());
-
-    if (// if the user isn't logged in
-        !$loginManager->isLoggedIn() &&
-        // and Shaarli doesn't have public content...
-        $conf->get('privacy.hide_public_links') &&
-        // and is configured to enforce the login
-        $conf->get('privacy.force_login') &&
-        // and the current page isn't already the login page
-        $targetPage !== Router::$PAGE_LOGIN &&
-        // and the user is not requesting a feed (which would lead to a different content-type as expected)
-        $targetPage !== Router::$PAGE_FEED_ATOM &&
-        $targetPage !== Router::$PAGE_FEED_RSS
-    ) {
-        // force current page to be the login page
-        $targetPage = Router::$PAGE_LOGIN;
-    }
-
-    // Call plugin hooks for header, footer and includes, specifying which page will be rendered.
-    // Then assign generated data to RainTPL.
-    $common_hooks = array(
-        'includes',
-        'header',
-        'footer',
-    );
-
-    foreach ($common_hooks as $name) {
-        $plugin_data = array();
-        $pluginManager->executeHooks(
-            'render_' . $name,
-            $plugin_data,
-            array(
-                'target' => $targetPage,
-                'loggedin' => $loginManager->isLoggedIn()
-            )
-        );
-        $PAGE->assign('plugins_' . $name, $plugin_data);
-    }
-
-    // -------- Display login form.
-    if ($targetPage == Router::$PAGE_LOGIN) {
-        header('Location: ./login');
-        exit;
-    }
-    // -------- User wants to logout.
-    if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=logout')) {
-        invalidateCaches($conf->get('resource.page_cache'));
-        $sessionManager->logout();
-        setcookie(LoginManager::$STAY_SIGNED_IN_COOKIE, 'false', 0, WEB_PATH);
-        header('Location: ?');
-        exit;
-    }
-
-    // -------- Picture wall
-    if ($targetPage == Router::$PAGE_PICWALL) {
-        $PAGE->assign('pagetitle', t('Picture wall') .' - '. $conf->get('general.title', 'Shaarli'));
-        if (! $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) === Thumbnailer::MODE_NONE) {
-            $PAGE->assign('linksToDisplay', []);
-            $PAGE->renderPage('picwall');
-            exit;
-        }
-
-        // Optionally filter the results:
-        $links = $bookmarkService->search($_GET);
-        $linksToDisplay = [];
-
-        // Get only bookmarks which have a thumbnail.
-        // Note: we do not retrieve thumbnails here, the request is too heavy.
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter();
-        foreach ($links as $key => $link) {
-            if ($link->getThumbnail() !== false) {
-                $linksToDisplay[] = $formatter->format($link);
-            }
-        }
-
-        $data = [
-            'linksToDisplay' => $linksToDisplay,
-        ];
-        $pluginManager->executeHooks('render_picwall', $data, ['loggedin' => $loginManager->isLoggedIn()]);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->renderPage('picwall');
-        exit;
-    }
-
-    // -------- Tag cloud
-    if ($targetPage == Router::$PAGE_TAGCLOUD) {
-        $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '';
-        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
-        $tags = $bookmarkService->bookmarksCountPerTag($filteringTags, $visibility);
-
-        // We sort tags alphabetically, then choose a font size according to count.
-        // First, find max value.
-        $maxcount = 0;
-        foreach ($tags as $value) {
-            $maxcount = max($maxcount, $value);
-        }
-
-        alphabetical_sort($tags, false, true);
-
-        $logMaxCount = $maxcount > 1 ? log($maxcount, 30) : 1;
-        $tagList = array();
-        foreach ($tags as $key => $value) {
-            if (in_array($key, $filteringTags)) {
-                continue;
-            }
-            // Tag font size scaling:
-            //   default 15 and 30 logarithm bases affect scaling,
-            //   2.2 and 0.8 are arbitrary font sizes in em.
-            $size = log($value, 15) / $logMaxCount * 2.2 + 0.8;
-            $tagList[$key] = array(
-                'count' => $value,
-                'size' => number_format($size, 2, '.', ''),
-            );
-        }
-
-        $searchTags = implode(' ', escape($filteringTags));
-        $data = array(
-            'search_tags' => $searchTags,
-            'tags' => $tagList,
-        );
-        $pluginManager->executeHooks('render_tagcloud', $data, array('loggedin' => $loginManager->isLoggedIn()));
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $searchTags = ! empty($searchTags) ? $searchTags .' - ' : '';
-        $PAGE->assign('pagetitle', $searchTags. t('Tag cloud') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('tag.cloud');
-        exit;
-    }
-
-    // -------- Tag list
-    if ($targetPage == Router::$PAGE_TAGLIST) {
-        $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '';
-        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
-        $tags = $bookmarkService->bookmarksCountPerTag($filteringTags, $visibility);
-        foreach ($filteringTags as $tag) {
-            if (array_key_exists($tag, $tags)) {
-                unset($tags[$tag]);
-            }
-        }
-
-        if (! empty($_GET['sort']) && $_GET['sort'] === 'alpha') {
-            alphabetical_sort($tags, false, true);
-        }
-
-        $searchTags = implode(' ', escape($filteringTags));
-        $data = [
-            'search_tags' => $searchTags,
-            'tags' => $tags,
-        ];
-        $pluginManager->executeHooks('render_taglist', $data, ['loggedin' => $loginManager->isLoggedIn()]);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $searchTags = ! empty($searchTags) ? $searchTags .' - ' : '';
-        $PAGE->assign('pagetitle', $searchTags . t('Tag list') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('tag.list');
-        exit;
-    }
-
-    // Daily page.
-    if ($targetPage == Router::$PAGE_DAILY) {
-        showDaily($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager);
-    }
-
-    // ATOM and RSS feed.
-    if ($targetPage == Router::$PAGE_FEED_ATOM || $targetPage == Router::$PAGE_FEED_RSS) {
-        $feedType = $targetPage == Router::$PAGE_FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
-        header('Content-Type: application/'. $feedType .'+xml; charset=utf-8');
-
-        // Cache system
-        $query = $_SERVER['QUERY_STRING'];
-        $cache = new CachedPage(
-            $conf->get('resource.page_cache'),
-            page_url($_SERVER),
-            startsWith($query, 'do='. $targetPage) && !$loginManager->isLoggedIn()
-        );
-        $cached = $cache->cachedVersion();
-        if (!empty($cached)) {
-            echo $cached;
-            exit;
-        }
-
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        // Generate data.
-        $feedGenerator = new FeedBuilder(
-            $bookmarkService,
-            $factory->getFormatter(),
-            $feedType,
-            $_SERVER,
-            $_GET,
-            $loginManager->isLoggedIn()
-        );
-        $feedGenerator->setLocale(strtolower(setlocale(LC_COLLATE, 0)));
-        $feedGenerator->setHideDates($conf->get('privacy.hide_timestamps') && !$loginManager->isLoggedIn());
-        $feedGenerator->setUsePermalinks(isset($_GET['permalinks']) || !$conf->get('feed.rss_permalinks'));
-        $data = $feedGenerator->buildData();
-
-        // Process plugin hook.
-        $pluginManager->executeHooks('render_feed', $data, array(
-            'loggedin' => $loginManager->isLoggedIn(),
-            'target' => $targetPage,
-        ));
-
-        // Render the template.
-        $PAGE->assignAll($data);
-        $PAGE->renderPage('feed.'. $feedType);
-        $cache->cache(ob_get_contents());
-        ob_end_flush();
-        exit;
-    }
-
-    // Display opensearch plugin (XML)
-    if ($targetPage == Router::$PAGE_OPENSEARCH) {
-        header('Content-Type: application/xml; charset=utf-8');
-        $PAGE->assign('serverurl', index_url($_SERVER));
-        $PAGE->renderPage('opensearch');
-        exit;
-    }
-
-    // -------- User clicks on a tag in a link: The tag is added to the list of searched tags (searchtags=...)
-    if (isset($_GET['addtag'])) {
-        // Get previous URL (http_referer) and add the tag to the searchtags parameters in query.
-        if (empty($_SERVER['HTTP_REFERER'])) {
-            // In case browser does not send HTTP_REFERER
-            header('Location: ?searchtags='.urlencode($_GET['addtag']));
-            exit;
-        }
-        parse_str(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_QUERY), $params);
-
-        // Prevent redirection loop
-        if (isset($params['addtag'])) {
-            unset($params['addtag']);
-        }
-
-        // Check if this tag is already in the search query and ignore it if it is.
-        // Each tag is always separated by a space
-        if (isset($params['searchtags'])) {
-            $current_tags = explode(' ', $params['searchtags']);
-        } else {
-            $current_tags = array();
-        }
-        $addtag = true;
-        foreach ($current_tags as $value) {
-            if ($value === $_GET['addtag']) {
-                $addtag = false;
-                break;
-            }
-        }
-        // Append the tag if necessary
-        if (empty($params['searchtags'])) {
-            $params['searchtags'] = trim($_GET['addtag']);
-        } elseif ($addtag) {
-            $params['searchtags'] = trim($params['searchtags']).' '.trim($_GET['addtag']);
-        }
-
-        // We also remove page (keeping the same page has no sense, since the
-        // results are different)
-        unset($params['page']);
-
-        header('Location: ?'.http_build_query($params));
-        exit;
-    }
-
-    // -------- User clicks on a tag in result count: Remove the tag from the list of searched tags (searchtags=...)
-    if (isset($_GET['removetag'])) {
-        // Get previous URL (http_referer) and remove the tag from the searchtags parameters in query.
-        if (empty($_SERVER['HTTP_REFERER'])) {
-            header('Location: ?');
-            exit;
-        }
-
-        // In case browser does not send HTTP_REFERER
-        parse_str(parse_url($_SERVER['HTTP_REFERER'], PHP_URL_QUERY), $params);
+$loginManager->checkLoginState(client_ip_id($_SERVER));
 
-        // Prevent redirection loop
-        if (isset($params['removetag'])) {
-            unset($params['removetag']);
-        }
-
-        if (isset($params['searchtags'])) {
-            $tags = explode(' ', $params['searchtags']);
-            // Remove value from array $tags.
-            $tags = array_diff($tags, array($_GET['removetag']));
-            $params['searchtags'] = implode(' ', $tags);
-
-            if (empty($params['searchtags'])) {
-                unset($params['searchtags']);
-            }
-
-            // We also remove page (keeping the same page has no sense, since
-            // the results are different)
-            unset($params['page']);
-        }
-        header('Location: ?'.http_build_query($params));
-        exit;
-    }
-
-    // -------- User wants to change the number of bookmarks per page (linksperpage=...)
-    if (isset($_GET['linksperpage'])) {
-        if (is_numeric($_GET['linksperpage'])) {
-            $_SESSION['LINKS_PER_PAGE']=abs(intval($_GET['linksperpage']));
-        }
-
-        if (! empty($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'], array('linksperpage'));
-        } else {
-            $location = '?';
-        }
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- User wants to see only private bookmarks (toggle)
-    if (isset($_GET['visibility'])) {
-        if ($_GET['visibility'] === 'private') {
-            // Visibility not set or not already private, set private, otherwise reset it
-            if (empty($_SESSION['visibility']) || $_SESSION['visibility'] !== 'private') {
-                // See only private bookmarks
-                $_SESSION['visibility'] = 'private';
-            } else {
-                unset($_SESSION['visibility']);
-            }
-        } elseif ($_GET['visibility'] === 'public') {
-            if (empty($_SESSION['visibility']) || $_SESSION['visibility'] !== 'public') {
-                // See only public bookmarks
-                $_SESSION['visibility'] = 'public';
-            } else {
-                unset($_SESSION['visibility']);
-            }
-        }
-
-        if (! empty($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'], array('visibility'));
-        } else {
-            $location = '?';
-        }
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- User wants to see only untagged bookmarks (toggle)
-    if (isset($_GET['untaggedonly'])) {
-        $_SESSION['untaggedonly'] = empty($_SESSION['untaggedonly']);
-
-        if (! empty($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation($_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'], array('untaggedonly'));
-        } else {
-            $location = '?';
-        }
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- Handle other actions allowed for non-logged in users:
-    if (!$loginManager->isLoggedIn()) {
-        // User tries to post new link but is not logged in:
-        // Show login screen, then redirect to ?post=...
-        if (isset($_GET['post'])) {
-            header( // Redirect to login page, then back to post link.
-                'Location: /login?post='.urlencode($_GET['post']).
-                (!empty($_GET['title'])?'&title='.urlencode($_GET['title']):'').
-                (!empty($_GET['description'])?'&description='.urlencode($_GET['description']):'').
-                (!empty($_GET['tags'])?'&tags='.urlencode($_GET['tags']):'').
-                (!empty($_GET['source'])?'&source='.urlencode($_GET['source']):'')
-            );
-            exit;
-        }
-
-        showLinkList($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager);
-        if (isset($_GET['edit_link'])) {
-            header('Location: /login?edit_link='. escape($_GET['edit_link']));
-            exit;
-        }
-
-        exit; // Never remove this one! All operations below are reserved for logged in user.
-    }
-
-    // -------- All other functions are reserved for the registered user:
-
-    // -------- Display the Tools menu if requested (import/export/bookmarklet...)
-    if ($targetPage == Router::$PAGE_TOOLS) {
-        $data = [
-            'pageabsaddr' => index_url($_SERVER),
-            'sslenabled' => is_https($_SERVER),
-        ];
-        $pluginManager->executeHooks('render_tools', $data);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->assign('pagetitle', t('Tools') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('tools');
-        exit;
-    }
-
-    // -------- User wants to change his/her password.
-    if ($targetPage == Router::$PAGE_CHANGEPASSWORD) {
-        if ($conf->get('security.open_shaarli')) {
-            die(t('You are not supposed to change a password on an Open Shaarli.'));
-        }
-
-        if (!empty($_POST['setpassword']) && !empty($_POST['oldpassword'])) {
-            if (!$sessionManager->checkToken($_POST['token'])) {
-                die(t('Wrong token.')); // Go away!
-            }
-
-            // Make sure old password is correct.
-            $oldhash = sha1(
-                $_POST['oldpassword'].$conf->get('credentials.login').$conf->get('credentials.salt')
-            );
-            if ($oldhash != $conf->get('credentials.hash')) {
-                echo '<script>alert("'
-                    . t('The old password is not correct.')
-                    .'");document.location=\'?do=changepasswd\';</script>';
-                exit;
-            }
-            // Save new password
-            // Salt renders rainbow-tables attacks useless.
-            $conf->set('credentials.salt', sha1(uniqid('', true) .'_'. mt_rand()));
-            $conf->set(
-                'credentials.hash',
-                sha1(
-                    $_POST['setpassword']
-                    . $conf->get('credentials.login')
-                    . $conf->get('credentials.salt')
-                )
-            );
-            try {
-                $conf->write($loginManager->isLoggedIn());
-            } catch (Exception $e) {
-                error_log(
-                    'ERROR while writing config file after changing password.' . PHP_EOL .
-                    $e->getMessage()
-                );
-
-                // TODO: do not handle exceptions/errors in JS.
-                echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=tools\';</script>';
-                exit;
-            }
-            echo '<script>alert("'. t('Your password has been changed') .'");document.location=\'?do=tools\';</script>';
-            exit;
-        } else {
-            // show the change password form.
-            $PAGE->assign('pagetitle', t('Change password') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('changepassword');
-            exit;
-        }
-    }
-
-    // -------- User wants to change configuration
-    if ($targetPage == Router::$PAGE_CONFIGURE) {
-        if (!empty($_POST['title'])) {
-            if (!$sessionManager->checkToken($_POST['token'])) {
-                die(t('Wrong token.')); // Go away!
-            }
-            $tz = 'UTC';
-            if (!empty($_POST['continent']) && !empty($_POST['city'])
-                && isTimeZoneValid($_POST['continent'], $_POST['city'])
-            ) {
-                $tz = $_POST['continent'] . '/' . $_POST['city'];
-            }
-            $conf->set('general.timezone', $tz);
-            $conf->set('general.title', escape($_POST['title']));
-            $conf->set('general.header_link', escape($_POST['titleLink']));
-            $conf->set('general.retrieve_description', !empty($_POST['retrieveDescription']));
-            $conf->set('resource.theme', escape($_POST['theme']));
-            $conf->set('security.session_protection_disabled', !empty($_POST['disablesessionprotection']));
-            $conf->set('privacy.default_private_links', !empty($_POST['privateLinkByDefault']));
-            $conf->set('feed.rss_permalinks', !empty($_POST['enableRssPermalinks']));
-            $conf->set('updates.check_updates', !empty($_POST['updateCheck']));
-            $conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks']));
-            $conf->set('api.enabled', !empty($_POST['enableApi']));
-            $conf->set('api.secret', escape($_POST['apiSecret']));
-            $conf->set('formatter', escape($_POST['formatter']));
-
-            if (! empty($_POST['language'])) {
-                $conf->set('translation.language', escape($_POST['language']));
-            }
-
-            $thumbnailsMode = extension_loaded('gd') ? $_POST['enableThumbnails'] : Thumbnailer::MODE_NONE;
-            if ($thumbnailsMode !== Thumbnailer::MODE_NONE
-                && $thumbnailsMode !== $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE)
-            ) {
-                $_SESSION['warnings'][] = t(
-                    'You have enabled or changed thumbnails mode. '
-                    .'<a href="?do=thumbs_update">Please synchronize them</a>.'
-                );
-            }
-            $conf->set('thumbnails.mode', $thumbnailsMode);
-
-            try {
-                $conf->write($loginManager->isLoggedIn());
-                $history->updateSettings();
-                invalidateCaches($conf->get('resource.page_cache'));
-            } catch (Exception $e) {
-                error_log(
-                    'ERROR while writing config file after configuration update.' . PHP_EOL .
-                    $e->getMessage()
-                );
-
-                // TODO: do not handle exceptions/errors in JS.
-                echo '<script>alert("'. $e->getMessage() .'");document.location=\'?do=configure\';</script>';
-                exit;
-            }
-            echo '<script>alert("'. t('Configuration was saved.') .'");document.location=\'?do=configure\';</script>';
-            exit;
-        } else {
-            // Show the configuration form.
-            $PAGE->assign('title', $conf->get('general.title'));
-            $PAGE->assign('theme', $conf->get('resource.theme'));
-            $PAGE->assign('theme_available', ThemeUtils::getThemes($conf->get('resource.raintpl_tpl')));
-            $PAGE->assign('formatter_available', ['default', 'markdown']);
-            list($continents, $cities) = generateTimeZoneData(
-                timezone_identifiers_list(),
-                $conf->get('general.timezone')
-            );
-            $PAGE->assign('continents', $continents);
-            $PAGE->assign('cities', $cities);
-            $PAGE->assign('retrieve_description', $conf->get('general.retrieve_description'));
-            $PAGE->assign('private_links_default', $conf->get('privacy.default_private_links', false));
-            $PAGE->assign('session_protection_disabled', $conf->get('security.session_protection_disabled', false));
-            $PAGE->assign('enable_rss_permalinks', $conf->get('feed.rss_permalinks', false));
-            $PAGE->assign('enable_update_check', $conf->get('updates.check_updates', true));
-            $PAGE->assign('hide_public_links', $conf->get('privacy.hide_public_links', false));
-            $PAGE->assign('api_enabled', $conf->get('api.enabled', true));
-            $PAGE->assign('api_secret', $conf->get('api.secret'));
-            $PAGE->assign('languages', Languages::getAvailableLanguages());
-            $PAGE->assign('gd_enabled', extension_loaded('gd'));
-            $PAGE->assign('thumbnails_mode', $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE));
-            $PAGE->assign('pagetitle', t('Configure') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('configure');
-            exit;
-        }
-    }
-
-    // -------- User wants to rename a tag or delete it
-    if ($targetPage == Router::$PAGE_CHANGETAG) {
-        if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) {
-            $PAGE->assign('fromtag', ! empty($_GET['fromtag']) ? escape($_GET['fromtag']) : '');
-            $PAGE->assign('pagetitle', t('Manage tags') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('changetag');
-            exit;
-        }
-
-        if (!$sessionManager->checkToken($_POST['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        $toTag = isset($_POST['totag']) ? escape($_POST['totag']) : null;
-        $fromTag = escape($_POST['fromtag']);
-        $count = 0;
-        $bookmarks = $bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true);
-        foreach ($bookmarks as $bookmark) {
-            if ($toTag) {
-                $bookmark->renameTag($fromTag, $toTag);
-            } else {
-                $bookmark->deleteTag($fromTag);
-            }
-            $bookmarkService->set($bookmark, false);
-            $history->updateLink($bookmark);
-            $count++;
-        }
-        $bookmarkService->save();
-        $delete = empty($_POST['totag']);
-        $redirect = $delete ? 'do=changetag' : 'searchtags='. urlencode(escape($_POST['totag']));
-        $alert = $delete
-            ? sprintf(t('The tag was removed from %d link.', 'The tag was removed from %d bookmarks.', $count), $count)
-            : sprintf(t('The tag was renamed in %d link.', 'The tag was renamed in %d bookmarks.', $count), $count);
-        echo '<script>alert("'. $alert .'");document.location=\'?'. $redirect .'\';</script>';
-        exit;
-    }
-
-    // -------- User wants to add a link without using the bookmarklet: Show form.
-    if ($targetPage == Router::$PAGE_ADDLINK) {
-        $PAGE->assign('pagetitle', t('Shaare a new link') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('addlink');
-        exit;
-    }
-
-    // -------- User clicked the "Save" button when editing a link: Save link to database.
-    if (isset($_POST['save_edit'])) {
-        // Go away!
-        if (! $sessionManager->checkToken($_POST['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        // lf_id should only be present if the link exists.
-        $id = isset($_POST['lf_id']) ? intval(escape($_POST['lf_id'])) : null;
-        if ($id && $bookmarkService->exists($id)) {
-            // Edit
-            $bookmark = $bookmarkService->get($id);
-        } else {
-            // New link
-            $bookmark = new Bookmark();
-        }
-
-        $bookmark->setTitle($_POST['lf_title']);
-        $bookmark->setDescription($_POST['lf_description']);
-        $bookmark->setUrl($_POST['lf_url'], $conf->get('security.allowed_protocols'));
-        $bookmark->setPrivate(isset($_POST['lf_private']));
-        $bookmark->setTagsString($_POST['lf_tags']);
-
-        if ($conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
-            && ! $bookmark->isNote()
-        ) {
-            $thumbnailer = new Thumbnailer($conf);
-            $bookmark->setThumbnail($thumbnailer->get($bookmark->getUrl()));
-        }
-        $bookmarkService->addOrSet($bookmark, false);
-
-        // To preserve backward compatibility with 3rd parties, plugins still use arrays
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        $data = $formatter->format($bookmark);
-        $pluginManager->executeHooks('save_link', $data);
-
-        $bookmark->fromArray($data);
-        $bookmarkService->set($bookmark);
-
-        // If we are called from the bookmarklet, we must close the popup:
-        if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
-            echo '<script>self.close();</script>';
-            exit;
-        }
-
-        $returnurl = !empty($_POST['returnurl']) ? $_POST['returnurl'] : '?';
-        $location = generateLocation($returnurl, $_SERVER['HTTP_HOST'], array('addlink', 'post', 'edit_link'));
-        // Scroll to the link which has been edited.
-        $location .= '#' . $bookmark->getShortUrl();
-        // After saving the link, redirect to the page the user was on.
-        header('Location: '. $location);
-        exit;
-    }
-
-    // -------- User clicked the "Delete" button when editing a link: Delete link from database.
-    if ($targetPage == Router::$PAGE_DELETELINK) {
-        if (! $sessionManager->checkToken($_GET['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        $ids = trim($_GET['lf_linkdate']);
-        if (strpos($ids, ' ') !== false) {
-            // multiple, space-separated ids provided
-            $ids = array_values(array_filter(
-                preg_split('/\s+/', escape($ids)),
-                function ($item) {
-                    return $item !== '';
-                }
-            ));
-        } else {
-            // only a single id provided
-            $shortUrl = $bookmarkService->get($ids)->getShortUrl();
-            $ids = [$ids];
-        }
-        // assert at least one id is given
-        if (!count($ids)) {
-            die('no id provided');
-        }
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        foreach ($ids as $id) {
-            $id = (int) escape($id);
-            $bookmark = $bookmarkService->get($id);
-            $data = $formatter->format($bookmark);
-            $pluginManager->executeHooks('delete_link', $data);
-            $bookmarkService->remove($bookmark, false);
-        }
-        $bookmarkService->save();
-
-        // If we are called from the bookmarklet, we must close the popup:
-        if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) {
-            echo '<script>self.close();</script>';
-            exit;
-        }
-
-        $location = '?';
-        if (isset($_SERVER['HTTP_REFERER'])) {
-            // Don't redirect to where we were previously if it was a permalink or an edit_link, because it would 404.
-            $location = generateLocation(
-                $_SERVER['HTTP_REFERER'],
-                $_SERVER['HTTP_HOST'],
-                ['delete_link', 'edit_link', ! empty($shortUrl) ? $shortUrl : null]
-            );
-        }
-
-        header('Location: ' . $location); // After deleting the link, redirect to appropriate location
-        exit;
-    }
-
-    // -------- User clicked either "Set public" or "Set private" bulk operation
-    if ($targetPage == Router::$PAGE_CHANGE_VISIBILITY) {
-        if (! $sessionManager->checkToken($_GET['token'])) {
-            die(t('Wrong token.'));
-        }
-
-        $ids = trim($_GET['ids']);
-        if (strpos($ids, ' ') !== false) {
-            // multiple, space-separated ids provided
-            $ids = array_values(array_filter(preg_split('/\s+/', escape($ids))));
-        } else {
-            // only a single id provided
-            $ids = [$ids];
-        }
-
-        // assert at least one id is given
-        if (!count($ids)) {
-            die('no id provided');
-        }
-        // assert that the visibility is valid
-        if (!isset($_GET['newVisibility']) || !in_array($_GET['newVisibility'], ['public', 'private'])) {
-            die('invalid visibility');
-        } else {
-            $private = $_GET['newVisibility'] === 'private';
-        }
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        foreach ($ids as $id) {
-            $id = (int) escape($id);
-            $bookmark = $bookmarkService->get($id);
-            $bookmark->setPrivate($private);
-
-            // To preserve backward compatibility with 3rd parties, plugins still use arrays
-            $data = $formatter->format($bookmark);
-            $pluginManager->executeHooks('save_link', $data);
-            $bookmark->fromArray($data);
-
-            $bookmarkService->set($bookmark);
-        }
-        $bookmarkService->save();
-
-        $location = '?';
-        if (isset($_SERVER['HTTP_REFERER'])) {
-            $location = generateLocation(
-                $_SERVER['HTTP_REFERER'],
-                $_SERVER['HTTP_HOST']
-            );
-        }
-        header('Location: ' . $location); // After deleting the link, redirect to appropriate location
-        exit;
-    }
-
-    // -------- User clicked the "EDIT" button on a link: Display link edit form.
-    if (isset($_GET['edit_link'])) {
-        $id = (int) escape($_GET['edit_link']);
-        try {
-            $link = $bookmarkService->get($id);  // Read database
-        } catch (BookmarkNotFoundException $e) {
-            // Link not found in database.
-            header('Location: ?');
-            exit;
-        }
-
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        $formatter = $factory->getFormatter('raw');
-        $formattedLink = $formatter->format($link);
-        $tags = $bookmarkService->bookmarksCountPerTag();
-        if ($conf->get('formatter') === 'markdown') {
-            $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
-        }
-        $data = array(
-            'link' => $formattedLink,
-            'link_is_new' => false,
-            'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
-            'tags' => $tags,
-        );
-        $pluginManager->executeHooks('render_editlink', $data);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->assign('pagetitle', t('Edit') .' '. t('Shaare') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('editlink');
-        exit;
-    }
-
-    // -------- User want to post a new link: Display link edit form.
-    if (isset($_GET['post'])) {
-        $url = cleanup_url($_GET['post']);
-
-        $link_is_new = false;
-        // Check if URL is not already in database (in this case, we will edit the existing link)
-        $bookmark = $bookmarkService->findByUrl($url);
-        if (! $bookmark) {
-            $link_is_new = true;
-            // Get title if it was provided in URL (by the bookmarklet).
-            $title = empty($_GET['title']) ? '' : escape($_GET['title']);
-            // Get description if it was provided in URL (by the bookmarklet). [Bronco added that]
-            $description = empty($_GET['description']) ? '' : escape($_GET['description']);
-            $tags = empty($_GET['tags']) ? '' : escape($_GET['tags']);
-            $private = !empty($_GET['private']) && $_GET['private'] === "1" ? 1 : 0;
-
-            // If this is an HTTP(S) link, we try go get the page to extract
-            // the title (otherwise we will to straight to the edit form.)
-            if (empty($title) && strpos(get_url_scheme($url), 'http') !== false) {
-                $retrieveDescription = $conf->get('general.retrieve_description');
-                // Short timeout to keep the application responsive
-                // The callback will fill $charset and $title with data from the downloaded page.
-                get_http_response(
-                    $url,
-                    $conf->get('general.download_timeout', 30),
-                    $conf->get('general.download_max_size', 4194304),
-                    get_curl_download_callback($charset, $title, $description, $tags, $retrieveDescription)
-                );
-                if (! empty($title) && strtolower($charset) != 'utf-8') {
-                    $title = mb_convert_encoding($title, 'utf-8', $charset);
-                }
-            }
-
-            if ($url == '') {
-                $title = $conf->get('general.default_note_title', t('Note: '));
-            }
-            $url = escape($url);
-            $title = escape($title);
-
-            $link = [
-                'title' => $title,
-                'url' => $url,
-                'description' => $description,
-                'tags' => $tags,
-                'private' => $private,
-            ];
-        } else {
-            $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-            $formatter = $factory->getFormatter('raw');
-            $link = $formatter->format($bookmark);
-        }
-
-        $tags = $bookmarkService->bookmarksCountPerTag();
-        if ($conf->get('formatter') === 'markdown') {
-            $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
-        }
-        $data = [
-            'link' => $link,
-            'link_is_new' => $link_is_new,
-            'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''),
-            'source' => (isset($_GET['source']) ? $_GET['source'] : ''),
-            'tags' => $tags,
-            'default_private_links' => $conf->get('privacy.default_private_links', false),
-        ];
-        $pluginManager->executeHooks('render_editlink', $data);
-
-        foreach ($data as $key => $value) {
-            $PAGE->assign($key, $value);
-        }
-
-        $PAGE->assign('pagetitle', t('Shaare') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('editlink');
-        exit;
-    }
-
-    if ($targetPage == Router::$PAGE_PINLINK) {
-        if (! isset($_GET['id']) || !$bookmarkService->exists($_GET['id'])) {
-            // FIXME! Use a proper error system.
-            $msg = t('Invalid link ID provided');
-            echo '<script>alert("'. $msg .'");document.location=\''. index_url($_SERVER) .'\';</script>';
-            exit;
-        }
-        if (! $sessionManager->checkToken($_GET['token'])) {
-            die('Wrong token.');
-        }
-
-        $link = $bookmarkService->get($_GET['id']);
-        $link->setSticky(! $link->isSticky());
-        $bookmarkService->set($link);
-        header('Location: '.index_url($_SERVER));
-        exit;
-    }
-
-    if ($targetPage == Router::$PAGE_EXPORT) {
-        // Export bookmarks as a Netscape Bookmarks file
-
-        if (empty($_GET['selection'])) {
-            $PAGE->assign('pagetitle', t('Export') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('export');
-            exit;
-        }
-
-        // export as bookmarks_(all|private|public)_YYYYmmdd_HHMMSS.html
-        $selection = $_GET['selection'];
-        if (isset($_GET['prepend_note_url'])) {
-            $prependNoteUrl = $_GET['prepend_note_url'];
-        } else {
-            $prependNoteUrl = false;
-        }
-
-        try {
-            $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-            $formatter = $factory->getFormatter('raw');
-            $PAGE->assign(
-                'links',
-                NetscapeBookmarkUtils::filterAndFormat(
-                    $bookmarkService,
-                    $formatter,
-                    $selection,
-                    $prependNoteUrl,
-                    index_url($_SERVER)
-                )
-            );
-        } catch (Exception $exc) {
-            header('Content-Type: text/plain; charset=utf-8');
-            echo $exc->getMessage();
-            exit;
-        }
-        $now = new DateTime();
-        header('Content-Type: text/html; charset=utf-8');
-        header(
-            'Content-disposition: attachment; filename=bookmarks_'
-            .$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html'
-        );
-        $PAGE->assign('date', $now->format(DateTime::RFC822));
-        $PAGE->assign('eol', PHP_EOL);
-        $PAGE->assign('selection', $selection);
-        $PAGE->renderPage('export.bookmarks');
-        exit;
-    }
-
-    if ($targetPage == Router::$PAGE_IMPORT) {
-        // Upload a Netscape bookmark dump to import its contents
-
-        if (! isset($_POST['token']) || ! isset($_FILES['filetoupload'])) {
-            // Show import dialog
-            $PAGE->assign(
-                'maxfilesize',
-                get_max_upload_size(
-                    ini_get('post_max_size'),
-                    ini_get('upload_max_filesize'),
-                    false
-                )
-            );
-            $PAGE->assign(
-                'maxfilesizeHuman',
-                get_max_upload_size(
-                    ini_get('post_max_size'),
-                    ini_get('upload_max_filesize'),
-                    true
-                )
-            );
-            $PAGE->assign('pagetitle', t('Import') .' - '. $conf->get('general.title', 'Shaarli'));
-            $PAGE->renderPage('import');
-            exit;
-        }
-
-        // Import bookmarks from an uploaded file
-        if (isset($_FILES['filetoupload']['size']) && $_FILES['filetoupload']['size'] == 0) {
-            // The file is too big or some form field may be missing.
-            $msg = sprintf(
-                t(
-                    'The file you are trying to upload is probably bigger than what this webserver can accept'
-                    .' (%s). Please upload in smaller chunks.'
-                ),
-                get_max_upload_size(ini_get('post_max_size'), ini_get('upload_max_filesize'))
-            );
-            echo '<script>alert("'. $msg .'");document.location=\'?do='.Router::$PAGE_IMPORT .'\';</script>';
-            exit;
-        }
-        if (! $sessionManager->checkToken($_POST['token'])) {
-            die('Wrong token.');
-        }
-        $status = NetscapeBookmarkUtils::import(
-            $_POST,
-            $_FILES,
-            $bookmarkService,
-            $conf,
-            $history
-        );
-        echo '<script>alert("'.$status.'");document.location=\'?do='
-             .Router::$PAGE_IMPORT .'\';</script>';
-        exit;
-    }
-
-    // Plugin administration page
-    if ($targetPage == Router::$PAGE_PLUGINSADMIN) {
-        $pluginMeta = $pluginManager->getPluginsMeta();
-
-        // Split plugins into 2 arrays: ordered enabled plugins and disabled.
-        $enabledPlugins = array_filter($pluginMeta, function ($v) {
-            return $v['order'] !== false;
-        });
-        // Load parameters.
-        $enabledPlugins = load_plugin_parameter_values($enabledPlugins, $conf->get('plugins', array()));
-        uasort(
-            $enabledPlugins,
-            function ($a, $b) {
-                return $a['order'] - $b['order'];
-            }
-        );
-        $disabledPlugins = array_filter($pluginMeta, function ($v) {
-            return $v['order'] === false;
-        });
-
-        $PAGE->assign('enabledPlugins', $enabledPlugins);
-        $PAGE->assign('disabledPlugins', $disabledPlugins);
-        $PAGE->assign('pagetitle', t('Plugin administration') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('pluginsadmin');
-        exit;
-    }
-
-    // Plugin administration form action
-    if ($targetPage == Router::$PAGE_SAVE_PLUGINSADMIN) {
-        try {
-            if (isset($_POST['parameters_form'])) {
-                $pluginManager->executeHooks('save_plugin_parameters', $_POST);
-                unset($_POST['parameters_form']);
-                foreach ($_POST as $param => $value) {
-                    $conf->set('plugins.'. $param, escape($value));
-                }
-            } else {
-                $conf->set('general.enabled_plugins', save_plugin_config($_POST));
-            }
-            $conf->write($loginManager->isLoggedIn());
-            $history->updateSettings();
-        } catch (Exception $e) {
-            error_log(
-                'ERROR while saving plugin configuration:.' . PHP_EOL .
-                $e->getMessage()
-            );
-
-            // TODO: do not handle exceptions/errors in JS.
-            echo '<script>alert("'
-                . $e->getMessage()
-                .'");document.location=\'?do='
-                . Router::$PAGE_PLUGINSADMIN
-                .'\';</script>';
-            exit;
-        }
-        header('Location: ?do='. Router::$PAGE_PLUGINSADMIN);
-        exit;
-    }
-
-    // Get a fresh token
-    if ($targetPage == Router::$GET_TOKEN) {
-        header('Content-Type:text/plain');
-        echo $sessionManager->generateToken();
-        exit;
-    }
-
-    // -------- Thumbnails Update
-    if ($targetPage == Router::$PAGE_THUMBS_UPDATE) {
-        $ids = [];
-        foreach ($bookmarkService->search() as $bookmark) {
-            // A note or not HTTP(S)
-            if ($bookmark->isNote() || ! startsWith(strtolower($bookmark->getUrl()), 'http')) {
-                continue;
-            }
-            $ids[] = $bookmark->getId();
-        }
-        $PAGE->assign('ids', $ids);
-        $PAGE->assign('pagetitle', t('Thumbnails update') .' - '. $conf->get('general.title', 'Shaarli'));
-        $PAGE->renderPage('thumbnails');
-        exit;
-    }
-
-    // -------- Single Thumbnail Update
-    if ($targetPage == Router::$AJAX_THUMB_UPDATE) {
-        if (! isset($_POST['id']) || ! ctype_digit($_POST['id'])) {
-            http_response_code(400);
-            exit;
-        }
-        $id = (int) $_POST['id'];
-        if (! $bookmarkService->exists($id)) {
-            http_response_code(404);
-            exit;
-        }
-        $thumbnailer = new Thumbnailer($conf);
-        $bookmark = $bookmarkService->get($id);
-        $bookmark->setThumbnail($thumbnailer->get($bookmark->getUrl()));
-        $bookmarkService->set($bookmark);
-
-        $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-        echo json_encode($factory->getFormatter('raw')->format($bookmark));
-        exit;
-    }
-
-    // -------- Otherwise, simply display search form and bookmarks:
-    showLinkList($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager);
-    exit;
-}
-
-/**
- * Template for the list of bookmarks (<div id="linklist">)
- * This function fills all the necessary fields in the $PAGE for the template 'linklist.html'
- *
- * @param pageBuilder              $PAGE          pageBuilder instance.
- * @param BookmarkServiceInterface $linkDb        LinkDB instance.
- * @param ConfigManager            $conf          Configuration Manager instance.
- * @param PluginManager            $pluginManager Plugin Manager instance.
- * @param LoginManager             $loginManager  LoginManager instance
- */
-function buildLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager)
-{
-    $factory = new FormatterFactory($conf, $loginManager->isLoggedIn());
-    $formatter = $factory->getFormatter();
-
-    // Used in templates
-    if (isset($_GET['searchtags'])) {
-        if (! empty($_GET['searchtags'])) {
-            $searchtags = escape(normalize_spaces($_GET['searchtags']));
-        } else {
-            $searchtags = false;
-        }
-    } else {
-        $searchtags = '';
-    }
-    $searchterm = !empty($_GET['searchterm']) ? escape(normalize_spaces($_GET['searchterm'])) : '';
-
-    // Smallhash filter
-    if (! empty($_SERVER['QUERY_STRING'])
-        && preg_match('/^[a-zA-Z0-9-_@]{6}($|&|#)/', $_SERVER['QUERY_STRING'])) {
-        try {
-            $linksToDisplay = $linkDb->findByHash($_SERVER['QUERY_STRING']);
-        } catch (BookmarkNotFoundException $e) {
-            $PAGE->render404($e->getMessage());
-            exit;
-        }
-    } else {
-        // Filter bookmarks according search parameters.
-        $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : null;
-        $request = [
-            'searchtags' => $searchtags,
-            'searchterm' => $searchterm,
-        ];
-        $linksToDisplay = $linkDb->search($request, $visibility, false, !empty($_SESSION['untaggedonly']));
-    }
-
-    // ---- Handle paging.
-    $keys = array();
-    foreach ($linksToDisplay as $key => $value) {
-        $keys[] = $key;
-    }
-
-    // Select articles according to paging.
-    $pagecount = ceil(count($keys) / $_SESSION['LINKS_PER_PAGE']);
-    $pagecount = $pagecount == 0 ? 1 : $pagecount;
-    $page= empty($_GET['page']) ? 1 : intval($_GET['page']);
-    $page = $page < 1 ? 1 : $page;
-    $page = $page > $pagecount ? $pagecount : $page;
-    // Start index.
-    $i = ($page-1) * $_SESSION['LINKS_PER_PAGE'];
-    $end = $i + $_SESSION['LINKS_PER_PAGE'];
-
-    $thumbnailsEnabled = $conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE;
-    if ($thumbnailsEnabled) {
-        $thumbnailer = new Thumbnailer($conf);
-    }
-
-    $linkDisp = array();
-    while ($i<$end && $i<count($keys)) {
-        $link = $formatter->format($linksToDisplay[$keys[$i]]);
-
-        // Logged in, thumbnails enabled, not a note,
-        // and (never retrieved yet or no valid cache file)
-        if ($loginManager->isLoggedIn()
-            && $thumbnailsEnabled
-            && !$linksToDisplay[$keys[$i]]->isNote()
-            && $linksToDisplay[$keys[$i]]->getThumbnail() !== false
-            && ! is_file($linksToDisplay[$keys[$i]]->getThumbnail())
-        ) {
-            $linksToDisplay[$keys[$i]]->setThumbnail($thumbnailer->get($link['url']));
-            $linkDb->set($linksToDisplay[$keys[$i]], false);
-            $updateDB = true;
-            $link['thumbnail'] = $linksToDisplay[$keys[$i]]->getThumbnail();
-        }
-
-        // Check for both signs of a note: starting with ? and 7 chars long.
-//        if ($link['url'][0] === '?' && strlen($link['url']) === 7) {
-//            $link['url'] = index_url($_SERVER) . $link['url'];
-//        }
-
-        $linkDisp[$keys[$i]] = $link;
-        $i++;
-    }
-
-    // If we retrieved new thumbnails, we update the database.
-    if (!empty($updateDB)) {
-        $linkDb->save();
-    }
+$containerBuilder = new ContainerBuilder($conf, $sessionManager, $cookieManager, $loginManager, $logger);
+$container = $containerBuilder->build();
+$app = new App($container);
 
-    // Compute paging navigation
-    $searchtagsUrl = $searchtags === '' ? '' : '&searchtags=' . urlencode($searchtags);
-    $searchtermUrl = empty($searchterm) ? '' : '&searchterm=' . urlencode($searchterm);
-    $previous_page_url = '';
-    if ($i != count($keys)) {
-        $previous_page_url = '?page=' . ($page+1) . $searchtermUrl . $searchtagsUrl;
-    }
-    $next_page_url='';
-    if ($page>1) {
-        $next_page_url = '?page=' . ($page-1) . $searchtermUrl . $searchtagsUrl;
-    }
+// Main Shaarli routes
+$app->group('', function () {
+    $this->get('/install', '\Shaarli\Front\Controller\Visitor\InstallController:index')->setName('displayInstall');
+    $this->get('/install/session-test', '\Shaarli\Front\Controller\Visitor\InstallController:sessionTest');
+    $this->post('/install', '\Shaarli\Front\Controller\Visitor\InstallController:save')->setName('saveInstall');
+
+    /* -- PUBLIC --*/
+    $this->get('/', '\Shaarli\Front\Controller\Visitor\BookmarkListController:index');
+    $this->get('/shaare/{hash}', '\Shaarli\Front\Controller\Visitor\BookmarkListController:permalink');
+    $this->get('/login', '\Shaarli\Front\Controller\Visitor\LoginController:index')->setName('login');
+    $this->post('/login', '\Shaarli\Front\Controller\Visitor\LoginController:login')->setName('processLogin');
+    $this->get('/picture-wall', '\Shaarli\Front\Controller\Visitor\PictureWallController:index');
+    $this->get('/tags/cloud', '\Shaarli\Front\Controller\Visitor\TagCloudController:cloud');
+    $this->get('/tags/list', '\Shaarli\Front\Controller\Visitor\TagCloudController:list');
+    $this->get('/daily', '\Shaarli\Front\Controller\Visitor\DailyController:index');
+    $this->get('/daily-rss', '\Shaarli\Front\Controller\Visitor\DailyController:rss')->setName('rss');
+    $this->get('/feed/atom', '\Shaarli\Front\Controller\Visitor\FeedController:atom')->setName('atom');
+    $this->get('/feed/rss', '\Shaarli\Front\Controller\Visitor\FeedController:rss');
+    $this->get('/open-search', '\Shaarli\Front\Controller\Visitor\OpenSearchController:index');
+
+    $this->get('/add-tag/{newTag}', '\Shaarli\Front\Controller\Visitor\TagController:addTag');
+    $this->get('/remove-tag/{tag}', '\Shaarli\Front\Controller\Visitor\TagController:removeTag');
+    $this->get('/links-per-page', '\Shaarli\Front\Controller\Visitor\PublicSessionFilterController:linksPerPage');
+    $this->get('/untagged-only', '\Shaarli\Front\Controller\Visitor\PublicSessionFilterController:untaggedOnly');
+})->add('\Shaarli\Front\ShaarliMiddleware');
 
-    // Fill all template fields.
-    $data = array(
-        'previous_page_url' => $previous_page_url,
-        'next_page_url' => $next_page_url,
-        'page_current' => $page,
-        'page_max' => $pagecount,
-        'result_count' => count($linksToDisplay),
-        'search_term' => $searchterm,
-        'search_tags' => $searchtags,
-        'visibility' => ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : '',
-        'links' => $linkDisp,
+$app->group('/admin', function () {
+    $this->get('/logout', '\Shaarli\Front\Controller\Admin\LogoutController:index');
+    $this->get('/tools', '\Shaarli\Front\Controller\Admin\ToolsController:index');
+    $this->get('/password', '\Shaarli\Front\Controller\Admin\PasswordController:index');
+    $this->post('/password', '\Shaarli\Front\Controller\Admin\PasswordController:change');
+    $this->get('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:index');
+    $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save');
+    $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index');
+    $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save');
+    $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare');
+    $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm');
+    $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm');
+    $this->get('/shaare/private/{hash}', '\Shaarli\Front\Controller\Admin\ShaareManageController:sharePrivate');
+    $this->post('/shaare-batch', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateBatchForms');
+    $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:save');
+    $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ShaareManageController:deleteBookmark');
+    $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ShaareManageController:changeVisibility');
+    $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ShaareManageController:pinBookmark');
+    $this->patch(
+        '/shaare/{id:[0-9]+}/update-thumbnail',
+        '\Shaarli\Front\Controller\Admin\ThumbnailsController:ajaxUpdate'
     );
+    $this->get('/export', '\Shaarli\Front\Controller\Admin\ExportController:index');
+    $this->post('/export', '\Shaarli\Front\Controller\Admin\ExportController:export');
+    $this->get('/import', '\Shaarli\Front\Controller\Admin\ImportController:index');
+    $this->post('/import', '\Shaarli\Front\Controller\Admin\ImportController:import');
+    $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index');
+    $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
+    $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
+    $this->get('/server', '\Shaarli\Front\Controller\Admin\ServerController:index');
+    $this->get('/clear-cache', '\Shaarli\Front\Controller\Admin\ServerController:clearCache');
+    $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
+    $this->get('/metadata', '\Shaarli\Front\Controller\Admin\MetadataController:ajaxRetrieveTitle');
+    $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
+})->add('\Shaarli\Front\ShaarliAdminMiddleware');
 
-    // If there is only a single link, we change on-the-fly the title of the page.
-    if (count($linksToDisplay) == 1) {
-        $data['pagetitle'] = $linksToDisplay[$keys[0]]->getTitle() .' - '. $conf->get('general.title');
-    } elseif (! empty($searchterm) || ! empty($searchtags)) {
-        $data['pagetitle'] = t('Search: ');
-        $data['pagetitle'] .= ! empty($searchterm) ? $searchterm .' ' : '';
-        $bracketWrap = function ($tag) {
-            return '['. $tag .']';
-        };
-        $data['pagetitle'] .= ! empty($searchtags)
-            ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchtags))).' '
-            : '';
-        $data['pagetitle'] .= '- '. $conf->get('general.title');
-    }
-
-    $pluginManager->executeHooks('render_linklist', $data, array('loggedin' => $loginManager->isLoggedIn()));
-
-    foreach ($data as $key => $value) {
-        $PAGE->assign($key, $value);
-    }
-
-    return;
-}
-
-/**
- * Installation
- * This function should NEVER be called if the file data/config.php exists.
- *
- * @param ConfigManager  $conf           Configuration Manager instance.
- * @param SessionManager $sessionManager SessionManager instance
- * @param LoginManager   $loginManager   LoginManager instance
- */
-function install($conf, $sessionManager, $loginManager)
-{
-    // On free.fr host, make sure the /sessions directory exists, otherwise login will not work.
-    if (endsWith($_SERVER['HTTP_HOST'], '.free.fr') && !is_dir($_SERVER['DOCUMENT_ROOT'].'/sessions')) {
-        mkdir($_SERVER['DOCUMENT_ROOT'].'/sessions', 0705);
-    }
-
-
-    // This part makes sure sessions works correctly.
-    // (Because on some hosts, session.save_path may not be set correctly,
-    // or we may not have write access to it.)
-    if (isset($_GET['test_session'])
-        && ( !isset($_SESSION) || !isset($_SESSION['session_tested']) || $_SESSION['session_tested']!='Working')) {
-        // Step 2: Check if data in session is correct.
-        $msg = t(
-            '<pre>Sessions do not seem to work correctly on your server.<br>'.
-            'Make sure the variable "session.save_path" is set correctly in your PHP config, '.
-            'and that you have write access to it.<br>'.
-            'It currently points to %s.<br>'.
-            'On some browsers, accessing your server via a hostname like \'localhost\' '.
-            'or any custom hostname without a dot causes cookie storage to fail. '.
-            'We recommend accessing your server via it\'s IP address or Fully Qualified Domain Name.<br>'
-        );
-        $msg = sprintf($msg, session_save_path());
-        echo $msg;
-        echo '<br><a href="?">'. t('Click to try again.') .'</a></pre>';
-        die;
-    }
-    if (!isset($_SESSION['session_tested'])) {
-        // Step 1 : Try to store data in session and reload page.
-        $_SESSION['session_tested'] = 'Working';  // Try to set a variable in session.
-        header('Location: '.index_url($_SERVER).'?test_session');  // Redirect to check stored data.
-    }
-    if (isset($_GET['test_session'])) {
-        // Step 3: Sessions are OK. Remove test parameter from URL.
-        header('Location: '.index_url($_SERVER));
-    }
-
-
-    if (!empty($_POST['setlogin']) && !empty($_POST['setpassword'])) {
-        $tz = 'UTC';
-        if (!empty($_POST['continent']) && !empty($_POST['city'])
-            && isTimeZoneValid($_POST['continent'], $_POST['city'])
-        ) {
-            $tz = $_POST['continent'].'/'.$_POST['city'];
-        }
-        $conf->set('general.timezone', $tz);
-        $login = $_POST['setlogin'];
-        $conf->set('credentials.login', $login);
-        $salt = sha1(uniqid('', true) .'_'. mt_rand());
-        $conf->set('credentials.salt', $salt);
-        $conf->set('credentials.hash', sha1($_POST['setpassword'] . $login . $salt));
-        if (!empty($_POST['title'])) {
-            $conf->set('general.title', escape($_POST['title']));
-        } else {
-            $conf->set('general.title', 'Shared bookmarks on '.escape(index_url($_SERVER)));
-        }
-        $conf->set('translation.language', escape($_POST['language']));
-        $conf->set('updates.check_updates', !empty($_POST['updateCheck']));
-        $conf->set('api.enabled', !empty($_POST['enableApi']));
-        $conf->set(
-            'api.secret',
-            generate_api_secret(
-                $conf->get('credentials.login'),
-                $conf->get('credentials.salt')
-            )
-        );
-        try {
-            // Everything is ok, let's create config file.
-            $conf->write($loginManager->isLoggedIn());
-        } catch (Exception $e) {
-            error_log(
-                'ERROR while writing config file after installation.' . PHP_EOL .
-                    $e->getMessage()
-            );
-
-            // TODO: do not handle exceptions/errors in JS.
-            echo '<script>alert("'. $e->getMessage() .'");document.location=\'?\';</script>';
-            exit;
-        }
-
-        $history = new History($conf->get('resource.history'));
-        $bookmarkService = new BookmarkFileService($conf, $history, true);
-        if ($bookmarkService->count() === 0) {
-            $bookmarkService->initialize();
-        }
-
-        echo '<script>alert('
-            .'"Shaarli is now configured. '
-            .'Please enter your login/password and start shaaring your bookmarks!"'
-            .');document.location=\'./login\';</script>';
-        exit;
-    }
-
-    $PAGE = new PageBuilder($conf, $_SESSION, null, $sessionManager->generateToken());
-    list($continents, $cities) = generateTimeZoneData(timezone_identifiers_list(), date_default_timezone_get());
-    $PAGE->assign('continents', $continents);
-    $PAGE->assign('cities', $cities);
-    $PAGE->assign('languages', Languages::getAvailableLanguages());
-    $PAGE->renderPage('install');
-    exit;
-}
-
-if (!isset($_SESSION['LINKS_PER_PAGE'])) {
-    $_SESSION['LINKS_PER_PAGE'] = $conf->get('general.links_per_page', 20);
-}
-
-try {
-    $history = new History($conf->get('resource.history'));
-} catch (Exception $e) {
-    die($e->getMessage());
-}
-
-$linkDb = new BookmarkFileService($conf, $history, $loginManager->isLoggedIn());
-
-if (isset($_SERVER['QUERY_STRING']) && startsWith($_SERVER['QUERY_STRING'], 'do=dailyrss')) {
-    showDailyRSS($linkDb, $conf, $loginManager);
-    exit;
-}
-
-$containerBuilder = new ContainerBuilder($conf, $sessionManager, $loginManager);
-$container = $containerBuilder->build();
-$app = new App($container);
 
 // REST API routes
 $app->group('/api/v1', function () {
@@ -1942,25 +170,12 @@ $app->group('/api/v1', function () {
     $this->get('/history', '\Shaarli\Api\Controllers\HistoryController:getHistory')->setName('getHistory');
 })->add('\Shaarli\Api\ApiMiddleware');
 
-$app->group('', function () {
-    $this->get('/login', '\Shaarli\Front\Controller\LoginController:index')->setName('login');
-})->add('\Shaarli\Front\ShaarliMiddleware');
-
-$response = $app->run(true);
-
-// Hack to make Slim and Shaarli router work together:
-// If a Slim route isn't found and NOT API call, we call renderPage().
-if ($response->getStatusCode() == 404 && strpos($_SERVER['REQUEST_URI'], '/api/v1') === false) {
-    // We use UTF-8 for proper international characters handling.
-    header('Content-Type: text/html; charset=utf-8');
-    renderPage($conf, $pluginManager, $linkDb, $history, $sessionManager, $loginManager);
-} else {
-    $response = $response
-        ->withHeader('Access-Control-Allow-Origin', '*')
-        ->withHeader(
-            'Access-Control-Allow-Headers',
-            'X-Requested-With, Content-Type, Accept, Origin, Authorization'
-        )
-        ->withHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+try {
+    $response = $app->run(true);
     $app->respond($response);
+} catch (Throwable $e) {
+    die(nl2br(
+        'An unexpected error happened, and the error template could not be displayed.' . PHP_EOL . PHP_EOL .
+       exception2text($e)
+    ));
 }
diff --git a/init.php b/init.php
new file mode 100644 (file)
index 0000000..d846271
--- /dev/null
+++ b/init.php
@@ -0,0 +1,86 @@
+<?php
+
+require_once __DIR__ . '/vendor/autoload.php';
+
+use Shaarli\Helper\ApplicationUtils;
+use Shaarli\Security\SessionManager;
+
+// Set 'UTC' as the default timezone if it is not defined in php.ini
+// See http://php.net/manual/en/datetime.configuration.php#ini.date.timezone
+if (date_default_timezone_get() == '') {
+    date_default_timezone_set('UTC');
+}
+
+// High execution time in case of problematic imports/exports.
+ini_set('max_input_time', '60');
+
+// Try to set max upload file size and read
+ini_set('memory_limit', '128M');
+ini_set('post_max_size', '16M');
+ini_set('upload_max_filesize', '16M');
+
+// See all error except warnings
+error_reporting(E_ALL^E_WARNING);
+
+// 3rd-party libraries
+if (! file_exists(__DIR__ . '/vendor/autoload.php')) {
+    header('Content-Type: text/plain; charset=utf-8');
+    echo "Error: missing Composer configuration\n\n"
+        ."If you installed Shaarli through Git or using the development branch,\n"
+        ."please refer to the installation documentation to install PHP"
+        ." dependencies using Composer:\n"
+        ."- https://shaarli.readthedocs.io/en/master/Server-configuration/\n"
+        ."- https://shaarli.readthedocs.io/en/master/Download-and-Installation/";
+    exit;
+}
+
+// Ensure the PHP version is supported
+try {
+    ApplicationUtils::checkPHPVersion('7.1', PHP_VERSION);
+} catch (Exception $exc) {
+    header('Content-Type: text/plain; charset=utf-8');
+    echo $exc->getMessage();
+    exit;
+}
+
+// Force cookie path (but do not change lifetime)
+$cookie = session_get_cookie_params();
+$cookiedir = '';
+if (dirname($_SERVER['SCRIPT_NAME']) != '/') {
+    $cookiedir = dirname($_SERVER["SCRIPT_NAME"]).'/';
+}
+// Set default cookie expiration and path.
+session_set_cookie_params($cookie['lifetime'], $cookiedir, $_SERVER['SERVER_NAME']);
+// Set session parameters on server side.
+// Use cookies to store session.
+ini_set('session.use_cookies', 1);
+// Force cookies for session (phpsessionID forbidden in URL).
+ini_set('session.use_only_cookies', 1);
+// Prevent PHP form using sessionID in URL if cookies are disabled.
+ini_set('session.use_trans_sid', false);
+
+define('SHAARLI_VERSION', ApplicationUtils::getVersion(__DIR__ .'/'. ApplicationUtils::$VERSION_FILE));
+define('SHAARLI_MUTEX_FILE', __FILE__);
+
+session_name('shaarli');
+// Start session if needed (Some server auto-start sessions).
+if (session_status() == PHP_SESSION_NONE) {
+    session_start();
+}
+
+// Regenerate session ID if invalid or not defined in cookie.
+if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) {
+    session_regenerate_id(true);
+    $_COOKIE['shaarli'] = session_id();
+}
+
+// LC_MESSAGES isn't defined without php-intl, in this case use LC_COLLATE locale instead.
+if (! defined('LC_MESSAGES')) {
+    define('LC_MESSAGES', LC_COLLATE);
+}
+
+// Prevent caching on client side or proxy: (yes, it's ugly)
+header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
+header("Cache-Control: no-store, no-cache, must-revalidate");
+header("Cache-Control: post-check=0, pre-check=0", false);
+header("Pragma: no-cache");
index cee2c5fbddd1c00096cd200df3e5f683a44b5359..2e201d03558cfddd9ada09c3c24514a9e16e88ea 100644 (file)
@@ -15,41 +15,25 @@ site_dir: doc/html
 pages:
 - Home: index.md
 - Setup:
-    - Download and Installation: Download-and-Installation.md
-    - Upgrade and migration: Upgrade-and-migration.md
     - Server configuration: Server-configuration.md
-    - Server security: Server-security.md
+    - Installation: Installation.md
+    - Docker: Docker.md
+    - Reverse Proxy: Reverse-proxy.md
+    - Backup and restore: Backup-and-restore.md
     - Shaarli configuration: Shaarli-configuration.md
     - Plugins: Plugins.md
-- Docker:
-    - Docker 101: docker/docker-101.md
-    - Shaarli images: docker/shaarli-images.md
-    - Reverse proxy configuration: docker/reverse-proxy-configuration.md
-    - Docker resources: docker/resources.md
+    - Upgrade and migration: Upgrade-and-migration.md
 - Usage:
-    - Browsing and searching: Browsing-and-searching.md
-    - Sharing content: Sharing-content.md
-    - RSS feeds: RSS-feeds.md
+    - Usage: Usage.md
     - REST API: REST-API.md
-    - Community & Related software: Community-&-Related-software.md
-- Guides:
-    - Install Shaarli on Debian 9 with Docker: guides/install-shaarli-with-debian9-and-docker.md
-    - Backup, restore, import and export: guides/backup-restore-import-export.md
-    - Various hacks: guides/various-hacks.md
+    - Community and Related software: Community-and-related-software.md
 - Development:
-    - Development guidelines: Development-guidelines.md
-    - Continuous integration tools: Continuous-integration-tools.md
-    - GnuPG signature: GnuPG-signature.md
-    - Directory structure: Directory-structure.md
-    - Link Structure: Link-structure.md
-    - 3rd party libraries: 3rd-party-libraries.md
-    - Plugin System: Plugin-System.md
-    - Release Shaarli: Release-Shaarli.md
-    - Versioning and Branches: Versioning-and-Branches.md
-    - Security: Security.md
-    - Static analysis: Static-analysis.md
-    - Translations: Translations.md
-    - Theming: Theming.md
-    - Unit tests: Unit-tests.md
-- FAQ: FAQ.md
+    - Development: dev/Development.md
+    - Versioning: dev/Versioning.md
+    - GnuPG signature: dev/GnuPG-signature.md
+    - Plugin System: dev/Plugin-system.md
+    - Translations: dev/Translations.md
+    - Release Shaarli: dev/Release-Shaarli.md
+    - Theming: dev/Theming.md
+    - Unit tests: dev/Unit-tests.md
 - Troubleshooting: Troubleshooting.md
index f3d9b51eb2271002df8c617636dab110b011401d..b879b22359b719e723708a7fab40e060a0fdef06 100644 (file)
@@ -7,26 +7,28 @@
     "awesomplete": "^1.1.2",
     "blazy": "^1.8.2",
     "fork-awesome": "^1.1.7",
+    "he": "^1.2.0",
     "pure-extras": "^1.0.0",
     "purecss": "^1.0.0"
   },
   "devDependencies": {
-    "babel-core": "^6.26.0",
-    "babel-loader": "^7.1.2",
-    "babel-minify-webpack-plugin": "^0.2.0",
-    "babel-preset-env": "^1.6.1",
-    "css-loader": "^0.28.9",
-    "eslint": "^4.16.0",
-    "eslint-config-airbnb-base": "^12.1.0",
-    "eslint-plugin-import": "^2.8.0",
-    "extract-text-webpack-plugin": "^3.0.2",
+    "@babel/core": "^7.11.6",
+    "@babel/preset-env": "^7.11.5",
+    "babel-loader": "^8.1.0",
+    "css-loader": "^4.3.0",
+    "eslint": "^7.9.0",
+    "eslint-config-airbnb-base": "^14.2.0",
+    "eslint-plugin-import": "^2.22.0",
     "file-loader": "^1.1.6",
-    "node-sass": "^4.12.0",
-    "sass-lint": "^1.12.1",
-    "sass-loader": "^6.0.6",
-    "style-loader": "^0.19.1",
-    "url-loader": "^0.6.2",
-    "webpack": "^3.10.0"
+    "mini-css-extract-plugin": "^0.11.2",
+    "sass": "^1.26.11",
+    "sass-loader": "^10.0.2",
+    "stylelint": "^13.7.1",
+    "stylelint-config-standard": "^20.0.0",
+    "stylelint-scss": "^3.18.0",
+    "terser-webpack-plugin": "^4.2.2",
+    "webpack": "^4.44.2",
+    "webpack-cli": "^3.3.12"
   },
   "scripts": {
     "build": "webpack",
index 8bf4ed46d6cd5afded8fd8d551361b0e93bcef76..ab6ed6de0cc34f2eca752ea293c90b1b45655a4b 100644 (file)
@@ -5,7 +5,7 @@
  * Adds the addlink input on the linklist page.
  */
 
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * When linklist is displayed, add play videos to header's toolbar.
@@ -16,11 +16,11 @@ use Shaarli\Router;
  */
 function hook_addlink_toolbar_render_header($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST && $data['_LOGGEDIN_'] === true) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST && $data['_LOGGEDIN_'] === true) {
         $form = array(
             'attr' => array(
                 'method' => 'GET',
-                'action' => '',
+                'action' => $data['_BASE_PATH_'] . '/admin/shaare',
                 'name'   => 'addform',
                 'class'  => 'addform',
             ),
index ad501f4799b5770b6bd6f77c550c49317ac55bbe..e37d887e85c0ab129fe132c68bdba1725b517611 100644 (file)
@@ -1,5 +1,5 @@
 <span>
   <a href="https://web.archive.org/web/%s">
-    <img class="linklist-plugin-icon" src="plugins/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
+    <img class="linklist-plugin-icon" src="%s/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
   </a>
 </span>
index 0ee1c73c9333ef0fd9635db3ededbc64fb44521e..ed2715322686e82ca4a9373d3417f57babc67da1 100644 (file)
@@ -17,12 +17,15 @@ use Shaarli\Plugin\PluginManager;
 function hook_archiveorg_render_linklist($data)
 {
     $archive_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/archiveorg/archiveorg.html');
+    $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
 
     foreach ($data['links'] as &$value) {
-        if ($value['private'] && preg_match('/^\?[a-zA-Z0-9-_@]{6}($|&|#)/', $value['real_url'])) {
+        $isNote = startsWith($value['real_url'], '/shaare/');
+        if ($value['private'] && $isNote) {
             continue;
         }
-        $archive = sprintf($archive_html, $value['url'], t('View on archive.org'));
+        $url = $isNote ? rtrim(index_url($_SERVER), '/') . $value['real_url'] : $value['real_url'];
+        $archive = sprintf($archive_html, $url, $path, t('View on archive.org'));
         $value['link_plugin'][] = $archive;
     }
 
index 1928cc9f49b527e8aadddf64ed9796d9628bacc7..e1fd5cfbdb19daf547519af2d7f41482c88f7b96 100644 (file)
@@ -15,6 +15,8 @@ const DEFAULT_COLORS_PLACEHOLDERS = [
     'DEFAULT_COLORS_DARK_MAIN',
 ];
 
+const DEFAULT_COLORS_CSS_FILE = '/default_colors/default_colors.css';
+
 /**
  * Display an error if the plugin is active a no color is configured.
  *
@@ -24,58 +26,62 @@ const DEFAULT_COLORS_PLACEHOLDERS = [
  */
 function default_colors_init($conf)
 {
-    $params = '';
+    $params = [];
     foreach (DEFAULT_COLORS_PLACEHOLDERS as $placeholder) {
-        $params .= trim($conf->get('plugins.'. $placeholder, ''));
+        $value = trim($conf->get('plugins.'. $placeholder, ''));
+        if (strlen($value) > 0) {
+            $params[$placeholder] = $value;
+        }
     }
 
     if (empty($params)) {
         $error = t('Default colors plugin error: '.
             'This plugin is active and no custom color is configured.');
-        return array($error);
+        return [$error];
+    }
+
+    // Colors are defined but the custom CSS file does not exist -> generate it
+    if (!file_exists(PluginManager::$PLUGINS_PATH . DEFAULT_COLORS_CSS_FILE)) {
+        default_colors_generate_css_file($params);
     }
 }
 
 /**
- * When plugin parameters are saved, we regenerate the custom CSS file with provided settings.
+ * When linklist is displayed, include default_colors CSS file.
  *
- * @param array $data $_POST array
+ * @param array $data - header data.
  *
- * @return array Updated $_POST array
+ * @return mixed - header data with default_colors CSS file added.
  */
-function hook_default_colors_save_plugin_parameters($data)
+function hook_default_colors_render_includes($data)
 {
     $file = PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css';
-    $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css.template');
-    $content = '';
-    foreach (DEFAULT_COLORS_PLACEHOLDERS as $rule) {
-        $content .= ! empty($data[$rule])
-            ? default_colors_format_css_rule($data, $rule) .';'. PHP_EOL
-            : '';
-    }
-
-    if (! empty($content)) {
-        file_put_contents($file, sprintf($template, $content));
+    if (file_exists($file )) {
+        $data['css_files'][] = $file ;
     }
 
     return $data;
 }
 
 /**
- * When linklist is displayed, include default_colors CSS file.
- *
- * @param array $data - header data.
+ * Regenerate the custom CSS file with provided settings.
  *
- * @return mixed - header data with default_colors CSS file added.
+ * @param array $params Plugin configuration (CSS rules)
  */
-function hook_default_colors_render_includes($data)
+function default_colors_generate_css_file($params): void
 {
     $file = PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css';
-    if (file_exists($file )) {
-        $data['css_files'][] = $file ;
+    $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/default_colors/default_colors.css.template');
+    $content = '';
+    foreach (DEFAULT_COLORS_PLACEHOLDERS as $rule) {
+        $content .= !empty($params[$rule])
+            ? default_colors_format_css_rule($params, $rule) .';'. PHP_EOL
+            : '';
     }
 
-    return $data;
+    if (! empty($content)) {
+        file_put_contents($file, sprintf($template, $content));
+    }
 }
 
 /**
index 8ae1b47969e15dafa1b142c68e98aaf19d8813b4..defb01f7e4457d05f95f0f95e939297300033904 100644 (file)
@@ -16,7 +16,7 @@
 
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * In the footer hook, there is a working example of a translation extension for Shaarli.
@@ -74,7 +74,7 @@ function demo_plugin_init($conf)
 function hook_demo_plugin_render_header($data)
 {
     // Only execute when linklist is rendered.
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         // If loggedin
         if ($data['_LOGGEDIN_'] === true) {
             /*
@@ -118,7 +118,7 @@ function hook_demo_plugin_render_header($data)
         $form = array(
             'attr' => array(
                 'method' => 'GET',
-                'action' => '?',
+                'action' => $data['_BASE_PATH_'] . '/',
                 'class' => 'addform',
             ),
             'inputs' => array(
@@ -441,9 +441,9 @@ function hook_demo_plugin_delete_link($data)
 function hook_demo_plugin_render_feed($data)
 {
     foreach ($data['links'] as &$link) {
-        if ($data['_PAGE_'] == Router::$PAGE_FEED_ATOM) {
+        if ($data['_PAGE_'] == TemplatePage::FEED_ATOM) {
             $link['description'] .= ' - ATOM Feed' ;
-        } elseif ($data['_PAGE_'] == Router::$PAGE_FEED_RSS) {
+        } elseif ($data['_PAGE_'] == TemplatePage::FEED_RSS) {
             $link['description'] .= ' - RSS Feed';
         }
     }
index dab75dd55c5c0b06789da06e688664ef97256b54..d46321633e9fabdc15d714a7356af2dfc23767d3 100644 (file)
@@ -6,7 +6,7 @@
 
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * Display an error everywhere if the plugin is enabled without configuration.
@@ -49,12 +49,12 @@ function hook_isso_render_linklist($data, $conf)
         $isso = sprintf($issoHtml, $issoUrl, $issoUrl, $link['id'], $link['id']);
         $data['plugin_end_zone'][] = $isso;
     } else {
-        $button = '<span><a href="?%s#isso-thread">';
+        $button = '<span><a href="'. ($data['_BASE_PATH_'] ?? '') . '/shaare/%s#isso-thread">';
         // For the default theme we use a FontAwesome icon which is better than an image
         if ($conf->get('resource.theme') === 'default') {
             $button .= '<i class="linklist-plugin-icon fa fa-comment"></i>';
         } else {
-            $button .= '<img class="linklist-plugin-icon" src="plugins/isso/comment.png" ';
+            $button .= '<img class="linklist-plugin-icon" src="'. $data['_ROOT_PATH_'].'/plugins/isso/comment.png" ';
             $button .= 'title="Comment on this shaare" alt="Comments" />';
         }
         $button .= '</a></span>';
@@ -76,7 +76,7 @@ function hook_isso_render_linklist($data, $conf)
  */
 function hook_isso_render_includes($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $data['css_files'][] = PluginManager::$PLUGINS_PATH . '/isso/isso.css';
     }
 
diff --git a/plugins/isso/isso_button.html b/plugins/isso/isso_button.html
deleted file mode 100644 (file)
index 3f82848..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-<span>
-  <a href="?%s#isso-thread">
-    <img class="linklist-plugin-icon" src="plugins/archiveorg/internetarchive.png" title="%s" alt="archive.org" />
-  </a>
-</span>
index ab4be22a46b5ad2a222fb84e1bae5ca4af40e745..32a94e88080c31849b01e1acdcaa10677363eda2 100644 (file)
@@ -8,22 +8,21 @@ This uses code from https://zaius.github.io/youtube_playlist/ and is currently o
 
 #### Installation and setup
 
-This is a default Shaarli plugin, you just have to enable it. See https://shaarli.readthedocs.io/en/master/Shaarli-configuration/
+This is a default Shaarli plugin, you just have to enable it. See [Shaarli configuration](../../doc/md/Shaarli-configuration.md).
 
 
 #### Troubleshooting
 
-If your server has [Content Security Policy](http://content-security-policy.com/) headers enabled, this may prevent the script from loading fully. You should relax the CSP in your server settings. Example CSP rule for apache2:
-
-In `/etc/apache2/conf-available/shaarli-csp.conf`:
+If your server has [Content Security Policy](http://content-security-policy.com/) headers enabled, this may prevent the script from loading fully. You should relax the CSP in your server settings. Example CSP rule for apache2: 
 
 ```apache
 <Directory /path/to/shaarli>
+    # Required for playvideos plugin
     Header set Content-Security-Policy "script-src 'self' 'unsafe-inline' https://www.youtube.com https://s.ytimg.com 'unsafe-eval'"
 </Directory>
 ```
 
-Then run `a2enconf shaarli-csp; service apache2 reload`
+You may place the `Header` directive in the `<Directory...` section of your [webserver configuration](../../doc/md/Server-configuration.md)/virtualhost file, or write the above snippet to `/etc/apache2/conf-available/shaarli-csp.conf`; then run `a2enconf shaarli-csp; service apache2 reload`.
 
 ### License
 ```
index 0341ed593abd19cb0afb5db4a092e4bb34944dd6..91a9c1e554c58437c5862adfd605019865096be7 100644 (file)
@@ -7,7 +7,7 @@
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * When linklist is displayed, add play videos to header's toolbar.
@@ -18,7 +18,7 @@ use Shaarli\Router;
  */
 function hook_playvideos_render_header($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $playvideo = array(
             'attr' => array(
                 'href' => '#',
@@ -42,7 +42,7 @@ function hook_playvideos_render_header($data)
  */
 function hook_playvideos_render_footer($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $data['js_files'][] = PluginManager::$PLUGINS_PATH . '/playvideos/jquery-1.11.2.min.js';
         $data['js_files'][] = PluginManager::$PLUGINS_PATH . '/playvideos/youtube_playlist.js';
     }
index 2878c0505be296b486ab2b9ee4547a2851c7986b..8fe6799ce6d00933445a9b7b7f1652dc13387af8 100644 (file)
@@ -13,7 +13,7 @@ use pubsubhubbub\publisher\Publisher;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Feed\FeedBuilder;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * Plugin init function - set the hub to the default appspot one.
@@ -41,7 +41,7 @@ function pubsubhubbub_init($conf)
  */
 function hook_pubsubhubbub_render_feed($data, $conf)
 {
-    $feedType = $data['_PAGE_'] == Router::$PAGE_FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
+    $feedType = $data['_PAGE_'] == TemplatePage::FEED_RSS ? FeedBuilder::$FEED_RSS : FeedBuilder::$FEED_ATOM;
     $template = file_get_contents(PluginManager::$PLUGINS_PATH . '/pubsubhubbub/hub.'. $feedType .'.xml');
     $data['feed_plugins_header'][] = sprintf($template, $conf->get('plugins.PUBSUBHUB_URL'));
 
@@ -60,8 +60,8 @@ function hook_pubsubhubbub_render_feed($data, $conf)
 function hook_pubsubhubbub_save_link($data, $conf)
 {
     $feeds = array(
-        index_url($_SERVER) .'?do=atom',
-        index_url($_SERVER) .'?do=rss',
+        index_url($_SERVER) .'feed/atom',
+        index_url($_SERVER) .'feed/rss',
     );
 
     $httpPost = function_exists('curl_version') ? false : 'nocurl_http_post';
index c1d237d5d664532a6912e5ee5ba34c80ce59ddea..24fd18baf99aefe2f6f58c7a9225735f6b54ad7e 100644 (file)
@@ -6,7 +6,7 @@
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 /**
  * Add qrcode icon to link_plugin when rendering linklist.
@@ -19,11 +19,12 @@ function hook_qrcode_render_linklist($data)
 {
     $qrcode_html = file_get_contents(PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.html');
 
+    $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
     foreach ($data['links'] as &$value) {
         $qrcode = sprintf(
             $qrcode_html,
             $value['url'],
-            PluginManager::$PLUGINS_PATH
+            $path
         );
         $value['link_plugin'][] = $qrcode;
     }
@@ -40,7 +41,7 @@ function hook_qrcode_render_linklist($data)
  */
 function hook_qrcode_render_footer($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $data['js_files'][] = PluginManager::$PLUGINS_PATH . '/qrcode/shaarli-qrcode.js';
     }
 
@@ -56,7 +57,7 @@ function hook_qrcode_render_footer($data)
  */
 function hook_qrcode_render_includes($data)
 {
-    if ($data['_PAGE_'] == Router::$PAGE_LINKLIST) {
+    if ($data['_PAGE_'] == TemplatePage::LINKLIST) {
         $data['css_files'][] = PluginManager::$PLUGINS_PATH . '/qrcode/qrcode.css';
     }
 
index fe77c4cdc47cbb4ae36471513908e950adfe8d6d..3316d6f63d272b2e5b13215e61aae4a27d19e459 100644 (file)
 
 // Show the QR-Code of a permalink (when the QR-Code icon is clicked).
 function showQrCode(caller,loading)
-{ 
+{
     // Dynamic javascript lib loading: We only load qr.js if the QR code icon is clicked:
     if (typeof(qr) == 'undefined') // Load qr.js only if not present.
     {
         if (!loading)  // If javascript lib is still loading, do not append script to body.
         {
-            var element = document.createElement("script");
-            element.src = "plugins/qrcode/qr-1.1.3.min.js";
+          var basePath = document.querySelector('input[name="js_base_path"]').value;
+          var element = document.createElement("script");
+            element.src = basePath + "/plugins/qrcode/qr-1.1.3.min.js";
             document.body.appendChild(element);
         }
         setTimeout(function() { showQrCode(caller,true);}, 200); // Retry in 200 milliseconds.
@@ -44,7 +45,7 @@ function showQrCode(caller,loading)
 
     // Remove previous qrcode if present.
     removeQrcode();
-    
+
     // Build the div which contains the QR-Code:
     var element = document.createElement('div');
     element.id = 'permalinkQrcode';
@@ -57,11 +58,11 @@ function showQrCode(caller,loading)
         // Damn IE
         element.setAttribute('onclick', 'this.parentNode.removeChild(this);' );
     }
-    
+
     // Build the QR-Code:
     var image = qr.image({size: 8,value: caller.dataset.permalink});
     if (image)
-    { 
+    {
         element.appendChild(image);
         element.innerHTML += "<br>Click to close";
         caller.parentNode.appendChild(element);
@@ -87,4 +88,4 @@ function removeQrcode()
         elem.parentNode.removeChild(elem);
     }
     return false;
-}
\ No newline at end of file
+}
index ea21a51925f33de0986a28550a167559e1406043..c53a04d9c721abdfb98647ecdc611ff78e61068c 100644 (file)
@@ -21,7 +21,7 @@ The directory structure should look like:
 
 To enable the plugin, you can either:
 
-  * enable it in the plugins administration page (`?do=pluginadmin`). 
+  * enable it in the plugins administration page (`/admin/plugins`).
   * add `wallabag` to your list of enabled plugins in `data/config.json.php` (`general.enabled_plugins` section).
 
 ### Configuration
index bc35df08dcde122f0ca11e8fad30dc17e6f16baf..d0df3501d6389b40ce2e1c340675f4debada71ea 100644 (file)
@@ -45,12 +45,14 @@ function hook_wallabag_render_linklist($data, $conf)
     $wallabagHtml = file_get_contents(PluginManager::$PLUGINS_PATH . '/wallabag/wallabag.html');
 
     $linkTitle = t('Save to wallabag');
+    $path = ($data['_ROOT_PATH_'] ?? '') . '/' . PluginManager::$PLUGINS_PATH;
+
     foreach ($data['links'] as &$value) {
         $wallabag = sprintf(
             $wallabagHtml,
             $wallabagInstance->getWallabagUrl(),
             urlencode($value['url']),
-            PluginManager::$PLUGINS_PATH,
+            $path,
             $linkTitle
         );
         $value['link_plugin'][] = $wallabag;
diff --git a/tests/FileUtilsTest.php b/tests/FileUtilsTest.php
deleted file mode 100644 (file)
index 5771917..0000000
+++ /dev/null
@@ -1,110 +0,0 @@
-<?php
-
-namespace Shaarli;
-
-use Exception;
-
-/**
- * Class FileUtilsTest
- *
- * Test file utility class.
- */
-class FileUtilsTest extends \PHPUnit\Framework\TestCase
-{
-    /**
-     * @var string Test file path.
-     */
-    protected static $file = 'sandbox/flat.db';
-
-    /**
-     * Delete test file after every test.
-     */
-    public function tearDown()
-    {
-        @unlink(self::$file);
-    }
-
-    /**
-     * Test writeDB, then readDB with different data.
-     */
-    public function testSimpleWriteRead()
-    {
-        $data = ['blue', 'red'];
-        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
-        $this->assertTrue(startsWith(file_get_contents(self::$file), '<?php /*'));
-        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
-
-        $data = 0;
-        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
-        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
-
-        $data = null;
-        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
-        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
-
-        $data = false;
-        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
-        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
-    }
-
-    /**
-     * File not writable: raise an exception.
-     *
-     * @expectedException Shaarli\Exceptions\IOException
-     * @expectedExceptionMessage Error accessing "sandbox/flat.db"
-     */
-    public function testWriteWithoutPermission()
-    {
-        touch(self::$file);
-        chmod(self::$file, 0440);
-        FileUtils::writeFlatDB(self::$file, null);
-    }
-
-    /**
-     * Folder non existent: raise an exception.
-     *
-     * @expectedException Shaarli\Exceptions\IOException
-     * @expectedExceptionMessage Error accessing "nopefolder"
-     */
-    public function testWriteFolderDoesNotExist()
-    {
-        FileUtils::writeFlatDB('nopefolder/file', null);
-    }
-
-    /**
-     * Folder non writable: raise an exception.
-     *
-     * @expectedException Shaarli\Exceptions\IOException
-     * @expectedExceptionMessage Error accessing "sandbox"
-     */
-    public function testWriteFolderPermission()
-    {
-        chmod(dirname(self::$file), 0555);
-        try {
-            FileUtils::writeFlatDB(self::$file, null);
-        } catch (Exception $e) {
-            chmod(dirname(self::$file), 0755);
-            throw $e;
-        }
-    }
-
-    /**
-     * Read non existent file, use default parameter.
-     */
-    public function testReadNotExistentFile()
-    {
-        $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
-        $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
-    }
-
-    /**
-     * Read non readable file, use default parameter.
-     */
-    public function testReadNotReadable()
-    {
-        touch(self::$file);
-        chmod(self::$file, 0220);
-        $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
-        $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
-    }
-}
index 7189c3a99fb171dd436834252b7243eaa59a3d23..e810104eae23fe3bb3467403b504b9b3622010c8 100644 (file)
@@ -3,10 +3,9 @@
 namespace Shaarli;
 
 use DateTime;
-use Exception;
 use Shaarli\Bookmark\Bookmark;
 
-class HistoryTest extends \PHPUnit\Framework\TestCase
+class HistoryTest extends \Shaarli\TestCase
 {
     /**
      * @var string History file path
@@ -16,7 +15,7 @@ class HistoryTest extends \PHPUnit\Framework\TestCase
     /**
      * Delete history file.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         if (file_exists(self::$historyFilePath)) {
             unlink(self::$historyFilePath);
@@ -44,12 +43,12 @@ class HistoryTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Not writable history file: raise an exception.
-     *
-     * @expectedException Exception
-     * @expectedExceptionMessage History file isn't readable or writable
      */
     public function testConstructNotWritable()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('History file isn\'t readable or writable');
+
         touch(self::$historyFilePath);
         chmod(self::$historyFilePath, 0440);
         $history = new History(self::$historyFilePath);
@@ -58,12 +57,12 @@ class HistoryTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Not parsable history file: raise an exception.
-     *
-     * @expectedException Exception
-     * @expectedExceptionMessageRegExp /Could not parse history file/
      */
     public function testConstructNotParsable()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Could not parse history file/');
+
         file_put_contents(self::$historyFilePath, 'not parsable');
         $history = new History(self::$historyFilePath);
         // gzinflate generates a warning
@@ -90,14 +89,6 @@ class HistoryTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(History::CREATED, $actual['event']);
         $this->assertTrue(new DateTime('-2 seconds') < $actual['datetime']);
         $this->assertEquals(1, $actual['id']);
-
-        $history = new History(self::$historyFilePath);
-        $bookmark = (new Bookmark())->setId('str');
-        $history->addLink($bookmark);
-        $actual = $history->getHistory()[0];
-        $this->assertEquals(History::CREATED, $actual['event']);
-        $this->assertTrue(new DateTime('-2 seconds') < $actual['datetime']);
-        $this->assertEquals('str', $actual['id']);
     }
 
 //    /**
index de83f2913707aef08f52ff08474186e38210c9a8..ce24c160728a833b7dc750d8e5c568c9a07a9206 100644 (file)
@@ -7,7 +7,7 @@ use Shaarli\Config\ConfigManager;
 /**
  * Class LanguagesTest.
  */
-class LanguagesTest extends \PHPUnit\Framework\TestCase
+class LanguagesTest extends \Shaarli\TestCase
 {
     /**
      * @var string Config file path (without extension).
@@ -22,7 +22,7 @@ class LanguagesTest extends \PHPUnit\Framework\TestCase
     /**
      *
      */
-    public function setUp()
+    protected function setUp(): void
     {
         $this->conf = new ConfigManager(self::$configFile);
     }
index 195d959c2fc4c3ab3e30d4a07b9b1ce57105a1f1..efef5e8746ed2b165d4902a1877a550cc007b462 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Plugin;
 
 use Shaarli\Config\ConfigManager;
@@ -6,7 +7,7 @@ use Shaarli\Config\ConfigManager;
 /**
  * Unit tests for Plugins
  */
-class PluginManagerTest extends \PHPUnit\Framework\TestCase
+class PluginManagerTest extends \Shaarli\TestCase
 {
     /**
      * Path to tests plugin.
@@ -25,7 +26,7 @@ class PluginManagerTest extends \PHPUnit\Framework\TestCase
      */
     protected $pluginManager;
 
-    public function setUp()
+    public function setUp(): void
     {
         $conf = new ConfigManager('');
         $this->pluginManager = new PluginManager($conf);
@@ -33,57 +34,88 @@ class PluginManagerTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test plugin loading and hook execution.
-     *
-     * @return void
      */
-    public function testPlugin()
+    public function testPlugin(): void
     {
         PluginManager::$PLUGINS_PATH = self::$pluginPath;
         $this->pluginManager->load(array(self::$pluginName));
 
         $this->assertTrue(function_exists('hook_test_random'));
 
-        $data = array(0 => 'woot');
+        $data = [0 => 'woot'];
         $this->pluginManager->executeHooks('random', $data);
-        $this->assertEquals('woot', $data[1]);
 
-        $data = array(0 => 'woot');
+        static::assertCount(2, $data);
+        static::assertSame('woot', $data[1]);
+
+        $data = [0 => 'woot'];
         $this->pluginManager->executeHooks('random', $data, array('target' => 'test'));
-        $this->assertEquals('page test', $data[1]);
 
-        $data = array(0 => 'woot');
+        static::assertCount(2, $data);
+        static::assertSame('page test', $data[1]);
+
+        $data = [0 => 'woot'];
         $this->pluginManager->executeHooks('random', $data, array('loggedin' => true));
-        $this->assertEquals('loggedin', $data[1]);
+
+        static::assertCount(2, $data);
+        static::assertEquals('loggedin', $data[1]);
+
+        $data = [0 => 'woot'];
+        $this->pluginManager->executeHooks('random', $data, array('loggedin' => null));
+
+        static::assertCount(3, $data);
+        static::assertEquals('loggedin', $data[1]);
+        static::assertArrayHasKey(2, $data);
+        static::assertNull($data[2]);
+    }
+
+    /**
+     * Test plugin loading and hook execution with an error: raise an incompatibility error.
+     */
+    public function testPluginWithPhpError(): void
+    {
+        PluginManager::$PLUGINS_PATH = self::$pluginPath;
+        $this->pluginManager->load(array(self::$pluginName));
+
+        $this->assertTrue(function_exists('hook_test_error'));
+
+        $data = [];
+        $this->pluginManager->executeHooks('error', $data);
+
+        $this->assertRegExp(
+            '/test \[plugin incompatibility\]: Class [\'"]Unknown[\'"] not found/',
+            $this->pluginManager->getErrors()[0]
+        );
     }
 
     /**
      * Test missing plugin loading.
      */
-    public function testPluginNotFound()
+    public function testPluginNotFound(): void
     {
-        $this->pluginManager->load(array());
-        $this->pluginManager->load(array('nope', 'renope'));
+        $this->pluginManager->load([]);
+        $this->pluginManager->load(['nope', 'renope']);
         $this->addToAssertionCount(1);
     }
 
     /**
      * Test plugin metadata loading.
      */
-    public function testGetPluginsMeta()
+    public function testGetPluginsMeta(): void
     {
         PluginManager::$PLUGINS_PATH = self::$pluginPath;
-        $this->pluginManager->load(array(self::$pluginName));
+        $this->pluginManager->load([self::$pluginName]);
 
-        $expectedParameters = array(
-            'pop' => array(
+        $expectedParameters = [
+            'pop' => [
                 'value' => '',
                 'desc'  => 'pop description',
-            ),
-            'hip' => array(
+            ],
+            'hip' => [
                 'value' => '',
                 'desc' => '',
-            ),
-        );
+            ],
+        ];
         $meta = $this->pluginManager->getPluginsMeta();
         $this->assertEquals('test plugin', $meta[self::$pluginName]['description']);
         $this->assertEquals($expectedParameters, $meta[self::$pluginName]['parameters']);
diff --git a/tests/RouterTest.php b/tests/RouterTest.php
deleted file mode 100644 (file)
index 0cd49bb..0000000
+++ /dev/null
@@ -1,509 +0,0 @@
-<?php
-namespace Shaarli;
-
-/**
- * Unit tests for Router
- */
-class RouterTest extends \PHPUnit\Framework\TestCase
-{
-    /**
-     * Test findPage: login page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageLoginValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login', array(), false)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login', array(), 1)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login&stuff', array(), false)
-        );
-    }
-
-    /**
-     * Test findPage: login page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageLoginInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=login', array(), true)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_LOGIN,
-            Router::findPage('do=other', array(), false)
-        );
-    }
-
-    /**
-     * Test findPage: picwall page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPagePicwallValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=picwall', array(), false)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=picwall', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: picwall page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPagePicwallInvalid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=picwall&stuff', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_PICWALL,
-            Router::findPage('do=other', array(), false)
-        );
-    }
-
-    /**
-     * Test findPage: tagcloud page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageTagcloudValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=tagcloud', array(), false)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=tagcloud', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=tagcloud&stuff', array(), false)
-        );
-    }
-
-    /**
-     * Test findPage: tagcloud page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageTagcloudInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_TAGCLOUD,
-            Router::findPage('do=other', array(), false)
-        );
-    }
-
-    /**
-     * Test findPage: linklist page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageLinklistValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('whatever', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('whatever', array(), false)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_LINKLIST,
-            Router::findPage('do=tools', array(), false)
-        );
-    }
-
-    /**
-     * Test findPage: tools page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageToolsValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools&stuff', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: tools page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageToolsInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools', array(), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=tools', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_TOOLS,
-            Router::findPage('do=other', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: changepasswd page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageChangepasswdValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd', array(), true)
-        );
-        $this->assertEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd&stuff', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: changepasswd page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageChangepasswdInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd', array(), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=changepasswd', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_CHANGEPASSWORD,
-            Router::findPage('do=other', array(), true)
-        );
-    }
-    /**
-     * Test findPage: configure page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageConfigureValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure&stuff', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: configure page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageConfigureInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure', array(), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=configure', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_CONFIGURE,
-            Router::findPage('do=other', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: changetag page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageChangetagValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag&stuff', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: changetag page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageChangetagInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag', array(), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=changetag', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_CHANGETAG,
-            Router::findPage('do=other', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: addlink page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageAddlinkValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink&stuff', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: addlink page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageAddlinkInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink', array(), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=addlink', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_ADDLINK,
-            Router::findPage('do=other', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: export page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageExportValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export&stuff', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: export page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageExportInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export', array(), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=export', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_EXPORT,
-            Router::findPage('do=other', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: import page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageImportValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import', array(), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import&stuff', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: import page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageImportInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import', array(), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=import', array(), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_IMPORT,
-            Router::findPage('do=other', array(), true)
-        );
-    }
-
-    /**
-     * Test findPage: editlink page output.
-     * Valid: page should be return.
-     *
-     * @return void
-     */
-    public function testFindPageEditlinkValid()
-    {
-        $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('edit_link' => 1), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('', array('edit_link' => 1), true)
-        );
-
-
-        $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('post' => 1), true)
-        );
-
-        $this->assertEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('post' => 1, 'edit_link' => 1), true)
-        );
-    }
-
-    /**
-     * Test findPage: editlink page output.
-     * Invalid: page shouldn't be return.
-     *
-     * @return void
-     */
-    public function testFindPageEditlinkInvalid()
-    {
-        $this->assertNotEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('edit_link' => 1), false)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array('edit_link' => 1), 1)
-        );
-
-        $this->assertNotEquals(
-            Router::$PAGE_EDITLINK,
-            Router::findPage('whatever', array(), true)
-        );
-    }
-}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644 (file)
index 0000000..781e7aa
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli;
+
+/**
+ * Helper class extending \PHPUnit\Framework\TestCase.
+ * Used to make Shaarli UT run on multiple versions of PHPUnit.
+ */
+class TestCase extends \PHPUnit\Framework\TestCase
+{
+    /**
+     * expectExceptionMessageRegExp has been removed and replaced by expectExceptionMessageMatches in PHPUnit 9.
+     */
+    public function expectExceptionMessageRegExp(string $regularExpression): void
+    {
+        if (method_exists($this, 'expectExceptionMessageMatches')) {
+            $this->expectExceptionMessageMatches($regularExpression);
+        } else {
+            parent::expectExceptionMessageRegExp($regularExpression);
+        }
+    }
+
+    /**
+     * assertContains is now used for iterable, strings should use assertStringContainsString
+     */
+    public function assertContainsPolyfill($expected, $actual, string $message = ''): void
+    {
+        if (is_string($actual) && method_exists($this, 'assertStringContainsString')) {
+            static::assertStringContainsString($expected, $actual, $message);
+        } else {
+            static::assertContains($expected, $actual, $message);
+        }
+    }
+
+    /**
+     * assertNotContains is now used for iterable, strings should use assertStringNotContainsString
+     */
+    public function assertNotContainsPolyfill($expected, $actual, string $message = ''): void
+    {
+        if (is_string($actual) && method_exists($this, 'assertStringNotContainsString')) {
+            static::assertStringNotContainsString($expected, $actual, $message);
+        } else {
+            static::assertNotContains($expected, $actual, $message);
+        }
+    }
+
+    /**
+     * assertFileNotExists has been renamed in assertFileDoesNotExist
+     */
+    public static function assertFileNotExists(string $filename, string $message = ''): void
+    {
+        if (method_exists(TestCase::class, 'assertFileDoesNotExist')) {
+            static::assertFileDoesNotExist($filename, $message);
+        } else {
+            parent::assertFileNotExists($filename, $message);
+        }
+    }
+
+    /**
+     * assertRegExp has been renamed in assertMatchesRegularExpression
+     */
+    public static function assertRegExp(string $pattern, string $string, string $message = ''): void
+    {
+        if (method_exists(TestCase::class, 'assertMatchesRegularExpression')) {
+            static::assertMatchesRegularExpression($pattern, $string, $message);
+        } else {
+            parent::assertRegExp($pattern, $string, $message);
+        }
+    }
+
+    public function isInTestsContext(): bool
+    {
+        return true;
+    }
+}
index c01849f7f089e45e1ca17af6dcb0ee0a3c2e5daf..70519aca066892d04c6dee3c84a18db34ec536de 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace Shaarli;
 
-use PHPUnit\Framework\TestCase;
 use Shaarli\Config\ConfigManager;
 use WebThumbnailer\Application\ConfigManager as WTConfigManager;
 
@@ -30,7 +29,7 @@ class ThumbnailerTest extends TestCase
      */
     protected $conf;
 
-    public function setUp()
+    protected function setUp(): void
     {
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('thumbnails.mode', Thumbnailer::MODE_ALL);
@@ -43,7 +42,7 @@ class ThumbnailerTest extends TestCase
         WTConfigManager::addFile('tests/utils/config/wt.json');
     }
 
-    public function tearDown()
+    protected function tearDown(): void
     {
         $this->rrmdirContent('sandbox/');
     }
index 02bf060f34a0a7a1be177b2ab96d10316c73b869..77862855e77743c16fef50630d82856741badb5d 100644 (file)
@@ -8,14 +8,14 @@ require_once 'application/TimeZone.php';
 /**
  * Unitary tests for timezone utilities
  */
-class TimeZoneTest extends PHPUnit\Framework\TestCase
+class TimeZoneTest extends \Shaarli\TestCase
 {
     /**
      * @var array of timezones
      */
     protected $installedTimezones;
 
-    public function setUp()
+    protected function setUp(): void
     {
         $this->installedTimezones = [
             'Antarctica/Syowa',
index 26d2a6b823170d913be90f3372f220863d8827ca..59dca75f572f3d92948211e72e1487f365598b6c 100644 (file)
@@ -10,7 +10,7 @@ require_once 'application/Languages.php';
 /**
  * Unitary tests for Shaarli utilities
  */
-class UtilsTest extends PHPUnit\Framework\TestCase
+class UtilsTest extends \Shaarli\TestCase
 {
     // Log file
     protected static $testLogFile = 'tests.log';
@@ -26,7 +26,7 @@ class UtilsTest extends PHPUnit\Framework\TestCase
     /**
      * Assign reference data
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
         self::$defaultTimeZone = date_default_timezone_get();
         // Timezone without DST for test consistency
@@ -36,7 +36,7 @@ class UtilsTest extends PHPUnit\Framework\TestCase
     /**
      * Reset the timezone
      */
-    public static function tearDownAfterClass()
+    public static function tearDownAfterClass(): void
     {
         date_default_timezone_set(self::$defaultTimeZone);
     }
@@ -44,7 +44,7 @@ class UtilsTest extends PHPUnit\Framework\TestCase
     /**
      * Resets test data before each test
      */
-    protected function setUp()
+    protected function setUp(): void
     {
         if (file_exists(self::$testLogFile)) {
             unlink(self::$testLogFile);
@@ -63,41 +63,25 @@ class UtilsTest extends PHPUnit\Framework\TestCase
     }
 
     /**
-     * Log a message to a file - IPv4 client address
+     * Format a log a message - IPv4 client address
      */
-    public function testLogmIp4()
+    public function testFormatLogIp4()
     {
-        $logMessage = 'IPv4 client connected';
-        logm(self::$testLogFile, '127.0.0.1', $logMessage);
-        list($date, $ip, $message) = $this->getLastLogEntry();
+        $message = 'IPv4 client connected';
+        $log = format_log($message, '127.0.0.1');
 
-        $this->assertInstanceOf(
-            'DateTime',
-            DateTime::createFromFormat(self::$dateFormat, $date)
-        );
-        $this->assertTrue(
-            filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false
-        );
-        $this->assertEquals($logMessage, $message);
+        static::assertSame('- 127.0.0.1 - IPv4 client connected', $log);
     }
 
     /**
-     * Log a message to a file - IPv6 client address
+     * Format a log a message - IPv6 client address
      */
-    public function testLogmIp6()
+    public function testFormatLogIp6()
     {
-        $logMessage = 'IPv6 client connected';
-        logm(self::$testLogFile, '2001:db8::ff00:42:8329', $logMessage);
-        list($date, $ip, $message) = $this->getLastLogEntry();
+        $message = 'IPv6 client connected';
+        $log = format_log($message, '2001:db8::ff00:42:8329');
 
-        $this->assertInstanceOf(
-            'DateTime',
-            DateTime::createFromFormat(self::$dateFormat, $date)
-        );
-        $this->assertTrue(
-            filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
-        );
-        $this->assertEquals($logMessage, $message);
+        static::assertSame('- 2001:db8::ff00:42:8329 - IPv6 client connected', $log);
     }
 
     /**
index df2fb33a7183e306b7bc53344633d93f15003f5b..86700840b35dfc03ef75e052e0365b4e8ee89e29 100644 (file)
@@ -18,7 +18,7 @@ use Slim\Http\Response;
  *
  * @package Api
  */
-class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
+class ApiMiddlewareTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -26,7 +26,7 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
     protected static $testDatastore = 'sandbox/datastore.php';
 
     /**
-     * @var \ConfigManager instance
+     * @var ConfigManager instance
      */
     protected $conf;
 
@@ -43,7 +43,7 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('api.secret', 'NapoleonWasALizard');
@@ -61,11 +61,58 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
     }
 
+    /**
+     * Invoke the middleware with a valid token
+     */
+    public function testInvokeMiddlewareWithValidToken(): void
+    {
+        $next = function (Request $request, Response $response): Response {
+            return $response;
+        };
+        $mw = new ApiMiddleware($this->container);
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+            'REQUEST_URI' => '/echo',
+            'HTTP_AUTHORIZATION'=> 'Bearer ' . ApiUtilsTest::generateValidJwtToken('NapoleonWasALizard'),
+        ]);
+        $request = Request::createFromEnvironment($env);
+        $response = new Response();
+        /** @var Response $response */
+        $response = $mw($request, $response, $next);
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
+    /**
+     * Invoke the middleware with a valid token
+     * Using specific Apache CGI redirected authorization.
+     */
+    public function testInvokeMiddlewareWithValidTokenFromRedirectedHeader(): void
+    {
+        $next = function (Request $request, Response $response): Response {
+            return $response;
+        };
+
+        $token = 'Bearer ' . ApiUtilsTest::generateValidJwtToken('NapoleonWasALizard');
+        $this->container->environment['REDIRECT_HTTP_AUTHORIZATION'] = $token;
+        $mw = new ApiMiddleware($this->container);
+        $env = Environment::mock([
+            'REQUEST_METHOD' => 'GET',
+            'REQUEST_URI' => '/echo',
+        ]);
+        $request = Request::createFromEnvironment($env);
+        $response = new Response();
+        /** @var Response $response */
+        $response = $mw($request, $response, $next);
+
+        $this->assertEquals(200, $response->getStatusCode());
+    }
+
     /**
      * Invoke the middleware with the API disabled:
      * should return a 401 error Unauthorized.
@@ -109,7 +156,7 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(401, $response->getStatusCode());
         $body = json_decode((string) $response->getBody());
         $this->assertEquals('Not authorized: API is disabled', $body->message);
-        $this->assertContains('ApiAuthorizationException', $body->stacktrace);
+        $this->assertContainsPolyfill('ApiAuthorizationException', $body->stacktrace);
     }
 
     /**
@@ -132,7 +179,7 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(401, $response->getStatusCode());
         $body = json_decode((string) $response->getBody());
         $this->assertEquals('Not authorized: JWT token not provided', $body->message);
-        $this->assertContains('ApiAuthorizationException', $body->stacktrace);
+        $this->assertContainsPolyfill('ApiAuthorizationException', $body->stacktrace);
     }
 
     /**
@@ -157,7 +204,7 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(401, $response->getStatusCode());
         $body = json_decode((string) $response->getBody());
         $this->assertEquals('Not authorized: Token secret must be set in Shaarli\'s administration', $body->message);
-        $this->assertContains('ApiAuthorizationException', $body->stacktrace);
+        $this->assertContainsPolyfill('ApiAuthorizationException', $body->stacktrace);
     }
 
     /**
@@ -180,7 +227,7 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(401, $response->getStatusCode());
         $body = json_decode((string) $response->getBody());
         $this->assertEquals('Not authorized: Invalid JWT header', $body->message);
-        $this->assertContains('ApiAuthorizationException', $body->stacktrace);
+        $this->assertContainsPolyfill('ApiAuthorizationException', $body->stacktrace);
     }
 
     /**
@@ -206,6 +253,6 @@ class ApiMiddlewareTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(401, $response->getStatusCode());
         $body = json_decode((string) $response->getBody());
         $this->assertEquals('Not authorized: Malformed JWT token', $body->message);
-        $this->assertContains('ApiAuthorizationException', $body->stacktrace);
+        $this->assertContainsPolyfill('ApiAuthorizationException', $body->stacktrace);
     }
 }
index 7efec9bb98f6aa74bea0b2d572e7142bb94ad7a9..7a143859529b30b9de9fe35b183925b6c58bd82d 100644 (file)
@@ -8,12 +8,12 @@ use Shaarli\Http\Base64Url;
 /**
  * Class ApiUtilsTest
  */
-class ApiUtilsTest extends \PHPUnit\Framework\TestCase
+class ApiUtilsTest extends \Shaarli\TestCase
 {
     /**
      * Force the timezone for ISO datetimes.
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
         date_default_timezone_set('UTC');
     }
@@ -66,143 +66,143 @@ class ApiUtilsTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test validateJwtToken() with a malformed JWT token.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Malformed JWT token
      */
     public function testValidateJwtTokenMalformed()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Malformed JWT token');
+
         $token = 'ABC.DEF';
         ApiUtils::validateJwtToken($token, 'foo');
     }
 
     /**
      * Test validateJwtToken() with an empty JWT token.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Malformed JWT token
      */
     public function testValidateJwtTokenMalformedEmpty()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Malformed JWT token');
+
         $token = false;
         ApiUtils::validateJwtToken($token, 'foo');
     }
 
     /**
      * Test validateJwtToken() with a JWT token without header.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Malformed JWT token
      */
     public function testValidateJwtTokenMalformedEmptyHeader()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Malformed JWT token');
+
         $token = '.payload.signature';
         ApiUtils::validateJwtToken($token, 'foo');
     }
 
     /**
      * Test validateJwtToken() with a JWT token without payload
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Malformed JWT token
      */
     public function testValidateJwtTokenMalformedEmptyPayload()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Malformed JWT token');
+
         $token = 'header..signature';
         ApiUtils::validateJwtToken($token, 'foo');
     }
 
     /**
      * Test validateJwtToken() with a JWT token with an empty signature.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT signature
      */
     public function testValidateJwtTokenInvalidSignatureEmpty()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT signature');
+
         $token = 'header.payload.';
         ApiUtils::validateJwtToken($token, 'foo');
     }
 
     /**
      * Test validateJwtToken() with a JWT token with an invalid signature.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT signature
      */
     public function testValidateJwtTokenInvalidSignature()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT signature');
+
         $token = 'header.payload.nope';
         ApiUtils::validateJwtToken($token, 'foo');
     }
 
     /**
      * Test validateJwtToken() with a JWT token with a signature generated with the wrong API secret.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT signature
      */
     public function testValidateJwtTokenInvalidSignatureSecret()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT signature');
+
         ApiUtils::validateJwtToken(self::generateValidJwtToken('foo'), 'bar');
     }
 
     /**
      * Test validateJwtToken() with a JWT token with a an invalid header (not JSON).
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT header
      */
     public function testValidateJwtTokenInvalidHeader()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT header');
+
         $token = $this->generateCustomJwtToken('notJSON', '{"JSON":1}', 'secret');
         ApiUtils::validateJwtToken($token, 'secret');
     }
 
     /**
      * Test validateJwtToken() with a JWT token with a an invalid payload (not JSON).
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT payload
      */
     public function testValidateJwtTokenInvalidPayload()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT payload');
+
         $token = $this->generateCustomJwtToken('{"JSON":1}', 'notJSON', 'secret');
         ApiUtils::validateJwtToken($token, 'secret');
     }
 
     /**
      * Test validateJwtToken() with a JWT token without issued time.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT issued time
      */
     public function testValidateJwtTokenInvalidTimeEmpty()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT issued time');
+
         $token = $this->generateCustomJwtToken('{"JSON":1}', '{"JSON":1}', 'secret');
         ApiUtils::validateJwtToken($token, 'secret');
     }
 
     /**
      * Test validateJwtToken() with an expired JWT token.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT issued time
      */
     public function testValidateJwtTokenInvalidTimeExpired()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT issued time');
+
         $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() - 600) . '}', 'secret');
         ApiUtils::validateJwtToken($token, 'secret');
     }
 
     /**
      * Test validateJwtToken() with a JWT token issued in the future.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiAuthorizationException
-     * @expectedExceptionMessage Invalid JWT issued time
      */
     public function testValidateJwtTokenInvalidTimeFuture()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiAuthorizationException::class);
+        $this->expectExceptionMessage('Invalid JWT issued time');
+
         $token = $this->generateCustomJwtToken('{"JSON":1}', '{"iat":' . (time() + 60) . '}', 'secret');
         ApiUtils::validateJwtToken($token, 'secret');
     }
index f4d3b646224d166ecc9412433a709d393741561b..84f8716e1308e92a70cdd530b4c0358570ff4ddd 100644 (file)
@@ -11,7 +11,7 @@ use Slim\Http\Response;
 
 require_once 'tests/utils/ReferenceHistory.php';
 
-class HistoryTest extends \PHPUnit\Framework\TestCase
+class HistoryTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -41,7 +41,7 @@ class HistoryTest extends \PHPUnit\Framework\TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->refHistory = new \ReferenceHistory();
@@ -57,7 +57,7 @@ class HistoryTest extends \PHPUnit\Framework\TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testHistory);
     }
index b5c938e1bf9f82517596de57129d28139d0eaf8a..10b29ab2530bdf9c9752aef9c149ba7c6feeebed 100644 (file)
@@ -1,10 +1,11 @@
 <?php
 namespace Shaarli\Api\Controllers;
 
-use PHPUnit\Framework\TestCase;
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
+use Shaarli\TestCase;
 use Slim\Container;
 use Slim\Http\Environment;
 use Slim\Http\Request;
@@ -47,8 +48,9 @@ class InfoTest extends TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -58,7 +60,7 @@ class InfoTest extends TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Info($this->container);
@@ -67,7 +69,7 @@ class InfoTest extends TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
     }
index 6c2b36988c6741128934c3538d14b1496b3cc7b5..805c9be33be49c0523ef23311d930c356b252c11 100644 (file)
@@ -3,6 +3,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
@@ -11,7 +12,7 @@ use Slim\Http\Environment;
 use Slim\Http\Request;
 use Slim\Http\Response;
 
-class DeleteLinkTest extends \PHPUnit\Framework\TestCase
+class DeleteLinkTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -53,11 +54,15 @@ class DeleteLinkTest extends \PHPUnit\Framework\TestCase
      */
     protected $controller;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Before each test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $this->mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -65,7 +70,7 @@ class DeleteLinkTest extends \PHPUnit\Framework\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -78,7 +83,7 @@ class DeleteLinkTest extends \PHPUnit\Framework\TestCase
     /**
      * After each test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
         @unlink(self::$testHistory);
@@ -100,7 +105,7 @@ class DeleteLinkTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(204, $response->getStatusCode());
         $this->assertEmpty((string) $response->getBody());
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->assertFalse($this->bookmarkService->exists($id));
 
         $historyEntry = $this->history->getHistory()[0];
@@ -113,11 +118,11 @@ class DeleteLinkTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test DELETE link endpoint: reach not existing ID.
-     *
-     * @expectedException \Shaarli\Api\Exceptions\ApiLinkNotFoundException
      */
     public function testDeleteLink404()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiLinkNotFoundException::class);
+
         $id = -1;
         $this->assertFalse($this->bookmarkService->exists($id));
         $env = Environment::mock([
index c26411ac57d11963f9b091609b5492067b37b03b..1ec56ef3cd97c870fc52b7bbb54875178c52a6ea 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
@@ -20,7 +21,7 @@ use Slim\Http\Response;
  *
  * @package Shaarli\Api\Controllers
  */
-class GetLinkIdTest extends \PHPUnit\Framework\TestCase
+class GetLinkIdTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -55,8 +56,9 @@ class GetLinkIdTest extends \PHPUnit\Framework\TestCase
     /**
      * Before each test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -65,7 +67,7 @@ class GetLinkIdTest extends \PHPUnit\Framework\TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Links($this->container);
@@ -74,7 +76,7 @@ class GetLinkIdTest extends \PHPUnit\Framework\TestCase
     /**
      * After each test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
     }
@@ -102,7 +104,7 @@ class GetLinkIdTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals($id, $data['id']);
 
         // Check link elements
-        $this->assertEquals('http://domain.tld/?WDWyig', $data['url']);
+        $this->assertEquals('http://domain.tld/shaare/WDWyig', $data['url']);
         $this->assertEquals('WDWyig', $data['shorturl']);
         $this->assertEquals('Link title: @website', $data['title']);
         $this->assertEquals(
@@ -120,12 +122,12 @@ class GetLinkIdTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test basic getLink service: get non existent link => ApiLinkNotFoundException.
-     *
-     * @expectedException Shaarli\Api\Exceptions\ApiLinkNotFoundException
-     * @expectedExceptionMessage Link not found
      */
     public function testGetLink404()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiLinkNotFoundException::class);
+        $this->expectExceptionMessage('Link not found');
+
         $env = Environment::mock([
             'REQUEST_METHOD' => 'GET',
         ]);
index 4e2d55ac2f4fe89ff90ed5e560ec9f5d70787ffb..b1c46ee2816853b45a5a8abfcac28f8c71f4b973 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
@@ -20,7 +21,7 @@ use Slim\Http\Response;
  *
  * @package Shaarli\Api\Controllers
  */
-class GetLinksTest extends \PHPUnit\Framework\TestCase
+class GetLinksTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -55,8 +56,9 @@ class GetLinksTest extends \PHPUnit\Framework\TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -65,7 +67,7 @@ class GetLinksTest extends \PHPUnit\Framework\TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Links($this->container);
@@ -74,7 +76,7 @@ class GetLinksTest extends \PHPUnit\Framework\TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
     }
@@ -109,7 +111,7 @@ class GetLinksTest extends \PHPUnit\Framework\TestCase
 
         // Check first element fields
         $first = $data[2];
-        $this->assertEquals('http://domain.tld/?WDWyig', $first['url']);
+        $this->assertEquals('http://domain.tld/shaare/WDWyig', $first['url']);
         $this->assertEquals('WDWyig', $first['shorturl']);
         $this->assertEquals('Link title: @website', $first['title']);
         $this->assertEquals(
@@ -396,7 +398,7 @@ class GetLinksTest extends \PHPUnit\Framework\TestCase
         $response = $this->controller->getLinks($request, new Response());
         $this->assertEquals(200, $response->getStatusCode());
         $data = json_decode((string) $response->getBody(), true);
-        $this->assertEquals(4, count($data));
+        $this->assertEquals(5, count($data));
         $this->assertEquals(6, $data[0]['id']);
 
         // wildcard: placeholder at the middle
index 969b9fd9a3cbbb1a51fa0297294eaffa4dabf80e..e12f803be3ce99004294b926e4e14dd7b98f0ba1 100644 (file)
@@ -2,11 +2,12 @@
 
 namespace Shaarli\Api\Controllers;
 
-use PHPUnit\Framework\TestCase;
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
+use Shaarli\TestCase;
 use Slim\Container;
 use Slim\Http\Environment;
 use Slim\Http\Request;
@@ -70,8 +71,9 @@ class PostLinkTest extends TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -79,7 +81,7 @@ class PostLinkTest extends TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -107,7 +109,7 @@ class PostLinkTest extends TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
         @unlink(self::$testHistory);
@@ -131,8 +133,8 @@ class PostLinkTest extends TestCase
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
         $this->assertRegExp('/[\w_-]{6}/', $data['shorturl']);
-        $this->assertEquals('http://domain.tld/?' . $data['shorturl'], $data['url']);
-        $this->assertEquals('?' . $data['shorturl'], $data['title']);
+        $this->assertEquals('http://domain.tld/shaare/' . $data['shorturl'], $data['url']);
+        $this->assertEquals('/shaare/' . $data['shorturl'], $data['title']);
         $this->assertEquals('', $data['description']);
         $this->assertEquals([], $data['tags']);
         $this->assertEquals(true, $data['private']);
@@ -160,6 +162,8 @@ class PostLinkTest extends TestCase
             'description' => 'shaare description',
             'tags' => ['one', 'two'],
             'private' => true,
+            'created' => '2015-05-05T12:30:00+03:00',
+            'updated' => '2016-06-05T14:32:10+03:00',
         ];
         $env = Environment::mock([
             'REQUEST_METHOD' => 'POST',
@@ -181,10 +185,8 @@ class PostLinkTest extends TestCase
         $this->assertEquals($link['description'], $data['description']);
         $this->assertEquals($link['tags'], $data['tags']);
         $this->assertEquals(true, $data['private']);
-        $this->assertTrue(
-            new \DateTime('2 seconds ago') < \DateTime::createFromFormat(\DateTime::ATOM, $data['created'])
-        );
-        $this->assertEquals('', $data['updated']);
+        $this->assertSame($link['created'], $data['created']);
+        $this->assertSame($link['updated'], $data['updated']);
     }
 
     /**
index cb63742e626bf1af07aea2542fd0443c811daef2..240ee323a345cff812cc9ad529ea4ebe25f1ca7a 100644 (file)
@@ -3,6 +3,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Config\ConfigManager;
@@ -12,7 +13,7 @@ use Slim\Http\Environment;
 use Slim\Http\Request;
 use Slim\Http\Response;
 
-class PutLinkTest extends \PHPUnit\Framework\TestCase
+class PutLinkTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -62,8 +63,9 @@ class PutLinkTest extends \PHPUnit\Framework\TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -71,7 +73,7 @@ class PutLinkTest extends \PHPUnit\Framework\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -91,7 +93,7 @@ class PutLinkTest extends \PHPUnit\Framework\TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
         @unlink(self::$testHistory);
@@ -114,8 +116,8 @@ class PutLinkTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals($id, $data['id']);
         $this->assertEquals('WDWyig', $data['shorturl']);
-        $this->assertEquals('http://domain.tld/?WDWyig', $data['url']);
-        $this->assertEquals('?WDWyig', $data['title']);
+        $this->assertEquals('http://domain.tld/shaare/WDWyig', $data['url']);
+        $this->assertEquals('/shaare/WDWyig', $data['title']);
         $this->assertEquals('', $data['description']);
         $this->assertEquals([], $data['tags']);
         $this->assertEquals(true, $data['private']);
@@ -218,12 +220,12 @@ class PutLinkTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test link update on non existent link => ApiLinkNotFoundException.
-     *
-     * @expectedException Shaarli\Api\Exceptions\ApiLinkNotFoundException
-     * @expectedExceptionMessage Link not found
      */
     public function testGetLink404()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiLinkNotFoundException::class);
+        $this->expectExceptionMessage('Link not found');
+
         $env = Environment::mock([
             'REQUEST_METHOD' => 'PUT',
         ]);
index c67488720b7b2617f8b6b509d1eef4036ed3dd58..37f0722978c2e2ee4fc2a2a45589b4fd28f1ffe0 100644 (file)
@@ -3,6 +3,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
@@ -12,7 +13,7 @@ use Slim\Http\Environment;
 use Slim\Http\Request;
 use Slim\Http\Response;
 
-class DeleteTagTest extends \PHPUnit\Framework\TestCase
+class DeleteTagTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -54,11 +55,15 @@ class DeleteTagTest extends \PHPUnit\Framework\TestCase
      */
     protected $controller;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Before each test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $this->mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -66,7 +71,7 @@ class DeleteTagTest extends \PHPUnit\Framework\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -79,7 +84,7 @@ class DeleteTagTest extends \PHPUnit\Framework\TestCase
     /**
      * After each test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
         @unlink(self::$testHistory);
@@ -102,7 +107,7 @@ class DeleteTagTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(204, $response->getStatusCode());
         $this->assertEmpty((string) $response->getBody());
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $tags = $this->bookmarkService->bookmarksCountPerTag();
         $this->assertFalse(isset($tags[$tagName]));
 
@@ -136,7 +141,7 @@ class DeleteTagTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(204, $response->getStatusCode());
         $this->assertEmpty((string) $response->getBody());
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $tags = $this->bookmarkService->bookmarksCountPerTag();
         $this->assertFalse(isset($tags[$tagName]));
         $this->assertTrue($tags[strtolower($tagName)] > 0);
@@ -150,12 +155,12 @@ class DeleteTagTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test DELETE tag endpoint: reach not existing tag.
-     *
-     * @expectedException Shaarli\Api\Exceptions\ApiTagNotFoundException
-     * @expectedExceptionMessage Tag not found
      */
     public function testDeleteLink404()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiTagNotFoundException::class);
+        $this->expectExceptionMessage('Tag not found');
+
         $tagName = 'nopenope';
         $tags = $this->bookmarkService->bookmarksCountPerTag();
         $this->assertFalse(isset($tags[$tagName]));
index b9a81f9bd9514ada7352326910a2ec1cc3639a41..878de5a4202d2dbb5a2e54ec2f29a719cc8a5412 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
@@ -18,7 +19,7 @@ use Slim\Http\Response;
  *
  * @package Shaarli\Api\Controllers
  */
-class GetTagNameTest extends \PHPUnit\Framework\TestCase
+class GetTagNameTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -53,8 +54,9 @@ class GetTagNameTest extends \PHPUnit\Framework\TestCase
     /**
      * Before each test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -63,7 +65,7 @@ class GetTagNameTest extends \PHPUnit\Framework\TestCase
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
-        $this->container['db'] = new BookmarkFileService($this->conf, $history, true);
+        $this->container['db'] = new BookmarkFileService($this->conf, $history, $mutex, true);
         $this->container['history'] = null;
 
         $this->controller = new Tags($this->container);
@@ -72,7 +74,7 @@ class GetTagNameTest extends \PHPUnit\Framework\TestCase
     /**
      * After each test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
     }
@@ -117,12 +119,12 @@ class GetTagNameTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test basic getTag service: get non existent tag => ApiTagNotFoundException.
-     *
-     * @expectedException Shaarli\Api\Exceptions\ApiTagNotFoundException
-     * @expectedExceptionMessage Tag not found
      */
     public function testGetTag404()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiTagNotFoundException::class);
+        $this->expectExceptionMessage('Tag not found');
+
         $env = Environment::mock([
             'REQUEST_METHOD' => 'GET',
         ]);
index 53a3326d58fa0d97d51631bca83859c6cda749d9..b565a8c4d3620eba8282d871c3c63b5094815d3b 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
@@ -17,7 +18,7 @@ use Slim\Http\Response;
  *
  * @package Shaarli\Api\Controllers
  */
-class GetTagsTest extends \PHPUnit\Framework\TestCase
+class GetTagsTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -57,15 +58,16 @@ class GetTagsTest extends \PHPUnit\Framework\TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
         $this->refDB->write(self::$testDatastore);
         $history = new History('sandbox/history.php');
 
-        $this->bookmarkService = new BookmarkFileService($this->conf, $history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -78,7 +80,7 @@ class GetTagsTest extends \PHPUnit\Framework\TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
     }
index 2a3cc15a0d05e1ff5c21f2c6837cc222572cdbda..c73f6d3beaec758d1318599c46b9b477bdd66189 100644 (file)
@@ -2,6 +2,7 @@
 
 namespace Shaarli\Api\Controllers;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Api\Exceptions\ApiBadParametersException;
 use Shaarli\Bookmark\BookmarkFileService;
 use Shaarli\Bookmark\LinkDB;
@@ -12,7 +13,7 @@ use Slim\Http\Environment;
 use Slim\Http\Request;
 use Slim\Http\Response;
 
-class PutTagTest extends \PHPUnit\Framework\TestCase
+class PutTagTest extends \Shaarli\TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -62,8 +63,9 @@ class PutTagTest extends \PHPUnit\Framework\TestCase
     /**
      * Before every test, instantiate a new Api with its config, plugins and bookmarks.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->refDB = new \ReferenceLinkDB();
@@ -71,7 +73,7 @@ class PutTagTest extends \PHPUnit\Framework\TestCase
         $refHistory = new \ReferenceHistory();
         $refHistory->write(self::$testHistory);
         $this->history = new History(self::$testHistory);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
 
         $this->container = new Container();
         $this->container['conf'] = $this->conf;
@@ -84,7 +86,7 @@ class PutTagTest extends \PHPUnit\Framework\TestCase
     /**
      * After every test, remove the test datastore.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$testDatastore);
         @unlink(self::$testHistory);
@@ -159,12 +161,12 @@ class PutTagTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test tag update with an empty new tag name => ApiBadParametersException
-     *
-     * @expectedException Shaarli\Api\Exceptions\ApiBadParametersException
-     * @expectedExceptionMessage New tag name is required in the request body
      */
     public function testPutTagEmpty()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiBadParametersException::class);
+        $this->expectExceptionMessage('New tag name is required in the request body');
+
         $tagName = 'gnu';
         $newName = '';
 
@@ -194,12 +196,12 @@ class PutTagTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test tag update on non existent tag => ApiTagNotFoundException.
-     *
-     * @expectedException Shaarli\Api\Exceptions\ApiTagNotFoundException
-     * @expectedExceptionMessage Tag not found
      */
     public function testPutTag404()
     {
+        $this->expectException(\Shaarli\Api\Exceptions\ApiTagNotFoundException::class);
+        $this->expectExceptionMessage('Tag not found');
+
         $env = Environment::mock([
             'REQUEST_METHOD' => 'PUT',
         ]);
index 0f8f04c5ad2d9e7e5aab348c4704f764f7fdacda..1953078cd985a0c75aaa39a6464b7e833d8d2912 100644 (file)
@@ -2,10 +2,7 @@
 
 namespace Shaarli\Bookmark;
 
-use PHPUnit\Framework\TestCase;
-use Shaarli\Bookmark\Exception\InvalidBookmarkException;
-use Shaarli\Config\ConfigManager;
-use Shaarli\History;
+use Shaarli\TestCase;
 
 /**
  * Class BookmarkArrayTest
@@ -47,22 +44,22 @@ class BookmarkArrayTest extends TestCase
 
     /**
      * Test adding a bad entry: wrong type
-     *
-     * @expectedException Shaarli\Bookmark\Exception\InvalidBookmarkException
      */
     public function testArrayAccessAddBadEntryInstance()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\InvalidBookmarkException::class);
+
         $array = new BookmarkArray();
         $array[] = 'nope';
     }
 
     /**
      * Test adding a bad entry: no id
-     *
-     * @expectedException Shaarli\Bookmark\Exception\InvalidBookmarkException
      */
     public function testArrayAccessAddBadEntryNoId()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\InvalidBookmarkException::class);
+
         $array = new BookmarkArray();
         $bookmark = new Bookmark();
         $array[] = $bookmark;
@@ -70,11 +67,11 @@ class BookmarkArrayTest extends TestCase
 
     /**
      * Test adding a bad entry: no url
-     *
-     * @expectedException Shaarli\Bookmark\Exception\InvalidBookmarkException
      */
     public function testArrayAccessAddBadEntryNoUrl()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\InvalidBookmarkException::class);
+
         $array = new BookmarkArray();
         $bookmark = (new Bookmark())->setId(11);
         $array[] = $bookmark;
@@ -82,37 +79,24 @@ class BookmarkArrayTest extends TestCase
 
     /**
      * Test adding a bad entry: invalid offset
-     *
-     * @expectedException Shaarli\Bookmark\Exception\InvalidBookmarkException
      */
     public function testArrayAccessAddBadEntryOffset()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\InvalidBookmarkException::class);
+
         $array = new BookmarkArray();
         $bookmark = (new Bookmark())->setId(11);
         $bookmark->validate();
         $array['nope'] = $bookmark;
     }
 
-    /**
-     * Test adding a bad entry: invalid ID type
-     *
-     * @expectedException Shaarli\Bookmark\Exception\InvalidBookmarkException
-     */
-    public function testArrayAccessAddBadEntryIdType()
-    {
-        $array = new BookmarkArray();
-        $bookmark = (new Bookmark())->setId('nope');
-        $bookmark->validate();
-        $array[] = $bookmark;
-    }
-
     /**
      * Test adding a bad entry: ID/offset not consistent
-     *
-     * @expectedException Shaarli\Bookmark\Exception\InvalidBookmarkException
      */
     public function testArrayAccessAddBadEntryIdOffset()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\InvalidBookmarkException::class);
+
         $array = new BookmarkArray();
         $bookmark = (new Bookmark())->setId(11);
         $bookmark->validate();
index 4900d41d80598b320e71784ff555739fb6444bdf..f619aff3f7865d7aebddcd4fb06622b52521b2e0 100644 (file)
@@ -6,7 +6,7 @@
 namespace Shaarli\Bookmark;
 
 use DateTime;
-use PHPUnit\Framework\TestCase;
+use malkusch\lock\mutex\NoMutex;
 use ReferenceLinkDB;
 use ReflectionClass;
 use Shaarli;
@@ -14,6 +14,7 @@ use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Formatter\BookmarkMarkdownFormatter;
 use Shaarli\History;
+use Shaarli\TestCase;
 
 /**
  * Unitary tests for LegacyLinkDBTest
@@ -52,6 +53,9 @@ class BookmarkFileServiceTest extends TestCase
      */
     protected $privateLinkDB = null;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Instantiates public and private LinkDBs with test data
      *
@@ -66,8 +70,10 @@ class BookmarkFileServiceTest extends TestCase
      *
      * Resets test data for each test
      */
-    protected function setUp()
+    protected function setUp(): void
     {
+        $this->mutex = new NoMutex();
+
         if (file_exists(self::$testDatastore)) {
             unlink(self::$testDatastore);
         }
@@ -87,8 +93,8 @@ class BookmarkFileServiceTest extends TestCase
         $this->refDB = new \ReferenceLinkDB();
         $this->refDB->write(self::$testDatastore);
         $this->history = new History('sandbox/history.php');
-        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, false);
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
     }
 
     /**
@@ -105,7 +111,7 @@ class BookmarkFileServiceTest extends TestCase
         $db = self::getMethod('migrate');
         $db->invokeArgs($this->privateLinkDB, []);
 
-        $db = new \FakeBookmarkService($this->conf, $this->history, true);
+        $db = new \FakeBookmarkService($this->conf, $this->history, $this->mutex, true);
         $this->assertInstanceOf(BookmarkArray::class, $db->getBookmarks());
         $this->assertEquals($this->refDB->countLinks(), $db->count());
     }
@@ -134,11 +140,11 @@ class BookmarkFileServiceTest extends TestCase
 
     /**
      * Test get() method for an undefined bookmark
-     *
-     * @expectedException Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testGetUndefined()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         $this->privateLinkDB->get(666);
     }
 
@@ -174,7 +180,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($updated, $bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
@@ -200,7 +206,7 @@ class BookmarkFileServiceTest extends TestCase
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
-        $this->assertRegExp('/\?[\w\-]{6}/', $bookmark->getUrl());
+        $this->assertRegExp('#/shaare/[\w\-]{6}#', $bookmark->getUrl());
         $this->assertRegExp('/[\w\-]{6}/', $bookmark->getShortUrl());
         $this->assertEquals($bookmark->getUrl(), $bookmark->getTitle());
         $this->assertEmpty($bookmark->getDescription());
@@ -212,11 +218,11 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertNull($bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
-        $this->assertRegExp('/\?[\w\-]{6}/', $bookmark->getUrl());
+        $this->assertRegExp('#/shaare/[\w\-]{6}#', $bookmark->getUrl());
         $this->assertRegExp('/[\w\-]{6}/', $bookmark->getShortUrl());
         $this->assertEquals($bookmark->getUrl(), $bookmark->getTitle());
         $this->assertEmpty($bookmark->getDescription());
@@ -230,53 +236,42 @@ class BookmarkFileServiceTest extends TestCase
 
     /**
      * Test add() method for a bookmark without any field set and without writing the data store
-     *
-     * @expectedExceptionMessage Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testAddMinimalNoWrite()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         $bookmark = new Bookmark();
-        $this->privateLinkDB->add($bookmark);
+        $this->privateLinkDB->add($bookmark, false);
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->privateLinkDB->get(43);
     }
 
     /**
      * Test add() method while logged out
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage You're not authorized to alter the datastore
      */
     public function testAddLoggedOut()
     {
-        $this->publicLinkDB->add(new Bookmark());
-    }
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('You\'re not authorized to alter the datastore');
 
-    /**
-     * Test add() method with an entry which is not a bookmark instance
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage Provided data is invalid
-     */
-    public function testAddNotABookmark()
-    {
-        $this->privateLinkDB->add(['title' => 'hi!']);
+        $this->publicLinkDB->add(new Bookmark());
     }
 
     /**
      * Test add() method with a Bookmark already containing an ID
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage This bookmarks already exists
      */
     public function testAddWithId()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('This bookmarks already exists');
+
         $bookmark = new Bookmark();
         $bookmark->setId(43);
         $this->privateLinkDB->add($bookmark);
@@ -314,7 +309,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertTrue(new \DateTime('5 seconds ago') < $bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -340,7 +335,7 @@ class BookmarkFileServiceTest extends TestCase
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
-        $this->assertEquals('?WDWyig', $bookmark->getUrl());
+        $this->assertEquals('/shaare/WDWyig', $bookmark->getUrl());
         $this->assertEquals('1eYJ1Q', $bookmark->getShortUrl());
         $this->assertEquals('Note: I have a big ID but an old date', $bookmark->getTitle());
         $this->assertEquals('Used to test bookmarks reordering.', $bookmark->getDescription());
@@ -355,11 +350,11 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertTrue(new \DateTime('5 seconds ago') < $bookmark->getUpdated());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
-        $this->assertEquals('?WDWyig', $bookmark->getUrl());
+        $this->assertEquals('/shaare/WDWyig', $bookmark->getUrl());
         $this->assertEquals('1eYJ1Q', $bookmark->getShortUrl());
         $this->assertEquals('Note: I have a big ID but an old date', $bookmark->getTitle());
         $this->assertEquals('Used to test bookmarks reordering.', $bookmark->getDescription());
@@ -388,7 +383,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($title, $bookmark->getTitle());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -397,44 +392,33 @@ class BookmarkFileServiceTest extends TestCase
 
     /**
      * Test set() method while logged out
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage You're not authorized to alter the datastore
      */
     public function testSetLoggedOut()
     {
-        $this->publicLinkDB->set(new Bookmark());
-    }
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('You\'re not authorized to alter the datastore');
 
-    /**
-     * Test set() method with an entry which is not a bookmark instance
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage Provided data is invalid
-     */
-    public function testSetNotABookmark()
-    {
-        $this->privateLinkDB->set(['title' => 'hi!']);
+        $this->publicLinkDB->set(new Bookmark());
     }
 
     /**
      * Test set() method with a Bookmark without an ID defined.
-     *
-     * @expectedException Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testSetWithoutId()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         $bookmark = new Bookmark();
         $this->privateLinkDB->set($bookmark);
     }
 
     /**
      * Test set() method with a Bookmark with an unknow ID
-     *
-     * @expectedException Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testSetWithUnknownId()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         $bookmark = new Bookmark();
         $bookmark->setId(666);
         $this->privateLinkDB->set($bookmark);
@@ -452,7 +436,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals(43, $bookmark->getId());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(43);
         $this->assertEquals(43, $bookmark->getId());
@@ -472,7 +456,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($title, $bookmark->getTitle());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -481,24 +465,13 @@ class BookmarkFileServiceTest extends TestCase
 
     /**
      * Test addOrSet() method while logged out
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage You're not authorized to alter the datastore
      */
     public function testAddOrSetLoggedOut()
     {
-        $this->publicLinkDB->addOrSet(new Bookmark());
-    }
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('You\'re not authorized to alter the datastore');
 
-    /**
-     * Test addOrSet() method with an entry which is not a bookmark instance
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage Provided data is invalid
-     */
-    public function testAddOrSetNotABookmark()
-    {
-        $this->privateLinkDB->addOrSet(['title' => 'hi!']);
+        $this->publicLinkDB->addOrSet(new Bookmark());
     }
 
     /**
@@ -515,7 +488,7 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($title, $bookmark->getTitle());
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $bookmark = $this->privateLinkDB->get(42);
         $this->assertEquals(42, $bookmark->getId());
@@ -524,11 +497,11 @@ class BookmarkFileServiceTest extends TestCase
 
     /**
      * Test remove() method with an existing Bookmark
-     *
-     * @expectedException Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testRemoveExisting()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         $bookmark = $this->privateLinkDB->get(42);
         $this->privateLinkDB->remove($bookmark);
 
@@ -541,41 +514,30 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertInstanceOf(BookmarkNotFoundException::class, $exception);
 
         // reload from file
-        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, true);
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->privateLinkDB->get(42);
     }
 
     /**
      * Test remove() method while logged out
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage You're not authorized to alter the datastore
      */
     public function testRemoveLoggedOut()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('You\'re not authorized to alter the datastore');
+
         $bookmark = $this->privateLinkDB->get(42);
         $this->publicLinkDB->remove($bookmark);
     }
 
-    /**
-     * Test remove() method with an entry which is not a bookmark instance
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessage Provided data is invalid
-     */
-    public function testRemoveNotABookmark()
-    {
-        $this->privateLinkDB->remove(['title' => 'hi!']);
-    }
-
     /**
      * Test remove() method with a Bookmark with an unknown ID
-     *
-     * @expectedException Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testRemoveWithUnknownId()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         $bookmark = new Bookmark();
         $bookmark->setId(666);
         $this->privateLinkDB->remove($bookmark);
@@ -615,14 +577,18 @@ class BookmarkFileServiceTest extends TestCase
     {
         $dbSize = $this->privateLinkDB->count();
         $this->privateLinkDB->initialize();
-        $this->assertEquals($dbSize + 2, $this->privateLinkDB->count());
-        $this->assertEquals(
-            'My secret stuff... - Pastebin.com',
-            $this->privateLinkDB->get(43)->getTitle()
+        $this->assertEquals($dbSize + 3, $this->privateLinkDB->count());
+        $this->assertStringStartsWith(
+            'Shaarli will automatically pick up the thumbnail for links to a variety of websites.',
+            $this->privateLinkDB->get(43)->getDescription()
         );
-        $this->assertEquals(
-            'The personal, minimalist, super-fast, database free, bookmarking service',
-            $this->privateLinkDB->get(44)->getTitle()
+        $this->assertStringStartsWith(
+            'Adding a shaare without entering a URL creates a text-only "note" post such as this one.',
+            $this->privateLinkDB->get(44)->getDescription()
+        );
+        $this->assertStringStartsWith(
+            'Welcome to Shaarli!',
+            $this->privateLinkDB->get(45)->getDescription()
         );
     }
 
@@ -631,18 +597,17 @@ class BookmarkFileServiceTest extends TestCase
      * to make sure that nothing have been broken in the migration process.
      * They mostly cover search/filters. Some of them might be redundant with the previous ones.
      */
-
     /**
      * Attempt to instantiate a LinkDB whereas the datastore is not writable
-     *
-     * @expectedException              Shaarli\Bookmark\Exception\NotWritableDataStoreException
-     * @expectedExceptionMessageRegExp #Couldn't load data from the data store file "null".*#
      */
     public function testConstructDatastoreNotWriteable()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\NotWritableDataStoreException::class);
+        $this->expectExceptionMessageRegExp('#Couldn\'t load data from the data store file "null".*#');
+
         $conf = new ConfigManager('tests/utils/config/configJson');
         $conf->set('resource.datastore', 'null/store.db');
-        new BookmarkFileService($conf, $this->history, true);
+        new BookmarkFileService($conf, $this->history, $this->mutex, true);
     }
 
     /**
@@ -652,7 +617,7 @@ class BookmarkFileServiceTest extends TestCase
     {
         unlink(self::$testDatastore);
         $this->assertFileNotExists(self::$testDatastore);
-        new BookmarkFileService($this->conf, $this->history, true);
+        new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->assertFileExists(self::$testDatastore);
 
         // ensure the correct data has been written
@@ -666,7 +631,7 @@ class BookmarkFileServiceTest extends TestCase
     {
         unlink(self::$testDatastore);
         $this->assertFileNotExists(self::$testDatastore);
-        $db = new \FakeBookmarkService($this->conf, $this->history, false);
+        $db = new \FakeBookmarkService($this->conf, $this->history, $this->mutex, false);
         $this->assertFileNotExists(self::$testDatastore);
         $this->assertInstanceOf(BookmarkArray::class, $db->getBookmarks());
         $this->assertCount(0, $db->getBookmarks());
@@ -699,13 +664,13 @@ class BookmarkFileServiceTest extends TestCase
      */
     public function testSave()
     {
-        $testDB = new BookmarkFileService($this->conf, $this->history, true);
+        $testDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $dbSize = $testDB->count();
 
         $bookmark = new Bookmark();
         $testDB->add($bookmark);
 
-        $testDB = new BookmarkFileService($this->conf, $this->history, true);
+        $testDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->assertEquals($dbSize + 1, $testDB->count());
     }
 
@@ -715,27 +680,11 @@ class BookmarkFileServiceTest extends TestCase
     public function testCountHiddenPublic()
     {
         $this->conf->set('privacy.hide_public_links', true);
-        $linkDB = new BookmarkFileService($this->conf, $this->history, false);
+        $linkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
 
         $this->assertEquals(0, $linkDB->count());
     }
 
-    /**
-     * List the days for which bookmarks have been posted
-     */
-    public function testDays()
-    {
-        $this->assertEquals(
-            ['20100309', '20100310', '20121206', '20121207', '20130614', '20150310'],
-            $this->publicLinkDB->days()
-        );
-
-        $this->assertEquals(
-            ['20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'],
-            $this->privateLinkDB->days()
-        );
-    }
-
     /**
      * The URL corresponds to an existing entry in the DB
      */
@@ -744,7 +693,7 @@ class BookmarkFileServiceTest extends TestCase
         $link = $this->publicLinkDB->findByUrl('http://mediagoblin.org/');
 
         $this->assertNotEquals(false, $link);
-        $this->assertContains(
+        $this->assertContainsPolyfill(
             'A free software media publishing platform',
             $link->getDescription()
         );
@@ -783,6 +732,10 @@ class BookmarkFileServiceTest extends TestCase
                 // They need to be grouped with the first case found - order by date DESC: `sTuff`.
                 'sTuff' => 2,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ],
             $this->publicLinkDB->bookmarksCountPerTag()
         );
@@ -811,12 +764,15 @@ class BookmarkFileServiceTest extends TestCase
                 'tag3' => 1,
                 'tag4' => 1,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ],
             $this->privateLinkDB->bookmarksCountPerTag()
         );
         $this->assertEquals(
             [
-                'web' => 4,
                 'cartoon' => 2,
                 'gnu' => 1,
                 'dev' => 1,
@@ -833,7 +789,6 @@ class BookmarkFileServiceTest extends TestCase
         );
         $this->assertEquals(
             [
-                'web' => 1,
                 'html' => 1,
                 'w3c' => 1,
                 'css' => 1,
@@ -894,38 +849,69 @@ class BookmarkFileServiceTest extends TestCase
     public function testFilterHashValid()
     {
         $request = smallHash('20150310_114651');
-        $this->assertEquals(
-            1,
-            count($this->publicLinkDB->findByHash($request))
+        $this->assertSame(
+            $request,
+            $this->publicLinkDB->findByHash($request)->getShortUrl()
         );
         $request = smallHash('20150310_114633' . 8);
-        $this->assertEquals(
-            1,
-            count($this->publicLinkDB->findByHash($request))
+        $this->assertSame(
+            $request,
+            $this->publicLinkDB->findByHash($request)->getShortUrl()
         );
     }
 
     /**
      * Test filterHash() with an invalid smallhash.
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterHashInValid1()
     {
+        $this->expectException(BookmarkNotFoundException::class);
+
         $request = 'blabla';
         $this->publicLinkDB->findByHash($request);
     }
 
     /**
      * Test filterHash() with an empty smallhash.
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterHashInValid()
     {
+        $this->expectException(BookmarkNotFoundException::class);
+
         $this->publicLinkDB->findByHash('');
     }
 
+    /**
+     * Test filterHash() on a private bookmark while logged out.
+     */
+    public function testFilterHashPrivateWhileLoggedOut()
+    {
+        $this->expectException(BookmarkNotFoundException::class);
+        $this->expectExceptionMessage('The link you are trying to reach does not exist or has been deleted');
+
+        $hash = smallHash('20141125_084734' . 6);
+
+        $this->publicLinkDB->findByHash($hash);
+    }
+
+    /**
+     * Test filterHash() with private key.
+     */
+    public function testFilterHashWithPrivateKey()
+    {
+        $hash = smallHash('20141125_084734' . 6);
+        $privateKey = 'this is usually auto generated';
+
+        $bookmark = $this->privateLinkDB->findByHash($hash);
+        $bookmark->addAdditionalContentEntry('private_key', $privateKey);
+        $this->privateLinkDB->save();
+
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+        $bookmark = $this->privateLinkDB->findByHash($hash, $privateKey);
+
+        static::assertSame(6, $bookmark->getId());
+    }
+
     /**
      * Test linksCountPerTag all tags without filter.
      * Equal occurrences should be sorted alphabetically.
@@ -955,6 +941,10 @@ class BookmarkFileServiceTest extends TestCase
             'tag4' => 1,
             'ut' => 1,
             'w3c' => 1,
+            'assurance' => 1,
+            'coding-style' => 1,
+            'quality' => 1,
+            'standards' => 1,
         ];
         $tags = $this->privateLinkDB->bookmarksCountPerTag();
 
@@ -968,7 +958,6 @@ class BookmarkFileServiceTest extends TestCase
     public function testCountLinkPerTagAllWithFilter()
     {
         $expected = [
-            'gnu' => 2,
             'hashtag' => 2,
             '-exclude' => 1,
             '.hidden' => 1,
@@ -991,7 +980,6 @@ class BookmarkFileServiceTest extends TestCase
     public function testCountLinkPerTagPublicWithFilter()
     {
         $expected = [
-            'gnu' => 2,
             'hashtag' => 2,
             '-exclude' => 1,
             '.hidden' => 1,
@@ -1015,7 +1003,6 @@ class BookmarkFileServiceTest extends TestCase
     {
         $expected = [
             'cartoon' => 1,
-            'dev' => 1,
             'tag1' => 1,
             'tag2' => 1,
             'tag3' => 1,
@@ -1056,6 +1043,10 @@ class BookmarkFileServiceTest extends TestCase
             'stallman' => 1,
             'ut' => 1,
             'w3c' => 1,
+            'assurance' => 1,
+            'coding-style' => 1,
+            'quality' => 1,
+            'standards' => 1,
         ];
         $bookmark = new Bookmark();
         $bookmark->setTags(['newTagToCount', BookmarkMarkdownFormatter::NO_MD_TAG]);
@@ -1066,6 +1057,108 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals($expected, $tags, var_export($tags, true));
     }
 
+    /**
+     * Test find by dates in the middle of the datastore (sorted by dates) with a single bookmark as a result.
+     */
+    public function testFilterByDateMidTimePeriodSingleBookmark(): void
+    {
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_160000'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(9, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_172539'), $after);
+    }
+
+    /**
+     * Test find by dates in the middle of the datastore (sorted by dates) with a multiple bookmarks as a result.
+     */
+    public function testFilterByDateMidTimePeriodMultipleBookmarks(): void
+    {
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_180000'),
+            $before,
+            $after
+        );
+
+        static::assertCount(2, $bookmarks);
+
+        static::assertSame(1, $bookmarks[0]->getId());
+        static::assertSame(9, $bookmarks[1]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_182539'), $after);
+    }
+
+    /**
+     * Test find by dates at the end of the datastore (sorted by dates).
+     */
+    public function testFilterByDateLastTimePeriod(): void
+    {
+        $after = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20150310_114640'),
+            DateTime::createFromFormat('Ymd_His', '20450101_010101'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(41, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20150310_114633'), $before);
+        static::assertNull($after);
+    }
+
+    /**
+     * Test find by dates at the beginning of the datastore (sorted by dates).
+     */
+    public function testFilterByDateFirstTimePeriod(): void
+    {
+        $before = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20000101_101010'),
+            DateTime::createFromFormat('Ymd_His', '20100309_110000'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(11, $bookmarks[0]->getId());
+        static::assertNull($before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20100310_101010'), $after);
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestWithSticky(): void
+    {
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertSame(41, $bookmark->getId());
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestEmptyDatastore(): void
+    {
+        unlink($this->conf->get('resource.datastore'));
+        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertNull($bookmark);
+    }
+
     /**
      * Allows to test LinkDB's private methods
      *
index d4c71cb94388ca86dede43b8c9054ce96f4e8261..574d8e3f270e5a4f80638dfc78db4779860eb39d 100644 (file)
@@ -2,12 +2,11 @@
 
 namespace Shaarli\Bookmark;
 
-use Exception;
-use PHPUnit\Framework\TestCase;
+use malkusch\lock\mutex\NoMutex;
 use ReferenceLinkDB;
 use Shaarli\Config\ConfigManager;
-use Shaarli\Formatter\FormatterFactory;
 use Shaarli\History;
+use Shaarli\TestCase;
 
 /**
  * Class BookmarkFilterTest.
@@ -36,14 +35,15 @@ class BookmarkFilterTest extends TestCase
     /**
      * Instantiate linkFilter with ReferenceLinkDB data.
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
+        $mutex = new NoMutex();
         $conf = new ConfigManager('tests/utils/config/configJson');
         $conf->set('resource.datastore', self::$testDatastore);
         self::$refDB = new \ReferenceLinkDB();
         self::$refDB->write(self::$testDatastore);
         $history = new History('sandbox/history.php');
-        self::$bookmarkService = new \FakeBookmarkService($conf, $history, true);
+        self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true);
         self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks());
     }
 
@@ -189,6 +189,17 @@ class BookmarkFilterTest extends TestCase
         );
     }
 
+    /**
+     * Return bookmarks for a given day
+     */
+    public function testFilterDayRestrictedVisibility(): void
+    {
+        $this->assertEquals(
+            3,
+            count(self::$linkFilter->filter(BookmarkFilter::$FILTER_DAY, '20121206', false, BookmarkFilter::$PUBLIC))
+        );
+    }
+
     /**
      * 404 - day not found
      */
@@ -202,21 +213,23 @@ class BookmarkFilterTest extends TestCase
 
     /**
      * Use an invalid date format
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Invalid date format/
      */
     public function testFilterInvalidDayWithChars()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Invalid date format/');
+
         self::$linkFilter->filter(BookmarkFilter::$FILTER_DAY, 'Rainy day, dream away');
     }
 
     /**
      * Use an invalid date format
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Invalid date format/
      */
     public function testFilterInvalidDayDigits()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Invalid date format/');
+
         self::$linkFilter->filter(BookmarkFilter::$FILTER_DAY, '20');
     }
 
@@ -240,11 +253,11 @@ class BookmarkFilterTest extends TestCase
 
     /**
      * No link for this hash
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterUnknownSmallHash()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         self::$linkFilter->filter(BookmarkFilter::$FILTER_HASH, 'Iblaah');
     }
 
@@ -511,4 +524,43 @@ class BookmarkFilterTest extends TestCase
             ))
         );
     }
+
+    /**
+     * Test search result highlights in every field of bookmark reference #9.
+     */
+    public function testFullTextSearchHighlight(): void
+    {
+        $bookmarks = self::$linkFilter->filter(
+            BookmarkFilter::$FILTER_TEXT,
+            '"psr-2" coding guide http fig "psr-2/" "This guide" basic standard. coding-style quality assurance'
+        );
+
+        static::assertCount(1, $bookmarks);
+        static::assertArrayHasKey(9, $bookmarks);
+
+        $bookmark = $bookmarks[9];
+        $expectedHighlights = [
+            'title' => [
+                ['start' => 0, 'end' => 5], // "psr-2"
+                ['start' => 7, 'end' => 13], // coding
+                ['start' => 20, 'end' => 25], // guide
+            ],
+            'description' => [
+                ['start' => 0, 'end' => 10], // "This guide"
+                ['start' => 45, 'end' => 50], // basic
+                ['start' => 58, 'end' => 67], // standard.
+            ],
+            'url' => [
+                ['start' => 0, 'end' => 4], // http
+                ['start' => 15, 'end' => 18], // fig
+                ['start' => 27, 'end' => 33], // "psr-2/"
+            ],
+            'tags' => [
+                ['start' => 0, 'end' => 12], // coding-style
+                ['start' => 23, 'end' => 30], // quality
+                ['start' => 31, 'end' => 40], // assurance
+            ],
+        ];
+        static::assertSame($expectedHighlights, $bookmark->getAdditionalContentEntry('search_highlight'));
+    }
 }
index d23eb0695b1fdde093b4fcb4b6274a8bd44cb44a..0c8420ce5ffceac84e4dd471cdc8f8fa567113d1 100644 (file)
@@ -2,10 +2,10 @@
 
 namespace Shaarli\Bookmark;
 
-use PHPUnit\Framework\TestCase;
-use ReferenceLinkDB;
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
+use Shaarli\TestCase;
 
 /**
  * Class BookmarkInitializerTest
@@ -35,11 +35,15 @@ class BookmarkInitializerTest extends TestCase
     /** @var BookmarkInitializer instance */
     protected $initializer;
 
+    /** @var NoMutex */
+    protected $mutex;
+
     /**
      * Initialize an empty BookmarkFileService
      */
-    public function setUp()
+    public function setUp(): void
     {
+        $this->mutex = new NoMutex();
         if (file_exists(self::$testDatastore)) {
             unlink(self::$testDatastore);
         }
@@ -48,72 +52,103 @@ class BookmarkInitializerTest extends TestCase
         $this->conf = new ConfigManager(self::$testConf);
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->history = new History('sandbox/history.php');
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
 
         $this->initializer = new BookmarkInitializer($this->bookmarkService);
     }
 
     /**
-     * Test initialize() with an empty data store.
+     * Test initialize() with a data store containing bookmarks.
      */
-    public function testInitializeEmptyDataStore()
+    public function testInitializeNotEmptyDataStore(): void
     {
         $refDB = new \ReferenceLinkDB();
         $refDB->write(self::$testDatastore);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
         $this->initializer = new BookmarkInitializer($this->bookmarkService);
 
         $this->initializer->initialize();
 
-        $this->assertEquals($refDB->countLinks() + 2, $this->bookmarkService->count());
+        $this->assertEquals($refDB->countLinks() + 3, $this->bookmarkService->count());
+
         $bookmark = $this->bookmarkService->get(43);
-        $this->assertEquals(43, $bookmark->getId());
-        $this->assertEquals('My secret stuff... - Pastebin.com', $bookmark->getTitle());
+        $this->assertStringStartsWith(
+            'Shaarli will automatically pick up the thumbnail for links to a variety of websites.',
+            $bookmark->getDescription()
+        );
         $this->assertTrue($bookmark->isPrivate());
 
         $bookmark = $this->bookmarkService->get(44);
-        $this->assertEquals(44, $bookmark->getId());
-        $this->assertEquals(
-            'The personal, minimalist, super-fast, database free, bookmarking service',
-            $bookmark->getTitle()
+        $this->assertStringStartsWith(
+            'Adding a shaare without entering a URL creates a text-only "note" post such as this one.',
+            $bookmark->getDescription()
+        );
+        $this->assertTrue($bookmark->isPrivate());
+
+        $bookmark = $this->bookmarkService->get(45);
+        $this->assertStringStartsWith(
+            'Welcome to Shaarli!',
+            $bookmark->getDescription()
         );
         $this->assertFalse($bookmark->isPrivate());
 
+        $this->bookmarkService->save();
+
         // Reload from file
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
-        $this->assertEquals($refDB->countLinks() + 2, $this->bookmarkService->count());
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
+        $this->assertEquals($refDB->countLinks() + 3, $this->bookmarkService->count());
+
         $bookmark = $this->bookmarkService->get(43);
-        $this->assertEquals(43, $bookmark->getId());
-        $this->assertEquals('My secret stuff... - Pastebin.com', $bookmark->getTitle());
+        $this->assertStringStartsWith(
+            'Shaarli will automatically pick up the thumbnail for links to a variety of websites.',
+            $bookmark->getDescription()
+        );
         $this->assertTrue($bookmark->isPrivate());
 
         $bookmark = $this->bookmarkService->get(44);
-        $this->assertEquals(44, $bookmark->getId());
-        $this->assertEquals(
-            'The personal, minimalist, super-fast, database free, bookmarking service',
-            $bookmark->getTitle()
+        $this->assertStringStartsWith(
+            'Adding a shaare without entering a URL creates a text-only "note" post such as this one.',
+            $bookmark->getDescription()
+        );
+        $this->assertTrue($bookmark->isPrivate());
+
+        $bookmark = $this->bookmarkService->get(45);
+        $this->assertStringStartsWith(
+            'Welcome to Shaarli!',
+            $bookmark->getDescription()
         );
         $this->assertFalse($bookmark->isPrivate());
     }
 
     /**
-     * Test initialize() with a data store containing bookmarks.
+     * Test initialize() with an a non existent datastore file .
      */
-    public function testInitializeNotEmptyDataStore()
+    public function testInitializeNonExistentDataStore(): void
     {
+        $this->conf->set('resource.datastore', static::$testDatastore . '_empty');
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $this->mutex, true);
+
         $this->initializer->initialize();
 
-        $this->assertEquals(2, $this->bookmarkService->count());
+        $this->assertEquals(3, $this->bookmarkService->count());
         $bookmark = $this->bookmarkService->get(0);
-        $this->assertEquals(0, $bookmark->getId());
-        $this->assertEquals('My secret stuff... - Pastebin.com', $bookmark->getTitle());
+        $this->assertStringStartsWith(
+            'Shaarli will automatically pick up the thumbnail for links to a variety of websites.',
+            $bookmark->getDescription()
+        );
         $this->assertTrue($bookmark->isPrivate());
 
         $bookmark = $this->bookmarkService->get(1);
-        $this->assertEquals(1, $bookmark->getId());
-        $this->assertEquals(
-            'The personal, minimalist, super-fast, database free, bookmarking service',
-            $bookmark->getTitle()
+        $this->assertStringStartsWith(
+            'Adding a shaare without entering a URL creates a text-only "note" post such as this one.',
+            $bookmark->getDescription()
+        );
+        $this->assertTrue($bookmark->isPrivate());
+
+        $bookmark = $this->bookmarkService->get(2);
+        $this->assertStringStartsWith(
+            'Welcome to Shaarli!',
+            $bookmark->getDescription()
         );
         $this->assertFalse($bookmark->isPrivate());
     }
index 9a3bbbfc5e920d4bec6be3e9ddd8670bef5758d7..4c1ae25dce6cfb8b329906e9840211ef3dbd3b83 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace Shaarli\Bookmark;
 
-use PHPUnit\Framework\TestCase;
 use Shaarli\Bookmark\Exception\InvalidBookmarkException;
+use Shaarli\TestCase;
 
 /**
  * Class BookmarkTest
@@ -124,8 +124,8 @@ class BookmarkTest extends TestCase
         $this->assertEquals(1, $bookmark->getId());
         $this->assertEquals('abc', $bookmark->getShortUrl());
         $this->assertEquals($date, $bookmark->getCreated());
-        $this->assertEquals('?abc', $bookmark->getUrl());
-        $this->assertEquals('?abc', $bookmark->getTitle());
+        $this->assertEquals('/shaare/abc', $bookmark->getUrl());
+        $this->assertEquals('/shaare/abc', $bookmark->getTitle());
         $this->assertEquals('', $bookmark->getDescription());
         $this->assertEquals([], $bookmark->getTags());
         $this->assertEquals('', $bookmark->getTagsString());
@@ -150,26 +150,7 @@ class BookmarkTest extends TestCase
             $exception = $e;
         }
         $this->assertNotNull($exception);
-        $this->assertContains('- ID: '. PHP_EOL, $exception->getMessage());
-    }
-
-    /**
-     * Test validate() with a a bookmark with a non integer ID.
-     */
-    public function testValidateNotValidStringId()
-    {
-        $bookmark = new Bookmark();
-        $bookmark->setId('str');
-        $bookmark->setShortUrl('abc');
-        $bookmark->setCreated(\DateTime::createFromFormat('Ymd_His', '20190514_200102'));
-        $exception = null;
-        try {
-            $bookmark->validate();
-        } catch (InvalidBookmarkException $e) {
-            $exception = $e;
-        }
-        $this->assertNotNull($exception);
-        $this->assertContains('- ID: str'. PHP_EOL, $exception->getMessage());
+        $this->assertContainsPolyfill('- ID: '. PHP_EOL, $exception->getMessage());
     }
 
     /**
@@ -188,7 +169,7 @@ class BookmarkTest extends TestCase
             $exception = $e;
         }
         $this->assertNotNull($exception);
-        $this->assertContains('- ShortUrl: '. PHP_EOL, $exception->getMessage());
+        $this->assertContainsPolyfill('- ShortUrl: '. PHP_EOL, $exception->getMessage());
     }
 
     /**
@@ -207,26 +188,7 @@ class BookmarkTest extends TestCase
             $exception = $e;
         }
         $this->assertNotNull($exception);
-        $this->assertContains('- Created: '. PHP_EOL, $exception->getMessage());
-    }
-
-    /**
-     * Test validate() with a a bookmark with a bad created datetime.
-     */
-    public function testValidateNotValidBadCreated()
-    {
-        $bookmark = new Bookmark();
-        $bookmark->setId(1);
-        $bookmark->setShortUrl('abc');
-        $bookmark->setCreated('hi!');
-        $exception = null;
-        try {
-            $bookmark->validate();
-        } catch (InvalidBookmarkException $e) {
-            $exception = $e;
-        }
-        $this->assertNotNull($exception);
-        $this->assertContains('- Created: Not a DateTime object'. PHP_EOL, $exception->getMessage());
+        $this->assertContainsPolyfill('- Created: '. PHP_EOL, $exception->getMessage());
     }
 
     /**
@@ -385,4 +347,48 @@ class BookmarkTest extends TestCase
         $bookmark->deleteTag('nope');
         $this->assertEquals(['tag1', 'tag2', 'chair'], $bookmark->getTags());
     }
+
+    /**
+     * Test shouldUpdateThumbnail() with bookmarks needing an update.
+     */
+    public function testShouldUpdateThumbnail(): void
+    {
+        $bookmark = (new Bookmark())->setUrl('http://domain.tld/with-image');
+
+        static::assertTrue($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('http://domain.tld/with-image')
+            ->setThumbnail('unknown file')
+        ;
+
+        static::assertTrue($bookmark->shouldUpdateThumbnail());
+    }
+
+    /**
+     * Test shouldUpdateThumbnail() with bookmarks that should not update.
+     */
+    public function testShouldNotUpdateThumbnail(): void
+    {
+        $bookmark = (new Bookmark());
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('ftp://domain.tld/other-protocol', ['ftp'])
+        ;
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())
+            ->setUrl('http://domain.tld/with-image')
+            ->setThumbnail(__FILE__)
+        ;
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+
+        $bookmark = (new Bookmark())->setUrl('/shaare/abcdef');
+
+        static::assertFalse($bookmark->shouldUpdateThumbnail());
+    }
 }
index 591976f2c30b1a293ec7f845972147a02c3420c4..3321242fae07f018c91b98b2f40067fa2d6a9e22 100644 (file)
@@ -2,9 +2,7 @@
 
 namespace Shaarli\Bookmark;
 
-use PHPUnit\Framework\TestCase;
-use ReferenceLinkDB;
-use Shaarli\Config\ConfigManager;
+use Shaarli\TestCase;
 
 require_once 'tests/utils/CurlUtils.php';
 
@@ -44,6 +42,19 @@ class LinkUtilsTest extends TestCase
         $this->assertEquals(strtolower($charset), header_extract_charset($headers));
     }
 
+    /**
+     * Test headers_extract_charset() when the charset is found with odd quotes.
+     */
+    public function testHeadersExtractExistentCharsetWithQuotes()
+    {
+        $charset = 'x-MacCroatian';
+        $headers = 'text/html; charset="' . $charset . '"otherstuff="test"';
+        $this->assertEquals(strtolower($charset), header_extract_charset($headers));
+
+        $headers = 'text/html; charset=\'' . $charset . '\'otherstuff="test"';
+        $this->assertEquals(strtolower($charset), header_extract_charset($headers));
+    }
+
     /**
      * Test headers_extract_charset() when the charset is not found.
      */
@@ -83,8 +94,78 @@ class LinkUtilsTest extends TestCase
     public function testHtmlExtractExistentNameTag()
     {
         $description = 'Bob and Alice share cookies.';
+
+        // Simple one line
         $html = '<html><meta>stuff2</meta><meta name="description" content="' . $description . '"/></html>';
         $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // Simple OpenGraph
+        $html = '<meta property="og:description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // Simple reversed OpenGraph
+        $html = '<meta content="' . $description . '" property="og:description">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // ItemProp OpenGraph
+        $html = '<meta itemprop="og:description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph without quotes
+        $html = '<meta property=og:description content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed without quotes
+        $html = '<meta content="' . $description . '" property=og:description>';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph with noise
+        $html = '<meta tag1="content1" property="og:description" tag2="content2" content="' .
+            $description . '" tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed with noise
+        $html = '<meta tag1="content1" content="' . $description . '" ' .
+            'tag3="content3" tag2="content2" property="og:description">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties start
+        $html = '<meta property="unrelated og:description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties end
+        $html = '<meta property="og:description unrelated" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties both end
+        $html = '<meta property="og:unrelated1 og:description og:unrelated2" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph multiple properties both end with noise
+        $html = '<meta tag1="content1" property="og:unrelated1 og:description og:unrelated2" '.
+            'tag2="content2" content="' . $description . '" tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties start
+        $html = '<meta content="' . $description . '" property="unrelated og:description">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties end
+        $html = '<meta content="' . $description . '" property="og:description unrelated">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties both end
+        $html = '<meta content="' . $description . '" property="og:unrelated1 og:description og:unrelated2">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // OpenGraph reversed multiple properties both end with noise
+        $html = '<meta tag1="content1" content="' . $description . '" tag2="content2" '.
+            'property="og:unrelated1 og:description og:unrelated2" tag3="content3">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
+
+        // Suggestion from #1375
+        $html = '<meta property="og:description" name="description" content="' . $description . '">';
+        $this->assertEquals($description, html_extract_tag('description', $html));
     }
 
     /**
@@ -94,6 +175,25 @@ class LinkUtilsTest extends TestCase
     {
         $html = '<html><meta>stuff2</meta><meta name="image" content="img"/></html>';
         $this->assertFalse(html_extract_tag('description', $html));
+
+        // Partial meta tag
+        $html = '<meta content="Brief description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta property="og:description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta tag1="content1" property="og:description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta property="og:description" tag1="content1">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta tag1="content1" content="Brief description">';
+        $this->assertFalse(html_extract_tag('description', $html));
+
+        $html = '<meta content="Brief description" tag1="content1">';
+        $this->assertFalse(html_extract_tag('description', $html));
     }
 
     /**
@@ -115,61 +215,92 @@ class LinkUtilsTest extends TestCase
         $this->assertFalse(html_extract_tag('description', $html));
     }
 
+    /**
+     * Test the header callback with valid value
+     */
+    public function testCurlHeaderCallbackOk(): void
+    {
+        $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_ok');
+        $data = [
+            'HTTP/1.1 200 OK',
+            'Server: GitHub.com',
+            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
+            'Content-Type: text/html; charset=utf-8',
+            'Status: 200 OK',
+        ];
+
+        foreach ($data as $chunk) {
+            static::assertIsInt($callback(null, $chunk));
+        }
+
+        static::assertSame('utf-8', $charset);
+    }
+
     /**
      * Test the download callback with valid value
      */
-    public function testCurlDownloadCallbackOk()
+    public function testCurlDownloadCallbackOk(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
-            false,
-            'ut_curl_getinfo_ok'
+            false
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
-            'end' => 'th=device-width">'
+            'th=device-width">'
                 . '<title>Refactoring · GitHub</title>'
                 . '<link rel="search" type="application/opensea',
             '<title>ignored</title>'
                 . '<meta name="description" content="desc" />'
                 . '<meta name="keywords" content="key1,key2" />',
         ];
-        foreach ($data as $key => $line) {
-            $ignore = null;
-            $expected = $key !== 'end' ? strlen($line) : false;
-            $this->assertEquals($expected, $callback($ignore, $line));
-            if ($expected === false) {
-                break;
-            }
+
+        foreach ($data as $chunk) {
+            static::assertSame(strlen($chunk), $callback(null, $chunk));
         }
-        $this->assertEquals('utf-8', $charset);
-        $this->assertEquals('Refactoring · GitHub', $title);
-        $this->assertEmpty($desc);
-        $this->assertEmpty($keywords);
+
+        static::assertSame('utf-8', $charset);
+        static::assertSame('Refactoring · GitHub', $title);
+        static::assertEmpty($desc);
+        static::assertEmpty($keywords);
+    }
+
+    /**
+     * Test the header callback with valid value
+     */
+    public function testCurlHeaderCallbackNoCharset(): void
+    {
+        $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_no_charset');
+        $data = [
+            'HTTP/1.1 200 OK',
+        ];
+
+        foreach ($data as $chunk) {
+            static::assertSame(strlen($chunk), $callback(null, $chunk));
+        }
+
+        static::assertFalse($charset);
     }
 
     /**
      * Test the download callback with valid values and no charset
      */
-    public function testCurlDownloadCallbackOkNoCharset()
+    public function testCurlDownloadCallbackOkNoCharset(): void
     {
+        $charset = null;
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
-            false,
-            'ut_curl_getinfo_no_charset'
+            false
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
             'end' => 'th=device-width">'
                 . '<title>Refactoring · GitHub</title>'
                 . '<link rel="search" type="application/opensea',
@@ -177,10 +308,11 @@ class LinkUtilsTest extends TestCase
             . '<meta name="description" content="desc" />'
             . '<meta name="keywords" content="key1,key2" />',
         ];
-        foreach ($data as $key => $line) {
-            $ignore = null;
-            $this->assertEquals(strlen($line), $callback($ignore, $line));
+
+        foreach ($data as $chunk) {
+            static::assertSame(strlen($chunk), $callback(null, $chunk));
         }
+
         $this->assertEmpty($charset);
         $this->assertEquals('Refactoring · GitHub', $title);
         $this->assertEmpty($desc);
@@ -190,18 +322,18 @@ class LinkUtilsTest extends TestCase
     /**
      * Test the download callback with valid values and no charset
      */
-    public function testCurlDownloadCallbackOkHtmlCharset()
+    public function testCurlDownloadCallbackOkHtmlCharset(): void
     {
+        $charset = null;
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
-            false,
-            'ut_curl_getinfo_no_charset'
+            false
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
             '<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />',
             'end' => 'th=device-width">'
                 . '<title>Refactoring · GitHub</title>'
@@ -210,14 +342,10 @@ class LinkUtilsTest extends TestCase
             . '<meta name="description" content="desc" />'
             . '<meta name="keywords" content="key1,key2" />',
         ];
-        foreach ($data as $key => $line) {
-            $ignore = null;
-            $expected = $key !== 'end' ? strlen($line) : false;
-            $this->assertEquals($expected, $callback($ignore, $line));
-            if ($expected === false) {
-                break;
-            }
+        foreach ($data as $chunk) {
+            static::assertSame(strlen($chunk), $callback(null, $chunk));
         }
+
         $this->assertEquals('utf-8', $charset);
         $this->assertEquals('Refactoring · GitHub', $title);
         $this->assertEmpty($desc);
@@ -227,25 +355,26 @@ class LinkUtilsTest extends TestCase
     /**
      * Test the download callback with valid values and no title
      */
-    public function testCurlDownloadCallbackOkNoTitle()
+    public function testCurlDownloadCallbackOkNoTitle(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
-            false,
-            'ut_curl_getinfo_ok'
+            false
         );
+
         $data = [
-            'HTTP/1.1 200 OK',
             'end' => 'th=device-width">Refactoring · GitHub<link rel="search" type="application/opensea',
             'ignored',
         ];
-        foreach ($data as $key => $line) {
-            $ignore = null;
-            $this->assertEquals(strlen($line), $callback($ignore, $line));
+
+        foreach ($data as $chunk) {
+            static::assertSame(strlen($chunk), $callback(null, $chunk));
         }
+
         $this->assertEquals('utf-8', $charset);
         $this->assertEmpty($title);
         $this->assertEmpty($desc);
@@ -253,81 +382,55 @@ class LinkUtilsTest extends TestCase
     }
 
     /**
-     * Test the download callback with an invalid content type.
+     * Test the header callback with an invalid content type.
      */
-    public function testCurlDownloadCallbackInvalidContentType()
+    public function testCurlHeaderCallbackInvalidContentType(): void
     {
-        $callback = get_curl_download_callback(
-            $charset,
-            $title,
-            $desc,
-            $keywords,
-            false,
-            'ut_curl_getinfo_ct_ko'
-        );
-        $ignore = null;
-        $this->assertFalse($callback($ignore, ''));
-        $this->assertEmpty($charset);
-        $this->assertEmpty($title);
+        $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_ct_ko');
+        $data = [
+            'HTTP/1.1 200 OK',
+        ];
+
+        static::assertFalse($callback(null, $data[0]));
+        static::assertNull($charset);
     }
 
     /**
-     * Test the download callback with an invalid response code.
+     * Test the header callback with an invalid response code.
      */
-    public function testCurlDownloadCallbackInvalidResponseCode()
+    public function testCurlHeaderCallbackInvalidResponseCode(): void
     {
-        $callback = $callback = get_curl_download_callback(
-            $charset,
-            $title,
-            $desc,
-            $keywords,
-            false,
-            'ut_curl_getinfo_rc_ko'
-        );
-        $ignore = null;
-        $this->assertFalse($callback($ignore, ''));
-        $this->assertEmpty($charset);
-        $this->assertEmpty($title);
+        $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_rc_ko');
+
+        static::assertFalse($callback(null, ''));
+        static::assertNull($charset);
     }
 
     /**
-     * Test the download callback with an invalid content type and response code.
+     * Test the header callback with an invalid content type and response code.
      */
-    public function testCurlDownloadCallbackInvalidContentTypeAndResponseCode()
+    public function testCurlHeaderCallbackInvalidContentTypeAndResponseCode(): void
     {
-        $callback = $callback = get_curl_download_callback(
-            $charset,
-            $title,
-            $desc,
-            $keywords,
-            false,
-            'ut_curl_getinfo_rs_ct_ko'
-        );
-        $ignore = null;
-        $this->assertFalse($callback($ignore, ''));
-        $this->assertEmpty($charset);
-        $this->assertEmpty($title);
+        $callback = get_curl_header_callback($charset, 'ut_curl_getinfo_rs_ct_ko');
+
+        static::assertFalse($callback(null, ''));
+        static::assertNull($charset);
     }
 
     /**
      * Test the download callback with valid value, and retrieve_description option enabled.
      */
-    public function testCurlDownloadCallbackOkWithDesc()
+    public function testCurlDownloadCallbackOkWithDesc(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
             $desc,
             $keywords,
-            true,
-            'ut_curl_getinfo_ok'
+            true
         );
         $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
             'th=device-width">'
                 . '<title>Refactoring · GitHub</title>'
                 . '<link rel="search" type="application/opensea',
@@ -335,14 +438,11 @@ class LinkUtilsTest extends TestCase
             . '<meta name="description" content="link desc" />'
             . '<meta name="keywords" content="key1,key2" />',
         ];
-        foreach ($data as $key => $line) {
-            $ignore = null;
-            $expected = $key !== 'end' ? strlen($line) : false;
-            $this->assertEquals($expected, $callback($ignore, $line));
-            if ($expected === false) {
-                break;
-            }
+
+        foreach ($data as $chunk) {
+            static::assertSame(strlen($chunk), $callback(null, $chunk));
         }
+
         $this->assertEquals('utf-8', $charset);
         $this->assertEquals('Refactoring · GitHub', $title);
         $this->assertEquals('link desc', $desc);
@@ -353,8 +453,9 @@ class LinkUtilsTest extends TestCase
      * Test the download callback with valid value, and retrieve_description option enabled,
      * but no desc or keyword defined in the page.
      */
-    public function testCurlDownloadCallbackOkWithDescNotFound()
+    public function testCurlDownloadCallbackOkWithDescNotFound(): void
     {
+        $charset = 'utf-8';
         $callback = get_curl_download_callback(
             $charset,
             $title,
@@ -364,24 +465,16 @@ class LinkUtilsTest extends TestCase
             'ut_curl_getinfo_ok'
         );
         $data = [
-            'HTTP/1.1 200 OK',
-            'Server: GitHub.com',
-            'Date: Sat, 28 Oct 2017 12:01:33 GMT',
-            'Content-Type: text/html; charset=utf-8',
-            'Status: 200 OK',
             'th=device-width">'
                 . '<title>Refactoring · GitHub</title>'
                 . '<link rel="search" type="application/opensea',
             'end' => '<title>ignored</title>',
         ];
-        foreach ($data as $key => $line) {
-            $ignore = null;
-            $expected = $key !== 'end' ? strlen($line) : false;
-            $this->assertEquals($expected, $callback($ignore, $line));
-            if ($expected === false) {
-                break;
-            }
+
+        foreach ($data as $chunk) {
+            static::assertSame(strlen($chunk), $callback(null, $chunk));
         }
+
         $this->assertEquals('utf-8', $charset);
         $this->assertEquals('Refactoring · GitHub', $title);
         $this->assertEmpty($desc);
@@ -439,13 +532,13 @@ class LinkUtilsTest extends TestCase
             カタカナ #カタカナ」カタカナ\n';
         $autolinkedDescription = hashtag_autolink($rawDescription, $index);
 
-        $this->assertContains($this->getHashtagLink('hashtag', $index), $autolinkedDescription);
-        $this->assertNotContains(' #hashtag', $autolinkedDescription);
-        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('ашок', $index), $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('カタカナ', $index), $autolinkedDescription);
-        $this->assertContains($this->getHashtagLink('hashtag_hashtag', $index), $autolinkedDescription);
-        $this->assertNotContains($this->getHashtagLink('hashtag-nothashtag', $index), $autolinkedDescription);
+        $this->assertContainsPolyfill($this->getHashtagLink('hashtag', $index), $autolinkedDescription);
+        $this->assertNotContainsPolyfill(' #hashtag', $autolinkedDescription);
+        $this->assertNotContainsPolyfill('>#nothashtag', $autolinkedDescription);
+        $this->assertContainsPolyfill($this->getHashtagLink('ашок', $index), $autolinkedDescription);
+        $this->assertContainsPolyfill($this->getHashtagLink('カタカナ', $index), $autolinkedDescription);
+        $this->assertContainsPolyfill($this->getHashtagLink('hashtag_hashtag', $index), $autolinkedDescription);
+        $this->assertNotContainsPolyfill($this->getHashtagLink('hashtag-nothashtag', $index), $autolinkedDescription);
     }
 
     /**
@@ -456,9 +549,9 @@ class LinkUtilsTest extends TestCase
         $rawDescription = 'blabla #hashtag x#nothashtag';
         $autolinkedDescription = hashtag_autolink($rawDescription);
 
-        $this->assertContains($this->getHashtagLink('hashtag'), $autolinkedDescription);
-        $this->assertNotContains(' #hashtag', $autolinkedDescription);
-        $this->assertNotContains('>#nothashtag', $autolinkedDescription);
+        $this->assertContainsPolyfill($this->getHashtagLink('hashtag'), $autolinkedDescription);
+        $this->assertNotContainsPolyfill(' #hashtag', $autolinkedDescription);
+        $this->assertNotContainsPolyfill('>#nothashtag', $autolinkedDescription);
     }
 
     /**
@@ -491,7 +584,7 @@ class LinkUtilsTest extends TestCase
      */
     private function getHashtagLink($hashtag, $index = '')
     {
-        $hashtagLink = '<a href="' . $index . '?addtag=$1" title="Hashtag $1">#$1</a>';
+        $hashtagLink = '<a href="' . $index . './add-tag/$1" title="Hashtag $1">#$1</a>';
         return str_replace('$1', $hashtag, $hashtagLink);
     }
 }
index 0afbcba61bb04da17b45b9ceb702a086ed816188..3508a7b170d1572b017120b1966f911ed9f7892f 100644 (file)
@@ -18,7 +18,19 @@ require_once 'application/bookmark/LinkUtils.php';
 require_once 'application/Utils.php';
 require_once 'application/http/UrlUtils.php';
 require_once 'application/http/HttpUtils.php';
-require_once 'application/feed/Cache.php';
-require_once 'tests/utils/ReferenceLinkDB.php';
-require_once 'tests/utils/ReferenceHistory.php';
+require_once 'tests/TestCase.php';
+require_once 'tests/container/ShaarliTestContainer.php';
+require_once 'tests/front/controller/visitor/FrontControllerMockHelper.php';
+require_once 'tests/front/controller/admin/FrontAdminControllerMockHelper.php';
+require_once 'tests/updater/DummyUpdater.php';
 require_once 'tests/utils/FakeBookmarkService.php';
+require_once 'tests/utils/FakeConfigManager.php';
+require_once 'tests/utils/ReferenceHistory.php';
+require_once 'tests/utils/ReferenceLinkDB.php';
+require_once 'tests/utils/ReferenceSessionIdHashes.php';
+
+\ReferenceSessionIdHashes::genAllHashes();
+
+if (!defined('SHAARLI_MUTEX_FILE')) {
+    define('SHAARLI_MUTEX_FILE', __FILE__);
+}
index 33160eb0d88dd7d37bd69aa2c903a84515a7baf8..c0ba5b8f07c1c0513ae26050408b3c76f326b9ae 100644 (file)
@@ -4,14 +4,14 @@ namespace Shaarli\Config;
 /**
  * Class ConfigJsonTest
  */
-class ConfigJsonTest extends \PHPUnit\Framework\TestCase
+class ConfigJsonTest extends \Shaarli\TestCase
 {
     /**
      * @var ConfigJson
      */
     protected $configIO;
 
-    public function setUp()
+    protected function setUp(): void
     {
         $this->configIO = new ConfigJson();
     }
@@ -38,12 +38,12 @@ class ConfigJsonTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Read a non existent config file -> empty array.
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessageRegExp  /An error occurred while parsing JSON configuration file \([\w\/\.]+\): error code #4/
      */
     public function testReadInvalidJson()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/An error occurred while parsing JSON configuration file \\([\\w\\/\\.]+\\): error code #4/');
+
         $this->configIO->read('tests/utils/config/configInvalid.json.php');
     }
 
@@ -110,22 +110,11 @@ class ConfigJsonTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Write to invalid path.
-     *
-     * @expectedException \Shaarli\Exceptions\IOException
-     */
-    public function testWriteInvalidArray()
-    {
-        $conf = array('conf' => 'value');
-        @$this->configIO->write(array(), $conf);
-    }
-
-    /**
-     * Write to invalid path.
-     *
-     * @expectedException \Shaarli\Exceptions\IOException
      */
     public function testWriteInvalidBlank()
     {
+        $this->expectException(\Shaarli\Exceptions\IOException::class);
+
         $conf = array('conf' => 'value');
         @$this->configIO->write('', $conf);
     }
index 33830bc94bcc3584380b1a18259e4cf9ec101980..65d8ba2c64f70aa58893a9a31f0ae0d08b42c2d5 100644 (file)
@@ -7,14 +7,14 @@ namespace Shaarli\Config;
  * Note: it only test the manager with ConfigJson,
  *  ConfigPhp is only a workaround to handle the transition to JSON type.
  */
-class ConfigManagerTest extends \PHPUnit\Framework\TestCase
+class ConfigManagerTest extends \Shaarli\TestCase
 {
     /**
      * @var ConfigManager
      */
     protected $conf;
 
-    public function setUp()
+    protected function setUp(): void
     {
         $this->conf = new ConfigManager('tests/utils/config/configJson');
     }
@@ -95,44 +95,44 @@ class ConfigManagerTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Set with an empty key.
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessageRegExp #^Invalid setting key parameter. String expected, got.*#
      */
     public function testSetEmptyKey()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('#^Invalid setting key parameter. String expected, got.*#');
+
         $this->conf->set('', 'stuff');
     }
 
     /**
      * Set with an array key.
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessageRegExp #^Invalid setting key parameter. String expected, got.*#
      */
     public function testSetArrayKey()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('#^Invalid setting key parameter. String expected, got.*#');
+
         $this->conf->set(array('foo' => 'bar'), 'stuff');
     }
 
     /**
      * Remove with an empty key.
-     *
-     * @expectedException \Exception
-     * @expectedExceptionMessageRegExp #^Invalid setting key parameter. String expected, got.*#
      */
     public function testRmoveEmptyKey()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('#^Invalid setting key parameter. String expected, got.*#');
+
         $this->conf->remove('');
     }
 
     /**
      * Try to write the config without mandatory parameter (e.g. 'login').
-     *
-     * @expectedException Shaarli\Config\Exception\MissingFieldConfigException
      */
     public function testWriteMissingParameter()
     {
+        $this->expectException(\Shaarli\Config\Exception\MissingFieldConfigException::class);
+
         $this->conf->setConfigFile('tests/utils/config/configTmp');
         $this->assertFalse(file_exists($this->conf->getConfigFileExt()));
         $this->conf->reload();
index fb91b51ba6007e9c1452dea17fa6681076ff1bc1..7bf9fe64933925d23c4f96362894c512ea918997 100644 (file)
@@ -8,14 +8,14 @@ namespace Shaarli\Config;
  * which are kept between tests.
  * @runTestsInSeparateProcesses
  */
-class ConfigPhpTest extends \PHPUnit\Framework\TestCase
+class ConfigPhpTest extends \Shaarli\TestCase
 {
     /**
      * @var ConfigPhp
      */
     protected $configIO;
 
-    public function setUp()
+    protected function setUp(): void
     {
         $this->configIO = new ConfigPhp();
     }
index d7a70e6886b54a889eed845560350eae05cefabe..fa72d8c4ae40438ccdac5af72c3734cd192fb0b5 100644 (file)
@@ -2,13 +2,14 @@
 namespace Shaarli\Config;
 
 use Shaarli\Config\Exception\PluginConfigOrderException;
+use Shaarli\Plugin\PluginManager;
 
 require_once 'application/config/ConfigPlugin.php';
 
 /**
  * Unitary tests for Shaarli config related functions
  */
-class ConfigPluginTest extends \PHPUnit\Framework\TestCase
+class ConfigPluginTest extends \Shaarli\TestCase
 {
     /**
      * Test save_plugin_config with valid data.
@@ -17,32 +18,39 @@ class ConfigPluginTest extends \PHPUnit\Framework\TestCase
      */
     public function testSavePluginConfigValid()
     {
-        $data = array(
+        $data = [
             'order_plugin1' => 2,   // no plugin related
             'plugin2' => 0,         // new - at the end
             'plugin3' => 0,         // 2nd
             'order_plugin3' => 8,
             'plugin4' => 0,         // 1st
             'order_plugin4' => 5,
-        );
+        ];
 
-        $expected = array(
+        $expected = [
             'plugin3',
             'plugin4',
             'plugin2',
-        );
+        ];
+
+        mkdir($path = __DIR__ . '/folder');
+        PluginManager::$PLUGINS_PATH = $path;
+        array_map(function (string $plugin) use ($path) { touch($path . '/' . $plugin); }, $expected);
 
         $out = save_plugin_config($data);
         $this->assertEquals($expected, $out);
+
+        array_map(function (string $plugin) use ($path) { unlink($path . '/' . $plugin); }, $expected);
+        rmdir($path);
     }
 
     /**
      * Test save_plugin_config with invalid data.
-     *
-     * @expectedException Shaarli\Config\Exception\PluginConfigOrderException
      */
     public function testSavePluginConfigInvalid()
     {
+        $this->expectException(\Shaarli\Config\Exception\PluginConfigOrderException::class);
+
         $data = array(
             'plugin2' => 0,
             'plugin3' => 0,
index 9b97ed6d6f4b71c5b342189f406a7d57c075c637..3d43c34470d098dd69a18e5a78c874187c0ca83d 100644 (file)
@@ -4,13 +4,27 @@ declare(strict_types=1);
 
 namespace Shaarli\Container;
 
-use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
 use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\Feed\FeedBuilder;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Front\Controller\Visitor\ErrorController;
+use Shaarli\Front\Controller\Visitor\ErrorNotFoundController;
 use Shaarli\History;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
+use Shaarli\Netscape\NetscapeBookmarkUtils;
+use Shaarli\Plugin\PluginManager;
 use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\CookieManager;
 use Shaarli\Security\LoginManager;
 use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Shaarli\Thumbnailer;
+use Shaarli\Updater\Updater;
+use Slim\Http\Environment;
 
 class ContainerBuilderTest extends TestCase
 {
@@ -26,24 +40,54 @@ class ContainerBuilderTest extends TestCase
     /** @var ContainerBuilder */
     protected $containerBuilder;
 
+    /** @var CookieManager */
+    protected $cookieManager;
+
     public function setUp(): void
     {
         $this->conf = new ConfigManager('tests/utils/config/configJson');
         $this->sessionManager = $this->createMock(SessionManager::class);
+        $this->cookieManager = $this->createMock(CookieManager::class);
+
         $this->loginManager = $this->createMock(LoginManager::class);
+        $this->loginManager->method('isLoggedIn')->willReturn(true);
 
-        $this->containerBuilder = new ContainerBuilder($this->conf, $this->sessionManager, $this->loginManager);
+        $this->containerBuilder = new ContainerBuilder(
+            $this->conf,
+            $this->sessionManager,
+            $this->cookieManager,
+            $this->loginManager,
+            $this->createMock(LoggerInterface::class)
+        );
     }
 
     public function testBuildContainer(): void
     {
         $container = $this->containerBuilder->build();
 
+        static::assertInstanceOf(BookmarkServiceInterface::class, $container->bookmarkService);
+        static::assertInstanceOf(CookieManager::class, $container->cookieManager);
         static::assertInstanceOf(ConfigManager::class, $container->conf);
-        static::assertInstanceOf(SessionManager::class, $container->sessionManager);
-        static::assertInstanceOf(LoginManager::class, $container->loginManager);
+        static::assertInstanceOf(ErrorController::class, $container->errorHandler);
+        static::assertInstanceOf(Environment::class, $container->environment);
+        static::assertInstanceOf(FeedBuilder::class, $container->feedBuilder);
+        static::assertInstanceOf(FormatterFactory::class, $container->formatterFactory);
         static::assertInstanceOf(History::class, $container->history);
-        static::assertInstanceOf(BookmarkServiceInterface::class, $container->bookmarkService);
+        static::assertInstanceOf(HttpAccess::class, $container->httpAccess);
+        static::assertInstanceOf(LoginManager::class, $container->loginManager);
+        static::assertInstanceOf(LoggerInterface::class, $container->logger);
+        static::assertInstanceOf(MetadataRetriever::class, $container->metadataRetriever);
+        static::assertInstanceOf(NetscapeBookmarkUtils::class, $container->netscapeBookmarkUtils);
         static::assertInstanceOf(PageBuilder::class, $container->pageBuilder);
+        static::assertInstanceOf(PageCacheManager::class, $container->pageCacheManager);
+        static::assertInstanceOf(ErrorController::class, $container->phpErrorHandler);
+        static::assertInstanceOf(ErrorNotFoundController::class, $container->notFoundHandler);
+        static::assertInstanceOf(PluginManager::class, $container->pluginManager);
+        static::assertInstanceOf(SessionManager::class, $container->sessionManager);
+        static::assertInstanceOf(Thumbnailer::class, $container->thumbnailer);
+        static::assertInstanceOf(Updater::class, $container->updater);
+
+        // Set by the middleware
+        static::assertNull($container->basePath);
     }
 }
diff --git a/tests/container/ShaarliTestContainer.php b/tests/container/ShaarliTestContainer.php
new file mode 100644 (file)
index 0000000..7dbe914
--- /dev/null
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Container;
+
+use PHPUnit\Framework\MockObject\MockObject;
+use Shaarli\Bookmark\BookmarkServiceInterface;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Feed\FeedBuilder;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\History;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Plugin\PluginManager;
+use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\Thumbnailer;
+
+/**
+ * Test helper allowing auto-completion for MockObjects.
+ *
+ * @property mixed[]                             $environment     $_SERVER automatically injected by Slim
+ * @property MockObject|ConfigManager            $conf
+ * @property MockObject|SessionManager           $sessionManager
+ * @property MockObject|LoginManager             $loginManager
+ * @property MockObject|string                   $webPath
+ * @property MockObject|History                  $history
+ * @property MockObject|BookmarkServiceInterface $bookmarkService
+ * @property MockObject|PageBuilder              $pageBuilder
+ * @property MockObject|PluginManager            $pluginManager
+ * @property MockObject|FormatterFactory         $formatterFactory
+ * @property MockObject|PageCacheManager         $pageCacheManager
+ * @property MockObject|FeedBuilder              $feedBuilder
+ * @property MockObject|Thumbnailer              $thumbnailer
+ * @property MockObject|HttpAccess               $httpAccess
+ */
+class ShaarliTestContainer extends ShaarliContainer
+{
+
+}
index 363028a2dae3ba4779a197ec58a031f76a45382d..904db9dc2251a4f23da5ecbf57df59c1b85e5d72 100644 (file)
@@ -7,17 +7,17 @@ namespace Shaarli\Feed;
 /**
  * Unitary tests for cached pages
  */
-class CachedPageTest extends \PHPUnit\Framework\TestCase
+class CachedPageTest extends \Shaarli\TestCase
 {
     // test cache directory
     protected static $testCacheDir = 'sandbox/pagecache';
-    protected static $url = 'http://shaar.li/?do=atom';
+    protected static $url = 'http://shaar.li/feed/atom';
     protected static $filename;
 
     /**
      * Create the cache directory if needed
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
         if (!is_dir(self::$testCacheDir)) {
             mkdir(self::$testCacheDir);
@@ -28,7 +28,7 @@ class CachedPageTest extends \PHPUnit\Framework\TestCase
     /**
      * Reset the page cache
      */
-    public function setUp()
+    protected function setUp(): void
     {
         if (file_exists(self::$filename)) {
             unlink(self::$filename);
@@ -42,8 +42,8 @@ class CachedPageTest extends \PHPUnit\Framework\TestCase
     {
         new CachedPage(self::$testCacheDir, '', true);
         new CachedPage(self::$testCacheDir, '', false);
-        new CachedPage(self::$testCacheDir, 'http://shaar.li/?do=rss', true);
-        new CachedPage(self::$testCacheDir, 'http://shaar.li/?do=atom', false);
+        new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/rss', true);
+        new CachedPage(self::$testCacheDir, 'http://shaar.li/feed/atom', false);
         $this->addToAssertionCount(1);
     }
 
index 5467189192bed2ac6442df947758394fef57244e..6b9204eb64949cb17ada2c26bf605e7f9a3373a4 100644 (file)
@@ -3,6 +3,7 @@
 namespace Shaarli\Feed;
 
 use DateTime;
+use malkusch\lock\mutex\NoMutex;
 use ReferenceLinkDB;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFileService;
@@ -10,13 +11,14 @@ use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Formatter\FormatterFactory;
 use Shaarli\History;
+use Shaarli\TestCase;
 
 /**
  * FeedBuilderTest class.
  *
  * Unit tests for FeedBuilder.
  */
-class FeedBuilderTest extends \PHPUnit\Framework\TestCase
+class FeedBuilderTest extends TestCase
 {
     /**
      * @var string locale Basque (Spain).
@@ -44,8 +46,9 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
     /**
      * Called before every test method.
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
+        $mutex = new NoMutex();
         $conf = new ConfigManager('tests/utils/config/configJson');
         $conf->set('resource.datastore', self::$testDatastore);
         $refLinkDB = new \ReferenceLinkDB();
@@ -53,34 +56,17 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $history = new History('sandbox/history.php');
         $factory = new FormatterFactory($conf, true);
         self::$formatter = $factory->getFormatter();
-        self::$bookmarkService = new BookmarkFileService($conf, $history, true);
+        self::$bookmarkService = new BookmarkFileService($conf, $history, $mutex, true);
 
         self::$serverInfo = array(
             'HTTPS' => 'Off',
             'SERVER_NAME' => 'host.tld',
             'SERVER_PORT' => '80',
             'SCRIPT_NAME' => '/index.php',
-            'REQUEST_URI' => '/index.php?do=feed',
+            'REQUEST_URI' => '/feed/atom',
         );
     }
 
-    /**
-     * Test GetTypeLanguage().
-     */
-    public function testGetTypeLanguage()
-    {
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_ATOM, null, null, false);
-        $feedBuilder->setLocale(self::$LOCALE);
-        $this->assertEquals(self::$ATOM_LANGUAGUE, $feedBuilder->getTypeLanguage());
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_RSS, null, null, false);
-        $feedBuilder->setLocale(self::$LOCALE);
-        $this->assertEquals(self::$RSS_LANGUAGE, $feedBuilder->getTypeLanguage());
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_ATOM, null, null, false);
-        $this->assertEquals('en', $feedBuilder->getTypeLanguage());
-        $feedBuilder = new FeedBuilder(null, self::$formatter, FeedBuilder::$FEED_RSS, null, null, false);
-        $this->assertEquals('en-en', $feedBuilder->getTypeLanguage());
-    }
-
     /**
      * Test buildData with RSS feed.
      */
@@ -89,35 +75,33 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_RSS,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_RSS, null);
         // Test headers (RSS)
         $this->assertEquals(self::$RSS_LANGUAGE, $data['language']);
         $this->assertRegExp('/Wed, 03 Aug 2016 09:30:33 \+\d{4}/', $data['last_update']);
         $this->assertEquals(true, $data['show_dates']);
-        $this->assertEquals('http://host.tld/index.php?do=feed', $data['self_link']);
+        $this->assertEquals('http://host.tld/feed/atom', $data['self_link']);
         $this->assertEquals('http://host.tld/', $data['index_url']);
         $this->assertFalse($data['usepermalinks']);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
 
         // Test first not pinned link (note link)
-        $link = $data['links'][array_keys($data['links'])[2]];
+        $link = $data['links'][array_keys($data['links'])[0]];
         $this->assertEquals(41, $link['id']);
         $this->assertEquals(DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'), $link['created']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['guid']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['url']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['guid']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['url']);
         $this->assertRegExp('/Tue, 10 Mar 2015 11:46:51 \+\d{4}/', $link['pub_iso_date']);
         $pub = DateTime::createFromFormat(DateTime::RSS, $link['pub_iso_date']);
         $up = DateTime::createFromFormat(DateTime::ATOM, $link['up_iso_date']);
         $this->assertEquals($pub, $up);
-        $this->assertContains('Stallman has a beard', $link['description']);
-        $this->assertContains('Permalink', $link['description']);
-        $this->assertContains('http://host.tld/?WDWyig', $link['description']);
+        $this->assertContainsPolyfill('Stallman has a beard', $link['description']);
+        $this->assertContainsPolyfill('Permalink', $link['description']);
+        $this->assertContainsPolyfill('http://host.tld/shaare/WDWyig', $link['description']);
         $this->assertEquals(1, count($link['taglist']));
         $this->assertEquals('sTuff', $link['taglist'][0]);
 
@@ -140,16 +124,14 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['last_update']);
-        $link = $data['links'][array_keys($data['links'])[2]];
+        $link = $data['links'][array_keys($data['links'])[0]];
         $this->assertRegExp('/2015-03-10T11:46:51\+\d{2}:\d{2}/', $link['pub_iso_date']);
         $this->assertRegExp('/2016-08-03T09:30:33\+\d{2}:\d{2}/', $data['links'][8]['up_iso_date']);
     }
@@ -166,13 +148,11 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            $criteria,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, $criteria);
         $this->assertEquals(1, count($data['links']));
         $link = array_shift($data['links']);
         $this->assertEquals(41, $link['id']);
@@ -190,15 +170,13 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            $criteria,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, $criteria);
         $this->assertEquals(3, count($data['links']));
-        $link = $data['links'][array_keys($data['links'])[2]];
+        $link = $data['links'][array_keys($data['links'])[0]];
         $this->assertEquals(41, $link['id']);
         $this->assertEquals(DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'), $link['created']);
     }
@@ -211,32 +189,30 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
         $feedBuilder->setUsePermalinks(true);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertTrue($data['usepermalinks']);
         // First link is a permalink
-        $link = $data['links'][array_keys($data['links'])[2]];
+        $link = $data['links'][array_keys($data['links'])[0]];
         $this->assertEquals(41, $link['id']);
         $this->assertEquals(DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'), $link['created']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['guid']);
-        $this->assertEquals('http://host.tld/?WDWyig', $link['url']);
-        $this->assertContains('Direct link', $link['description']);
-        $this->assertContains('http://host.tld/?WDWyig', $link['description']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['guid']);
+        $this->assertEquals('http://host.tld/shaare/WDWyig', $link['url']);
+        $this->assertContainsPolyfill('Direct link', $link['description']);
+        $this->assertContainsPolyfill('http://host.tld/shaare/WDWyig', $link['description']);
         // Second link is a direct link
-        $link = $data['links'][array_keys($data['links'])[3]];
+        $link = $data['links'][array_keys($data['links'])[1]];
         $this->assertEquals(8, $link['id']);
         $this->assertEquals(DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114633'), $link['created']);
-        $this->assertEquals('http://host.tld/?RttfEw', $link['guid']);
+        $this->assertEquals('http://host.tld/shaare/RttfEw', $link['guid']);
         $this->assertEquals('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['url']);
-        $this->assertContains('Direct link', $link['description']);
-        $this->assertContains('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['description']);
+        $this->assertContainsPolyfill('Direct link', $link['description']);
+        $this->assertContainsPolyfill('https://static.fsf.org/nosvn/faif-2.0.pdf', $link['description']);
     }
 
     /**
@@ -247,14 +223,12 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
         $feedBuilder->setHideDates(true);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertFalse($data['show_dates']);
 
@@ -262,14 +236,12 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
-            self::$serverInfo,
-            null,
+            static::$serverInfo,
             true
         );
         $feedBuilder->setLocale(self::$LOCALE);
         $feedBuilder->setHideDates(true);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
         $this->assertEquals(ReferenceLinkDB::$NB_LINKS_TOTAL, count($data['links']));
         $this->assertTrue($data['show_dates']);
     }
@@ -284,28 +256,26 @@ class FeedBuilderTest extends \PHPUnit\Framework\TestCase
             'SERVER_NAME' => 'host.tld',
             'SERVER_PORT' => '8080',
             'SCRIPT_NAME' => '/~user/shaarli/index.php',
-            'REQUEST_URI' => '/~user/shaarli/index.php?do=feed',
+            'REQUEST_URI' => '/~user/shaarli/feed/atom',
         );
         $feedBuilder = new FeedBuilder(
             self::$bookmarkService,
             self::$formatter,
-            FeedBuilder::$FEED_ATOM,
             $serverInfo,
-            null,
             false
         );
         $feedBuilder->setLocale(self::$LOCALE);
-        $data = $feedBuilder->buildData();
+        $data = $feedBuilder->buildData(FeedBuilder::$FEED_ATOM, null);
 
         $this->assertEquals(
-            'http://host.tld:8080/~user/shaarli/index.php?do=feed',
+            'http://host.tld:8080/~user/shaarli/feed/atom',
             $data['self_link']
         );
 
         // Test first link (note link)
-        $link = $data['links'][array_keys($data['links'])[2]];
-        $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['guid']);
-        $this->assertEquals('http://host.tld:8080/~user/shaarli/?WDWyig', $link['url']);
-        $this->assertContains('http://host.tld:8080/~user/shaarli/?addtag=hashtag', $link['description']);
+        $link = $data['links'][array_keys($data['links'])[0]];
+        $this->assertEquals('http://host.tld:8080/~user/shaarli/shaare/WDWyig', $link['guid']);
+        $this->assertEquals('http://host.tld:8080/~user/shaarli/shaare/WDWyig', $link['url']);
+        $this->assertContainsPolyfill('http://host.tld:8080/~user/shaarli/./add-tag/hashtag', $link['description']);
     }
 }
index 382a560efcb2785ecbf357d26c466fa6ff18ccf1..3fc6f8dc58f4c8f38e877f1c49c18d2d083cbcde 100644 (file)
@@ -3,9 +3,9 @@
 namespace Shaarli\Formatter;
 
 use DateTime;
-use PHPUnit\Framework\TestCase;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
+use Shaarli\TestCase;
 
 /**
  * Class BookmarkDefaultFormatterTest
@@ -25,7 +25,7 @@ class BookmarkDefaultFormatterTest extends TestCase
     /**
      * Initialize formatter instance.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         copy('tests/utils/config/configJson.json.php', self::$testConf .'.json.php');
         $this->conf = new ConfigManager(self::$testConf);
@@ -123,7 +123,7 @@ class BookmarkDefaultFormatterTest extends TestCase
         $description[0] = 'This a &lt;strong&gt;description&lt;/strong&gt;<br />';
         $url = 'https://sub.domain.tld?query=here&amp;for=real#hash';
         $description[1] = 'text <a href="'. $url .'">'. $url .'</a> more text<br />';
-        $description[2] = 'Also, there is an <a href="?addtag=hashtag" '.
+        $description[2] = 'Also, there is an <a href="./add-tag/hashtag" '.
             'title="Hashtag hashtag">#hashtag</a> added<br />';
         $description[3] = '&nbsp; &nbsp; A &nbsp;N &nbsp;D KEEP &nbsp; &nbsp; '.
             'SPACES &nbsp; &nbsp;! &nbsp; <br />';
@@ -148,7 +148,7 @@ class BookmarkDefaultFormatterTest extends TestCase
         $this->assertEquals($root . $short, $link['url']);
         $this->assertEquals($root . $short, $link['real_url']);
         $this->assertEquals(
-            'Text <a href="'. $root .'?addtag=hashtag" title="Hashtag hashtag">'.
+            'Text <a href="'. $root .'./add-tag/hashtag" title="Hashtag hashtag">'.
             '#hashtag</a> more text',
             $link['description']
         );
@@ -174,4 +174,119 @@ class BookmarkDefaultFormatterTest extends TestCase
         $this->assertSame($tags, $link['taglist']);
         $this->assertSame(implode(' ', $tags), $link['tags']);
     }
+
+    /**
+     * Test formatTitleHtml with search result highlight.
+     */
+    public function testFormatTitleHtmlWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setTitle('PSR-2: Coding Style Guide');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['title' => [
+                ['start' => 0, 'end' => 5], // "psr-2"
+                ['start' => 7, 'end' => 13], // coding
+                ['start' => 20, 'end' => 25], // guide
+            ]]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            '<span class="search-highlight">PSR-2</span>: ' .
+            '<span class="search-highlight">Coding</span> Style ' .
+            '<span class="search-highlight">Guide</span>',
+            $link['title_html']
+        );
+    }
+
+    /**
+     * Test formatDescription with search result highlight.
+     */
+    public function testFormatDescriptionWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setDescription('This guide extends and expands on PSR-1, the basic coding standard.');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['description' => [
+                ['start' => 0, 'end' => 10], // "This guide"
+                ['start' => 45, 'end' => 50], // basic
+                ['start' => 58, 'end' => 67], // standard.
+            ]]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            '<span class="search-highlight">This guide</span> extends and expands on PSR-1, the ' .
+            '<span class="search-highlight">basic</span> coding ' .
+            '<span class="search-highlight">standard.</span>',
+            $link['description']
+        );
+    }
+
+    /**
+     * Test formatUrlHtml with search result highlight.
+     */
+    public function testFormatUrlHtmlWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setUrl('http://www.php-fig.org/psr/psr-2/');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['url' => [
+                ['start' => 0, 'end' => 4], // http
+                ['start' => 15, 'end' => 18], // fig
+                ['start' => 27, 'end' => 33], // "psr-2/"
+            ]]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            '<span class="search-highlight">http</span>://www.php-' .
+            '<span class="search-highlight">fig</span>.org/psr/' .
+            '<span class="search-highlight">psr-2/</span>',
+            $link['url_html']
+        );
+    }
+
+    /**
+     * Test formatTagListHtml with search result highlight.
+     */
+    public function testFormatTagListHtmlWithSearchHighlight(): void
+    {
+        $this->formatter = new BookmarkDefaultFormatter($this->conf, false);
+
+        $bookmark = new Bookmark();
+        $bookmark->setTagsString('coding-style standards quality assurance');
+        $bookmark->addAdditionalContentEntry(
+            'search_highlight',
+            ['tags' => [
+                ['start' => 0, 'end' => 12], // coding-style
+                ['start' => 23, 'end' => 30], // quality
+                ['start' => 31, 'end' => 40], // assurance
+            ],]
+        );
+
+        $link = $this->formatter->format($bookmark);
+
+        $this->assertSame(
+            [
+                '<span class="search-highlight">coding-style</span>',
+                'standards',
+                '<span class="search-highlight">quality</span>',
+                '<span class="search-highlight">assurance</span>',
+            ],
+            $link['taglist_html']
+        );
+    }
 }
diff --git a/tests/formatter/BookmarkMarkdownExtraFormatterTest.php b/tests/formatter/BookmarkMarkdownExtraFormatterTest.php
new file mode 100644 (file)
index 0000000..d4941ef
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+
+namespace Shaarli\Formatter;
+
+use DateTime;
+use PHPUnit\Framework\TestCase;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkMarkdownExtraFormatterTest
+ * @package Shaarli\Formatter
+ */
+class BookmarkMarkdownExtraFormatterTest extends TestCase
+{
+    /** @var string Path of test config file */
+    protected static $testConf = 'sandbox/config';
+
+    /** @var BookmarkFormatter */
+    protected $formatter;
+
+    /** @var ConfigManager instance */
+    protected $conf;
+
+    /**
+     * Initialize formatter instance.
+     */
+    public function setUp(): void
+    {
+        copy('tests/utils/config/configJson.json.php', self::$testConf .'.json.php');
+        $this->conf = new ConfigManager(self::$testConf);
+        $this->formatter = new BookmarkMarkdownExtraFormatter($this->conf, true);
+    }
+
+    /**
+     * Test formatting a bookmark with all its attribute filled.
+     */
+    public function testFormatExtra(): void
+    {
+        $bookmark = new Bookmark();
+        $bookmark->setId($id = 11);
+        $bookmark->setShortUrl($short = 'abcdef');
+        $bookmark->setUrl('https://sub.domain.tld?query=here&for=real#hash');
+        $bookmark->setTitle($title = 'This is a <strong>bookmark</strong>');
+        $bookmark->setDescription('<h2>Content</h2><p>`Here is some content</p>');
+        $bookmark->setTags($tags = ['tag1', 'bookmark', 'other', '<script>alert("xss");</script>']);
+        $bookmark->setThumbnail('http://domain2.tdl2/?type=img&name=file.png');
+        $bookmark->setSticky(true);
+        $bookmark->setCreated($created = DateTime::createFromFormat('Ymd_His', '20190521_190412'));
+        $bookmark->setUpdated($updated = DateTime::createFromFormat('Ymd_His', '20190521_191213'));
+        $bookmark->setPrivate(true);
+
+        $link = $this->formatter->format($bookmark);
+        $this->assertEquals($id, $link['id']);
+        $this->assertEquals($short, $link['shorturl']);
+        $this->assertEquals('https://sub.domain.tld?query=here&amp;for=real#hash', $link['url']);
+        $this->assertEquals(
+            'https://sub.domain.tld?query=here&amp;for=real#hash',
+            $link['real_url']
+        );
+        $this->assertEquals('This is a &lt;strong&gt;bookmark&lt;/strong&gt;', $link['title']);
+        $this->assertEquals(
+            '<div class="markdown"><p>'.
+                '&lt;h2&gt;Content&lt;/h2&gt;&lt;p&gt;`Here is some content&lt;/p&gt;'.
+            '</p></div>',
+            $link['description']
+        );
+        $tags[3] = '&lt;script&gt;alert(&quot;xss&quot;);&lt;/script&gt;';
+        $this->assertEquals($tags, $link['taglist']);
+        $this->assertEquals(implode(' ', $tags), $link['tags']);
+        $this->assertEquals(
+            'http://domain2.tdl2/?type=img&amp;name=file.png',
+            $link['thumbnail']
+        );
+        $this->assertEquals($created, $link['created']);
+        $this->assertEquals($created->getTimestamp(), $link['timestamp']);
+        $this->assertEquals($updated, $link['updated']);
+        $this->assertEquals($updated->getTimestamp(), $link['updated_timestamp']);
+        $this->assertTrue($link['private']);
+        $this->assertTrue($link['sticky']);
+        $this->assertEquals('private', $link['class']);
+    }
+
+    /**
+     * Test formatting a bookmark with all its attribute filled.
+     */
+    public function testFormatExtraMinimal(): void
+    {
+        $bookmark = new Bookmark();
+
+        $link = $this->formatter->format($bookmark);
+        $this->assertEmpty($link['id']);
+        $this->assertEmpty($link['shorturl']);
+        $this->assertEmpty($link['url']);
+        $this->assertEmpty($link['real_url']);
+        $this->assertEmpty($link['title']);
+        $this->assertEmpty($link['description']);
+        $this->assertEmpty($link['taglist']);
+        $this->assertEmpty($link['tags']);
+        $this->assertEmpty($link['thumbnail']);
+        $this->assertEmpty($link['created']);
+        $this->assertEmpty($link['timestamp']);
+        $this->assertEmpty($link['updated']);
+        $this->assertEmpty($link['updated_timestamp']);
+        $this->assertFalse($link['private']);
+        $this->assertFalse($link['sticky']);
+        $this->assertEmpty($link['class']);
+    }
+
+    /**
+     * Make sure that the description is properly formatted by the default formatter.
+     */
+    public function testFormatExtrraDescription(): void
+    {
+        $description = 'This a <strong>description</strong>'. PHP_EOL;
+        $description .= 'text https://sub.domain.tld?query=here&for=real#hash more text'. PHP_EOL;
+        $description .= 'Also, there is an #hashtag added'. PHP_EOL;
+        $description .= '    A  N  D KEEP     SPACES    !   '. PHP_EOL;
+        $description .= '# Header {.class}'. PHP_EOL;
+
+        $bookmark = new Bookmark();
+        $bookmark->setDescription($description);
+        $link = $this->formatter->format($bookmark);
+
+        $description = '<div class="markdown"><p>';
+        $description .= 'This a &lt;strong&gt;description&lt;/strong&gt;<br />'. PHP_EOL;
+        $url = 'https://sub.domain.tld?query=here&amp;for=real#hash';
+        $description .= 'text <a href="'. $url .'">'. $url .'</a> more text<br />'. PHP_EOL;
+        $description .= 'Also, there is an <a href="./add-tag/hashtag">#hashtag</a> added<br />'. PHP_EOL;
+        $description .= 'A  N  D KEEP     SPACES    !   </p>' . PHP_EOL;
+        $description .= '<h1 class="class">Header</h1>';
+        $description .= '</div>';
+
+        $this->assertEquals($description, $link['description']);
+    }
+
+    /**
+     * Test formatting URL with an index_url set
+     * It should prepend relative links.
+     */
+    public function testFormatExtraNoteWithIndexUrl(): void
+    {
+        $bookmark = new Bookmark();
+        $bookmark->setUrl($short = '?abcdef');
+        $description = 'Text #hashtag more text';
+        $bookmark->setDescription($description);
+
+        $this->formatter->addContextData('index_url', $root = 'https://domain.tld/hithere/');
+
+        $description = '<div class="markdown"><p>';
+        $description .= 'Text <a href="'. $root .'./add-tag/hashtag">#hashtag</a> more text';
+        $description .= '</p></div>';
+
+        $link = $this->formatter->format($bookmark);
+        $this->assertEquals($root . $short, $link['url']);
+        $this->assertEquals($root . $short, $link['real_url']);
+        $this->assertEquals(
+            $description,
+            $link['description']
+        );
+    }
+}
index f1f12c04efa6ef3f82e9a1d4ba76a280c1e0ce71..ab6b40809ab4563337142b03ded08261a893b065 100644 (file)
@@ -3,9 +3,9 @@
 namespace Shaarli\Formatter;
 
 use DateTime;
-use PHPUnit\Framework\TestCase;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
+use Shaarli\TestCase;
 
 /**
  * Class BookmarkMarkdownFormatterTest
@@ -25,7 +25,7 @@ class BookmarkMarkdownFormatterTest extends TestCase
     /**
      * Initialize formatter instance.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         copy('tests/utils/config/configJson.json.php', self::$testConf .'.json.php');
         $this->conf = new ConfigManager(self::$testConf);
@@ -125,7 +125,7 @@ class BookmarkMarkdownFormatterTest extends TestCase
         $description .= 'This a &lt;strong&gt;description&lt;/strong&gt;<br />'. PHP_EOL;
         $url = 'https://sub.domain.tld?query=here&amp;for=real#hash';
         $description .= 'text <a href="'. $url .'">'. $url .'</a> more text<br />'. PHP_EOL;
-        $description .= 'Also, there is an <a href="?addtag=hashtag">#hashtag</a> added<br />'. PHP_EOL;
+        $description .= 'Also, there is an <a href="./add-tag/hashtag">#hashtag</a> added<br />'. PHP_EOL;
         $description .= 'A  N  D KEEP     SPACES    !   ';
         $description .= '</p></div>';
 
@@ -146,7 +146,7 @@ class BookmarkMarkdownFormatterTest extends TestCase
         $this->formatter->addContextData('index_url', $root = 'https://domain.tld/hithere/');
 
         $description = '<div class="markdown"><p>';
-        $description .= 'Text <a href="'. $root .'?addtag=hashtag">#hashtag</a> more text';
+        $description .= 'Text <a href="'. $root .'./add-tag/hashtag">#hashtag</a> more text';
         $description .= '</p></div>';
 
         $link = $this->formatter->format($bookmark);
index 4491b03586ad7fddb1da215df67924d251435b3a..c76bb7b9f7138e81b76057d8ac535c4219fec539 100644 (file)
@@ -3,9 +3,9 @@
 namespace Shaarli\Formatter;
 
 use DateTime;
-use PHPUnit\Framework\TestCase;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
+use Shaarli\TestCase;
 
 /**
  * Class BookmarkRawFormatterTest
@@ -25,7 +25,7 @@ class BookmarkRawFormatterTest extends TestCase
     /**
      * Initialize formatter instance.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         copy('tests/utils/config/configJson.json.php', self::$testConf .'.json.php');
         $this->conf = new ConfigManager(self::$testConf);
index 5adf3ffd8dd2acfe20eb47fcf5ab58ed40f7c9ce..ae476cb5057df02ca9babf3c9fe83daec05f0e85 100644 (file)
@@ -2,8 +2,8 @@
 
 namespace Shaarli\Formatter;
 
-use PHPUnit\Framework\TestCase;
 use Shaarli\Config\ConfigManager;
+use Shaarli\TestCase;
 
 /**
  * Class FormatterFactoryTest
@@ -24,7 +24,7 @@ class FormatterFactoryTest extends TestCase
     /**
      * Initialize FormatterFactory instance
      */
-    public function setUp()
+    protected function setUp(): void
     {
         copy('tests/utils/config/configJson.json.php', self::$testConf .'.json.php');
         $this->conf = new ConfigManager(self::$testConf);
diff --git a/tests/front/ShaarliAdminMiddlewareTest.php b/tests/front/ShaarliAdminMiddlewareTest.php
new file mode 100644 (file)
index 0000000..44025f1
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Container\ShaarliContainer;
+use Shaarli\Security\LoginManager;
+use Shaarli\TestCase;
+use Shaarli\Updater\Updater;
+use Slim\Http\Request;
+use Slim\Http\Response;
+use Slim\Http\Uri;
+
+class ShaarliAdminMiddlewareTest extends TestCase
+{
+    protected const TMP_MOCK_FILE = '.tmp';
+
+    /** @var ShaarliContainer */
+    protected $container;
+
+    /** @var ShaarliMiddleware  */
+    protected $middleware;
+
+    public function setUp(): void
+    {
+        $this->container = $this->createMock(ShaarliContainer::class);
+
+        touch(static::TMP_MOCK_FILE);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::TMP_MOCK_FILE);
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->updater = $this->createMock(Updater::class);
+
+        $this->container->environment = ['REQUEST_URI' => 'http://shaarli/subfolder/path'];
+
+        $this->middleware = new ShaarliAdminMiddleware($this->container);
+    }
+
+    public function tearDown(): void
+    {
+        unlink(static::TMP_MOCK_FILE);
+    }
+
+    /**
+     * Try to access an admin controller while logged out -> redirected to login page.
+     */
+    public function testMiddlewareWhileLoggedOut(): void
+    {
+        $this->container->loginManager->expects(static::once())->method('isLoggedIn')->willReturn(false);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+
+        /** @var Response $result */
+        $result = $this->middleware->__invoke($request, $response, function () {});
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(
+            '/subfolder/login?returnurl=' . urlencode('http://shaarli/subfolder/path'),
+            $result->getHeader('location')[0]
+        );
+    }
+
+    /**
+     * Process controller while logged in.
+     */
+    public function testMiddlewareWhileLoggedIn(): void
+    {
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+        $controller = function (Request $request, Response $response): Response {
+            return $response->withStatus(418); // I'm a tea pot
+        };
+
+        /** @var Response $result */
+        $result = $this->middleware->__invoke($request, $response, $controller);
+
+        static::assertSame(418, $result->getStatusCode());
+    }
+}
index 80974f373f3f7ad3a28b344961e067c21ab5a272..655c5bba635ff2ec8ce3df3ba5a9bafacbf17051 100644 (file)
@@ -4,16 +4,23 @@ declare(strict_types=1);
 
 namespace Shaarli\Front;
 
-use PHPUnit\Framework\TestCase;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Container\ShaarliContainer;
 use Shaarli\Front\Exception\LoginBannedException;
+use Shaarli\Front\Exception\UnauthorizedException;
 use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\TestCase;
+use Shaarli\Updater\Updater;
 use Slim\Http\Request;
 use Slim\Http\Response;
+use Slim\Http\Uri;
 
 class ShaarliMiddlewareTest extends TestCase
 {
+    protected const TMP_MOCK_FILE = '.tmp';
+
     /** @var ShaarliContainer */
     protected $container;
 
@@ -23,12 +30,37 @@ class ShaarliMiddlewareTest extends TestCase
     public function setUp(): void
     {
         $this->container = $this->createMock(ShaarliContainer::class);
+
+        touch(static::TMP_MOCK_FILE);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::TMP_MOCK_FILE);
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+
+        $this->container->environment = ['REQUEST_URI' => 'http://shaarli/subfolder/path'];
+
         $this->middleware = new ShaarliMiddleware($this->container);
     }
 
+    public function tearDown(): void
+    {
+        unlink(static::TMP_MOCK_FILE);
+    }
+
+    /**
+     * Test middleware execution with valid controller call
+     */
     public function testMiddlewareExecution(): void
     {
         $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
         $response = new Response();
         $controller = function (Request $request, Response $response): Response {
             return $response->withStatus(418); // I'm a tea pot
@@ -41,9 +73,20 @@ class ShaarliMiddlewareTest extends TestCase
         static::assertSame(418, $result->getStatusCode());
     }
 
-    public function testMiddlewareExecutionWithException(): void
+    /**
+     * Test middleware execution with controller throwing a known front exception.
+     * The exception should be thrown to be later handled by the error handler.
+     */
+    public function testMiddlewareExecutionWithFrontException(): void
     {
         $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
         $response = new Response();
         $controller = function (): void {
             $exception = new LoginBannedException();
@@ -57,14 +100,122 @@ class ShaarliMiddlewareTest extends TestCase
         });
         $this->container->pageBuilder = $pageBuilder;
 
-        $conf = $this->createMock(ConfigManager::class);
-        $this->container->conf = $conf;
+        $this->expectException(LoginBannedException::class);
+
+        $this->middleware->__invoke($request, $response, $controller);
+    }
+
+    /**
+     * Test middleware execution with controller throwing a not authorized exception
+     * The middle should send a redirection response to the login page.
+     */
+    public function testMiddlewareExecutionWithUnauthorizedException(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+        $controller = function (): void {
+            throw new UnauthorizedException();
+        };
+
+        /** @var Response $result */
+        $result = $this->middleware->__invoke($request, $response, $controller);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(
+            '/subfolder/login?returnurl=' . urlencode('http://shaarli/subfolder/path'),
+            $result->getHeader('location')[0]
+        );
+    }
+
+    /**
+     * Test middleware execution with controller throwing a not authorized exception.
+     * The exception should be thrown to be later handled by the error handler.
+     */
+    public function testMiddlewareExecutionWithServerException(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $dummyException = new class() extends \Exception {};
+
+        $response = new Response();
+        $controller = function () use ($dummyException): void {
+            throw $dummyException;
+        };
+
+        $parameters = [];
+        $this->container->pageBuilder = $this->createMock(PageBuilder::class);
+        $this->container->pageBuilder->method('render')->willReturnCallback(function (string $message): string {
+            return $message;
+        });
+        $this->container->pageBuilder
+            ->method('assign')
+            ->willReturnCallback(function (string $key, string $value) use (&$parameters): void {
+                $parameters[$key] = $value;
+            })
+        ;
+
+        $this->expectException(get_class($dummyException));
+
+        $this->middleware->__invoke($request, $response, $controller);
+    }
+
+    public function testMiddlewareExecutionWithUpdates(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+        $controller = function (Request $request, Response $response): Response {
+            return $response->withStatus(418); // I'm a tea pot
+        };
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key): string {
+            return $key;
+        });
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::TMP_MOCK_FILE);
+
+        $this->container->pageCacheManager = $this->createMock(PageCacheManager::class);
+        $this->container->pageCacheManager->expects(static::once())->method('invalidateCaches');
+
+        $this->container->updater = $this->createMock(Updater::class);
+        $this->container->updater
+            ->expects(static::once())
+            ->method('update')
+            ->willReturn(['update123'])
+        ;
+        $this->container->updater->method('getDoneUpdates')->willReturn($updates = ['update123', 'other']);
+        $this->container->updater
+            ->expects(static::once())
+            ->method('writeUpdates')
+            ->with('resource.updates', $updates)
+        ;
 
         /** @var Response $result */
         $result = $this->middleware->__invoke($request, $response, $controller);
 
         static::assertInstanceOf(Response::class, $result);
-        static::assertSame(401, $result->getStatusCode());
-        static::assertContains('error', (string) $result->getBody());
+        static::assertSame(418, $result->getStatusCode());
     }
 }
diff --git a/tests/front/controller/LoginControllerTest.php b/tests/front/controller/LoginControllerTest.php
deleted file mode 100644 (file)
index 8cf8ece..0000000
+++ /dev/null
@@ -1,178 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use PHPUnit\Framework\TestCase;
-use Shaarli\Bookmark\BookmarkServiceInterface;
-use Shaarli\Config\ConfigManager;
-use Shaarli\Container\ShaarliContainer;
-use Shaarli\Front\Exception\LoginBannedException;
-use Shaarli\Plugin\PluginManager;
-use Shaarli\Render\PageBuilder;
-use Shaarli\Security\LoginManager;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-class LoginControllerTest extends TestCase
-{
-    /** @var ShaarliContainer */
-    protected $container;
-
-    /** @var LoginController */
-    protected $controller;
-
-    public function setUp(): void
-    {
-        $this->container = $this->createMock(ShaarliContainer::class);
-        $this->controller = new LoginController($this->container);
-    }
-
-    public function testValidControllerInvoke(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $request->expects(static::once())->method('getServerParam')->willReturn('> referer');
-        $response = new Response();
-
-        $assignedVariables = [];
-        $this->container->pageBuilder
-            ->method('assign')
-            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
-                $assignedVariables[$key] = $value;
-
-                return $this;
-            })
-        ;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(200, $result->getStatusCode());
-        static::assertSame('loginform', (string) $result->getBody());
-
-        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
-        static::assertSame(true, $assignedVariables['remember_user_default']);
-        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
-    }
-
-    public function testValidControllerInvokeWithUserName(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $request->expects(static::once())->method('getServerParam')->willReturn('> referer');
-        $request->expects(static::exactly(2))->method('getParam')->willReturn('myUser>');
-        $response = new Response();
-
-        $assignedVariables = [];
-        $this->container->pageBuilder
-            ->method('assign')
-            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
-                $assignedVariables[$key] = $value;
-
-                return $this;
-            })
-        ;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(200, $result->getStatusCode());
-        static::assertSame('loginform', (string) $result->getBody());
-
-        static::assertSame('myUser&gt;', $assignedVariables['username']);
-        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
-        static::assertSame(true, $assignedVariables['remember_user_default']);
-        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
-    }
-
-    public function testLoginControllerWhileLoggedIn(): void
-    {
-        $request = $this->createMock(Request::class);
-        $response = new Response();
-
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->expects(static::once())->method('isLoggedIn')->willReturn(true);
-        $this->container->loginManager = $loginManager;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['./'], $result->getHeader('Location'));
-    }
-
-    public function testLoginControllerOpenShaarli(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $response = new Response();
-
-        $conf = $this->createMock(ConfigManager::class);
-        $conf->method('get')->willReturnCallback(function (string $parameter, $default) {
-            if ($parameter === 'security.open_shaarli') {
-                return true;
-            }
-            return $default;
-        });
-        $this->container->conf = $conf;
-
-        $result = $this->controller->index($request, $response);
-
-        static::assertInstanceOf(Response::class, $result);
-        static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['./'], $result->getHeader('Location'));
-    }
-
-    public function testLoginControllerWhileBanned(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $request = $this->createMock(Request::class);
-        $response = new Response();
-
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->method('isLoggedIn')->willReturn(false);
-        $loginManager->method('canLogin')->willReturn(false);
-        $this->container->loginManager = $loginManager;
-
-        $this->expectException(LoginBannedException::class);
-
-        $this->controller->index($request, $response);
-    }
-
-    protected function createValidContainerMockSet(): void
-    {
-        // User logged out
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->method('isLoggedIn')->willReturn(false);
-        $loginManager->method('canLogin')->willReturn(true);
-        $this->container->loginManager = $loginManager;
-
-        // Config
-        $conf = $this->createMock(ConfigManager::class);
-        $conf->method('get')->willReturnCallback(function (string $parameter, $default) {
-            return $default;
-        });
-        $this->container->conf = $conf;
-
-        // PageBuilder
-        $pageBuilder = $this->createMock(PageBuilder::class);
-        $pageBuilder
-            ->method('render')
-            ->willReturnCallback(function (string $template): string {
-                return $template;
-            })
-        ;
-        $this->container->pageBuilder = $pageBuilder;
-
-        $pluginManager = $this->createMock(PluginManager::class);
-        $this->container->pluginManager = $pluginManager;
-        $bookmarkService = $this->createMock(BookmarkServiceInterface::class);
-        $this->container->bookmarkService = $bookmarkService;
-    }
-}
diff --git a/tests/front/controller/ShaarliControllerTest.php b/tests/front/controller/ShaarliControllerTest.php
deleted file mode 100644 (file)
index 6fa3feb..0000000
+++ /dev/null
@@ -1,116 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller;
-
-use PHPUnit\Framework\TestCase;
-use Shaarli\Bookmark\BookmarkFilter;
-use Shaarli\Bookmark\BookmarkServiceInterface;
-use Shaarli\Container\ShaarliContainer;
-use Shaarli\Plugin\PluginManager;
-use Shaarli\Render\PageBuilder;
-use Shaarli\Security\LoginManager;
-
-/**
- * Class ShaarliControllerTest
- *
- * This class is used to test default behavior of ShaarliController abstract class.
- * It uses a dummy non abstract controller.
- */
-class ShaarliControllerTest extends TestCase
-{
-    /** @var ShaarliContainer */
-    protected $container;
-
-    /** @var LoginController */
-    protected $controller;
-
-    /** @var mixed[] List of variable assigned to the template */
-    protected $assignedValues;
-
-    public function setUp(): void
-    {
-        $this->container = $this->createMock(ShaarliContainer::class);
-        $this->controller = new class($this->container) extends ShaarliController
-        {
-            public function assignView(string $key, $value): ShaarliController
-            {
-                return parent::assignView($key, $value);
-            }
-
-            public function render(string $template): string
-            {
-                return parent::render($template);
-            }
-        };
-        $this->assignedValues = [];
-    }
-
-    public function testAssignView(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $self = $this->controller->assignView('variableName', 'variableValue');
-
-        static::assertInstanceOf(ShaarliController::class, $self);
-        static::assertSame('variableValue', $this->assignedValues['variableName']);
-    }
-
-    public function testRender(): void
-    {
-        $this->createValidContainerMockSet();
-
-        $render = $this->controller->render('templateName');
-
-        static::assertSame('templateName', $render);
-
-        static::assertSame(10, $this->assignedValues['linkcount']);
-        static::assertSame(5, $this->assignedValues['privateLinkcount']);
-        static::assertSame(['error'], $this->assignedValues['plugin_errors']);
-
-        static::assertSame('templateName', $this->assignedValues['plugins_includes']['render_includes']['target']);
-        static::assertTrue($this->assignedValues['plugins_includes']['render_includes']['loggedin']);
-        static::assertSame('templateName', $this->assignedValues['plugins_header']['render_header']['target']);
-        static::assertTrue($this->assignedValues['plugins_header']['render_header']['loggedin']);
-        static::assertSame('templateName', $this->assignedValues['plugins_footer']['render_footer']['target']);
-        static::assertTrue($this->assignedValues['plugins_footer']['render_footer']['loggedin']);
-    }
-
-    protected function createValidContainerMockSet(): void
-    {
-        $pageBuilder = $this->createMock(PageBuilder::class);
-        $pageBuilder
-            ->method('assign')
-            ->willReturnCallback(function (string $key, $value): void {
-                $this->assignedValues[$key] = $value;
-            });
-        $pageBuilder
-            ->method('render')
-            ->willReturnCallback(function (string $template): string {
-                return $template;
-            });
-        $this->container->pageBuilder = $pageBuilder;
-
-        $bookmarkService = $this->createMock(BookmarkServiceInterface::class);
-        $bookmarkService
-            ->method('count')
-            ->willReturnCallback(function (string $visibility): int {
-                return $visibility === BookmarkFilter::$PRIVATE ? 5 : 10;
-            });
-        $this->container->bookmarkService = $bookmarkService;
-
-        $pluginManager = $this->createMock(PluginManager::class);
-        $pluginManager
-            ->method('executeHooks')
-            ->willReturnCallback(function (string $hook, array &$data, array $params): array {
-                return $data[$hook] = $params;
-            });
-        $pluginManager->method('getErrors')->willReturn(['error']);
-        $this->container->pluginManager = $pluginManager;
-
-        $loginManager = $this->createMock(LoginManager::class);
-        $loginManager->method('isLoggedIn')->willReturn(true);
-        $this->container->loginManager = $loginManager;
-    }
-}
diff --git a/tests/front/controller/admin/ConfigureControllerTest.php b/tests/front/controller/admin/ConfigureControllerTest.php
new file mode 100644 (file)
index 0000000..d82db0a
--- /dev/null
@@ -0,0 +1,252 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ConfigureControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ConfigureController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ConfigureController($this->container);
+    }
+
+    /**
+     * Test displaying configure page - it should display all config variables
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key) {
+            return $key;
+        });
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('configure', (string) $result->getBody());
+
+        static::assertSame('Configure - general.title', $assignedVariables['pagetitle']);
+        static::assertSame('general.title', $assignedVariables['title']);
+        static::assertSame('resource.theme', $assignedVariables['theme']);
+        static::assertEmpty($assignedVariables['theme_available']);
+        static::assertSame(['default', 'markdown', 'markdownExtra'], $assignedVariables['formatter_available']);
+        static::assertNotEmpty($assignedVariables['continents']);
+        static::assertNotEmpty($assignedVariables['cities']);
+        static::assertSame('general.retrieve_description', $assignedVariables['retrieve_description']);
+        static::assertSame('privacy.default_private_links', $assignedVariables['private_links_default']);
+        static::assertSame('security.session_protection_disabled', $assignedVariables['session_protection_disabled']);
+        static::assertSame('feed.rss_permalinks', $assignedVariables['enable_rss_permalinks']);
+        static::assertSame('updates.check_updates', $assignedVariables['enable_update_check']);
+        static::assertSame('privacy.hide_public_links', $assignedVariables['hide_public_links']);
+        static::assertSame('api.enabled', $assignedVariables['api_enabled']);
+        static::assertSame('api.secret', $assignedVariables['api_secret']);
+        static::assertCount(5, $assignedVariables['languages']);
+        static::assertArrayHasKey('gd_enabled', $assignedVariables);
+        static::assertSame('thumbnails.mode', $assignedVariables['thumbnails_mode']);
+    }
+
+    /**
+     * Test posting a new config - make sure that everything is saved properly, without errors.
+     */
+    public function testSaveNewConfig(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $parameters = [
+            'token' => 'token',
+            'continent' => 'Europe',
+            'city' => 'Moscow',
+            'title' => 'Shaarli',
+            'titleLink' => './',
+            'retrieveDescription' => 'on',
+            'theme' => 'vintage',
+            'disablesessionprotection' => null,
+            'privateLinkByDefault' => true,
+            'enableRssPermalinks' => true,
+            'updateCheck' => false,
+            'hidePublicLinks' => 'on',
+            'enableApi' => 'on',
+            'apiSecret' => 'abcdef',
+            'formatter' => 'markdown',
+            'language' => 'fr',
+            'enableThumbnails' => Thumbnailer::MODE_NONE,
+        ];
+
+        $parametersConfigMapping = [
+            'general.timezone' => $parameters['continent'] . '/' . $parameters['city'],
+            'general.title' => $parameters['title'],
+            'general.header_link' => $parameters['titleLink'],
+            'general.retrieve_description' => !!$parameters['retrieveDescription'],
+            'resource.theme' => $parameters['theme'],
+            'security.session_protection_disabled' => !!$parameters['disablesessionprotection'],
+            'privacy.default_private_links' => !!$parameters['privateLinkByDefault'],
+            'feed.rss_permalinks' => !!$parameters['enableRssPermalinks'],
+            'updates.check_updates' => !!$parameters['updateCheck'],
+            'privacy.hide_public_links' => !!$parameters['hidePublicLinks'],
+            'api.enabled' => !!$parameters['enableApi'],
+            'api.secret' => $parameters['apiSecret'],
+            'formatter' => $parameters['formatter'],
+            'translation.language' => $parameters['language'],
+            'thumbnails.mode' => $parameters['enableThumbnails'],
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')->willReturnCallback(function (string $key) use ($parameters) {
+                if (false === array_key_exists($key, $parameters)) {
+                    static::fail('unknown key: ' . $key);
+                }
+
+                return $parameters[$key];
+            }
+        );
+
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('set')
+            ->willReturnCallback(function (string $key, $value) use ($parametersConfigMapping): void {
+                if (false === array_key_exists($key, $parametersConfigMapping)) {
+                    static::fail('unknown key: ' . $key);
+                }
+
+                static::assertSame($parametersConfigMapping[$key], $value);
+            }
+        );
+
+        $result = $this->controller->save($request, $response);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/configure'], $result->getHeader('Location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Configuration was saved.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a new config - wrong token.
+     */
+    public function testSaveNewConfigWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+
+    /**
+     * Test posting a new config - thumbnail activation.
+     */
+    public function testSaveNewConfigThumbnailsActivation(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')->willReturnCallback(function (string $key) {
+                if ('enableThumbnails' === $key) {
+                    return Thumbnailer::MODE_ALL;
+                }
+
+                return $key;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/configure'], $result->getHeader('Location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertStringContainsString(
+            'You have enabled or changed thumbnails mode',
+            $session[SessionManager::KEY_WARNING_MESSAGES][0]
+        );
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Configuration was saved.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a new config - thumbnail activation.
+     */
+    public function testSaveNewConfigThumbnailsAlreadyActive(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')->willReturnCallback(function (string $key) {
+                if ('enableThumbnails' === $key) {
+                    return Thumbnailer::MODE_ALL;
+                }
+
+                return $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('get')
+            ->willReturnCallback(function (string $key): string {
+                if ('thumbnails.mode' === $key) {
+                    return Thumbnailer::MODE_ALL;
+                }
+
+                return $key;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/configure'], $result->getHeader('Location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Configuration was saved.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+}
diff --git a/tests/front/controller/admin/ExportControllerTest.php b/tests/front/controller/admin/ExportControllerTest.php
new file mode 100644 (file)
index 0000000..0e6f276
--- /dev/null
@@ -0,0 +1,163 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkRawFormatter;
+use Shaarli\Netscape\NetscapeBookmarkUtils;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ExportControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ExportController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ExportController($this->container);
+    }
+
+    /**
+     * Test displaying export page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('export', (string) $result->getBody());
+
+        static::assertSame('Export - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test posting an export request
+     */
+    public function testExportDefault(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $parameters = [
+            'selection' => 'all',
+            'prepend_note_url' => 'on',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($parameters) {
+            return $parameters[$key] ?? null;
+        });
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setUrl('http://link1.tld')->setTitle('Title 1'),
+            (new Bookmark())->setUrl('http://link2.tld')->setTitle('Title 2'),
+        ];
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils
+            ->expects(static::once())
+            ->method('filterAndFormat')
+            ->willReturnCallback(
+                function (
+                    BookmarkFormatter $formatter,
+                    string $selection,
+                    bool $prependNoteUrl,
+                    string $indexUrl
+                ) use ($parameters, $bookmarks): array {
+                    static::assertInstanceOf(BookmarkRawFormatter::class, $formatter);
+                    static::assertSame($parameters['selection'], $selection);
+                    static::assertTrue($prependNoteUrl);
+                    static::assertSame('http://shaarli/subfolder/', $indexUrl);
+
+                    return $bookmarks;
+                }
+            )
+        ;
+
+        $result = $this->controller->export($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('export.bookmarks', (string) $result->getBody());
+        static::assertSame(['text/html; charset=utf-8'], $result->getHeader('content-type'));
+        static::assertRegExp(
+            '/attachment; filename=bookmarks_all_[\d]{8}_[\d]{6}\.html/',
+            $result->getHeader('content-disposition')[0]
+        );
+
+        static::assertNotEmpty($assignedVariables['date']);
+        static::assertSame(PHP_EOL, $assignedVariables['eol']);
+        static::assertSame('all', $assignedVariables['selection']);
+        static::assertSame($bookmarks, $assignedVariables['links']);
+    }
+
+    /**
+     * Test posting an export request - without selection parameter
+     */
+    public function testExportSelectionMissing(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Please select an export mode.'])
+        ;
+
+        $result = $this->controller->export($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/export'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test posting an export request - without selection parameter
+     */
+    public function testExportErrorEncountered(): void
+    {
+        $parameters = [
+            'selection' => 'all',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($parameters) {
+            return $parameters[$key] ?? null;
+        });
+        $response = new Response();
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils
+            ->expects(static::once())
+            ->method('filterAndFormat')
+            ->willThrowException(new \Exception($message = 'error message'));
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, [$message])
+        ;
+
+        $result = $this->controller->export($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/export'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/FrontAdminControllerMockHelper.php b/tests/front/controller/admin/FrontAdminControllerMockHelper.php
new file mode 100644 (file)
index 0000000..2b9f2ef
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Container\ShaarliTestContainer;
+use Shaarli\Front\Controller\Visitor\FrontControllerMockHelper;
+use Shaarli\History;
+
+/**
+ * Trait FrontControllerMockHelper
+ *
+ * Helper trait used to initialize the ShaarliContainer and mock its services for admin controller tests.
+ *
+ * @property ShaarliTestContainer $container
+ */
+trait FrontAdminControllerMockHelper
+{
+    use FrontControllerMockHelper {
+        FrontControllerMockHelper::createContainer as parentCreateContainer;
+    }
+
+    /**
+     * Mock the container instance
+     */
+    protected function createContainer(): void
+    {
+        $this->parentCreateContainer();
+
+        $this->container->history = $this->createMock(History::class);
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager->method('checkToken')->willReturn(true);
+    }
+
+
+    /**
+     * Pass a reference of an array which will be populated by `sessionManager->setSessionParameter`
+     * calls during execution.
+     *
+     * @param mixed $variables Array reference to populate.
+     */
+    protected function assignSessionVars(array &$variables): void
+    {
+        $this->container->sessionManager
+            ->expects(static::atLeastOnce())
+            ->method('setSessionParameter')
+            ->willReturnCallback(function ($key, $value) use (&$variables) {
+                $variables[$key] = $value;
+
+                return $this->container->sessionManager;
+            })
+        ;
+    }
+}
diff --git a/tests/front/controller/admin/ImportControllerTest.php b/tests/front/controller/admin/ImportControllerTest.php
new file mode 100644 (file)
index 0000000..c266caa
--- /dev/null
@@ -0,0 +1,148 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Psr\Http\Message\UploadedFileInterface;
+use Shaarli\Netscape\NetscapeBookmarkUtils;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+use Slim\Http\UploadedFile;
+
+class ImportControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ImportController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ImportController($this->container);
+    }
+
+    /**
+     * Test displaying import page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('import', (string) $result->getBody());
+
+        static::assertSame('Import - Shaarli', $assignedVariables['pagetitle']);
+        static::assertIsInt($assignedVariables['maxfilesize']);
+        static::assertRegExp('/\d+[KM]iB/', $assignedVariables['maxfilesizeHuman']);
+    }
+
+    /**
+     * Test importing a file with default and valid parameters
+     */
+    public function testImportDefault(): void
+    {
+        $parameters = [
+            'abc' => 'def',
+            'other' => 'param',
+        ];
+
+        $requestFile = new UploadedFile('file', 'name', 'type', 123);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParams')->willReturnCallback(function () use ($parameters) {
+            return $parameters;
+        });
+        $request->method('getUploadedFiles')->willReturn(['filetoupload' => $requestFile]);
+        $response = new Response();
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils
+            ->expects(static::once())
+            ->method('import')
+            ->willReturnCallback(
+                function (
+                    array $post,
+                    UploadedFileInterface $file
+                ) use ($parameters, $requestFile): string {
+                    static::assertSame($parameters, $post);
+                    static::assertSame($requestFile, $file);
+
+                    return 'status';
+                }
+            )
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['status'])
+        ;
+
+        $result = $this->controller->import($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test posting an import request - without import file
+     */
+    public function testImportFileMissing(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['No import file provided.'])
+        ;
+
+        $result = $this->controller->import($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test posting an import request - with an empty file
+     */
+    public function testImportEmptyFile(): void
+    {
+        $requestFile = new UploadedFile('file', 'name', 'type', 0);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getUploadedFiles')->willReturn(['filetoupload' => $requestFile]);
+        $response = new Response();
+
+        $this->container->netscapeBookmarkUtils = $this->createMock(NetscapeBookmarkUtils::class);
+        $this->container->netscapeBookmarkUtils->expects(static::never())->method('filterAndFormat');
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->willReturnCallback(function (string $key, array $value): SessionManager {
+                static::assertSame(SessionManager::KEY_ERROR_MESSAGES, $key);
+                static::assertStringStartsWith('The file you are trying to upload is probably bigger', $value[0]);
+
+                return $this->container->sessionManager;
+            })
+        ;
+
+        $result = $this->controller->import($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/import'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/LogoutControllerTest.php b/tests/front/controller/admin/LogoutControllerTest.php
new file mode 100644 (file)
index 0000000..94e5301
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Security\CookieManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class LogoutControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var LogoutController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new LogoutController($this->container);
+    }
+
+    public function testValidControllerInvoke(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->pageCacheManager->expects(static::once())->method('invalidateCaches');
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->expects(static::once())->method('logout');
+
+        $this->container->cookieManager = $this->createMock(CookieManager::class);
+        $this->container->cookieManager
+            ->expects(static::once())
+            ->method('setCookieParameter')
+            ->with(CookieManager::STAY_SIGNED_IN, 'false', 0, '/subfolder/')
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ManageTagControllerTest.php b/tests/front/controller/admin/ManageTagControllerTest.php
new file mode 100644 (file)
index 0000000..8a0ff7a
--- /dev/null
@@ -0,0 +1,272 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ManageTagControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ManageTagController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ManageTagController($this->container);
+    }
+
+    /**
+     * Test displaying manage tag page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('fromtag')->willReturn('fromtag');
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('changetag', (string) $result->getBody());
+
+        static::assertSame('fromtag', $assignedVariables['fromtag']);
+        static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test posting a tag update - rename tag - valid info provided.
+     */
+    public function testSaveRenameTagValid(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'renametag' => 'rename',
+            'fromtag' => 'old-tag',
+            'totag' => 'new-tag',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark1 = $this->createMock(Bookmark::class);
+        $bookmark2 = $this->createMock(Bookmark::class);
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(['searchtags' => 'old-tag'], BookmarkFilter::$ALL, true)
+            ->willReturnCallback(function () use ($bookmark1, $bookmark2): array {
+                $bookmark1->expects(static::once())->method('renameTag')->with('old-tag', 'new-tag');
+                $bookmark2->expects(static::once())->method('renameTag')->with('old-tag', 'new-tag');
+
+                return [$bookmark1, $bookmark2];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive([$bookmark1, false], [$bookmark2, false])
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/?searchtags=new-tag'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['The tag was renamed in 2 bookmarks.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - delete tag - valid info provided.
+     */
+    public function testSaveDeleteTagValid(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'deletetag' => 'delete',
+            'fromtag' => 'old-tag',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark1 = $this->createMock(Bookmark::class);
+        $bookmark2 = $this->createMock(Bookmark::class);
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(['searchtags' => 'old-tag'], BookmarkFilter::$ALL, true)
+            ->willReturnCallback(function () use ($bookmark1, $bookmark2): array {
+                $bookmark1->expects(static::once())->method('deleteTag')->with('old-tag');
+                $bookmark2->expects(static::once())->method('deleteTag')->with('old-tag');
+
+                return [$bookmark1, $bookmark2];
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive([$bookmark1, false], [$bookmark2, false])
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['The tag was removed from 2 bookmarks.'], $session[SessionManager::KEY_SUCCESS_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - wrong token.
+     */
+    public function testSaveWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+
+    /**
+     * Test posting a tag update - rename tag - missing "FROM" tag.
+     */
+    public function testSaveRenameTagMissingFrom(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'renametag' => 'rename',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - delete tag - missing "FROM" tag.
+     */
+    public function testSaveDeleteTagMissingFrom(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'deletetag' => 'delete',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
+    }
+
+    /**
+     * Test posting a tag update - rename tag - missing "TO" tag.
+     */
+    public function testSaveRenameTagMissingTo(): void
+    {
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $requestParameters = [
+            'renametag' => 'rename',
+            'fromtag' => 'old-tag'
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($requestParameters): ?string {
+                return $requestParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
+    }
+}
diff --git a/tests/front/controller/admin/PasswordControllerTest.php b/tests/front/controller/admin/PasswordControllerTest.php
new file mode 100644 (file)
index 0000000..58f47b4
--- /dev/null
@@ -0,0 +1,203 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\OpenShaarliPasswordException;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PasswordControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var PasswordController */
+    protected $controller;
+
+    /** @var mixed[] Variables assigned to the template */
+    protected $assignedVariables = [];
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+        $this->assignTemplateVars($this->assignedVariables);
+
+        $this->controller = new PasswordController($this->container);
+    }
+
+    /**
+     * Test displaying the change password page.
+     */
+    public function testGetPage(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password with valid parameters
+     */
+    public function testPostNewPasswordDefault(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key): string {
+             if ('oldpassword' === $key) {
+                 return 'old';
+             }
+             if ('setpassword' === $key) {
+                 return 'new';
+             }
+
+             return $key;
+        });
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ('credentials.hash' === $key) {
+                return sha1('old' . 'credentials.login' . 'credentials.salt');
+            }
+
+            return strpos($key, 'credentials') !== false ? $key : $default;
+        });
+        $this->container->conf->expects(static::once())->method('write')->with(true);
+
+        $this->container->conf
+            ->method('set')
+            ->willReturnCallback(function (string $key, string $value) {
+                if ('credentials.hash' === $key) {
+                    static::assertSame(sha1('new' . 'credentials.login' . 'credentials.salt'), $value);
+                }
+            })
+        ;
+
+        $result = $this->controller->change($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testPostNewPasswordWrongOldPassword(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key): string {
+            if ('oldpassword' === $key) {
+                return 'wrong';
+            }
+            if ('setpassword' === $key) {
+                return 'new';
+            }
+
+            return $key;
+        });
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ('credentials.hash' === $key) {
+                return sha1('old' . 'credentials.login' . 'credentials.salt');
+            }
+
+            return strpos($key, 'credentials') !== false ? $key : $default;
+        });
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['The old password is not correct.'])
+        ;
+
+        $result = $this->controller->change($request, $response);
+
+        static::assertSame(400, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testPostNewPasswordWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->change($request, $response);
+    }
+
+    /**
+     * Change the password with an empty new password
+     */
+    public function testPostNewEmptyPassword(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['You must provide the current and new password to change it.'])
+        ;
+
+        $this->container->conf->expects(static::never())->method('set');
+        $this->container->conf->expects(static::never())->method('write');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key): string {
+            if ('oldpassword' === $key) {
+                return 'old';
+            }
+            if ('setpassword' === $key) {
+                return '';
+            }
+
+            return $key;
+        });
+        $response = new Response();
+
+        $result = $this->controller->change($request, $response);
+
+        static::assertSame(400, $result->getStatusCode());
+        static::assertSame('changepassword', (string) $result->getBody());
+        static::assertSame('Change password - Shaarli', $this->assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Change the password on an open shaarli
+     */
+    public function testPostNewPasswordOnOpenShaarli(): void
+    {
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->with('security.open_shaarli')->willReturn(true);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(OpenShaarliPasswordException::class);
+
+        $this->controller->change($request, $response);
+    }
+}
diff --git a/tests/front/controller/admin/PluginsControllerTest.php b/tests/front/controller/admin/PluginsControllerTest.php
new file mode 100644 (file)
index 0000000..974d614
--- /dev/null
@@ -0,0 +1,205 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Plugin\PluginManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PluginsControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    const PLUGIN_NAMES = ['plugin1', 'plugin2', 'plugin3', 'plugin4'];
+
+    /** @var PluginsController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new PluginsController($this->container);
+
+        mkdir($path = __DIR__ . '/folder');
+        PluginManager::$PLUGINS_PATH = $path;
+        array_map(function (string $plugin) use ($path) { touch($path . '/' . $plugin); }, static::PLUGIN_NAMES);
+    }
+
+    public function tearDown(): void
+    {
+        $path = __DIR__ . '/folder';
+        array_map(function (string $plugin) use ($path) { unlink($path . '/' . $plugin); }, static::PLUGIN_NAMES);
+        rmdir($path);
+    }
+
+    /**
+     * Test displaying plugins admin page
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $data = [
+            'plugin1' => ['order' => 2, 'other' => 'field'],
+            'plugin2' => ['order' => 1],
+            'plugin3' => ['order' => false, 'abc' => 'def'],
+            'plugin4' => [],
+        ];
+
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('getPluginsMeta')
+            ->willReturn($data);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('pluginsadmin', (string) $result->getBody());
+
+        static::assertSame('Plugin Administration - Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame(
+            ['plugin2' => $data['plugin2'], 'plugin1' => $data['plugin1']],
+            $assignedVariables['enabledPlugins']
+        );
+        static::assertSame(
+            ['plugin3' => $data['plugin3'], 'plugin4' => $data['plugin4']],
+            $assignedVariables['disabledPlugins']
+        );
+    }
+
+    /**
+     * Test save plugins admin page
+     */
+    public function testSaveEnabledPlugins(): void
+    {
+        $parameters = [
+            'plugin1' => 'on',
+            'order_plugin1' => '2',
+            'plugin2' => 'on',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParams')
+            ->willReturnCallback(function () use ($parameters): array {
+                return $parameters;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_plugin_parameters', $parameters)
+        ;
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('set')
+            ->with('general.enabled_plugins', ['plugin1', 'plugin2'])
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/plugins'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test save plugin parameters
+     */
+    public function testSavePluginParameters(): void
+    {
+        $parameters = [
+            'parameters_form' => true,
+            'parameter1' => 'blip',
+            'parameter2' => 'blop',
+            'token' => 'this parameter should not be saved'
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParams')
+            ->willReturnCallback(function () use ($parameters): array {
+                return $parameters;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_plugin_parameters', $parameters)
+        ;
+        $this->container->conf
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive(['plugins.parameter1', 'blip'], ['plugins.parameter2', 'blop'])
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/plugins'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test save plugin parameters - error encountered
+     */
+    public function testSaveWithError(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('write')
+            ->willThrowException(new \Exception($message = 'error message'))
+        ;
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(true);
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(
+                SessionManager::KEY_ERROR_MESSAGES,
+                ['Error while saving plugin configuration: ' . PHP_EOL . $message]
+            )
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/plugins'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test save plugin parameters - wrong token
+     */
+    public function testSaveWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+}
diff --git a/tests/front/controller/admin/ServerControllerTest.php b/tests/front/controller/admin/ServerControllerTest.php
new file mode 100644 (file)
index 0000000..355cce7
--- /dev/null
@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Test Server administration controller.
+ */
+class ServerControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ServerController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ServerController($this->container);
+
+        // initialize dummy cache
+        @mkdir('sandbox/');
+        foreach (['pagecache', 'tmp', 'cache'] as $folder) {
+            @mkdir('sandbox/' . $folder);
+            @touch('sandbox/' . $folder . '/.htaccess');
+            @touch('sandbox/' . $folder . '/1');
+            @touch('sandbox/' . $folder . '/2');
+        }
+    }
+
+    public function tearDown(): void
+    {
+        foreach (['pagecache', 'tmp', 'cache'] as $folder) {
+            @unlink('sandbox/' . $folder . '/.htaccess');
+            @unlink('sandbox/' . $folder . '/1');
+            @unlink('sandbox/' . $folder . '/2');
+            @rmdir('sandbox/' . $folder);
+        }
+    }
+
+    /**
+     * Test default display of server administration page.
+     */
+    public function testIndex(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+       // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('server', (string) $result->getBody());
+
+        static::assertSame(PHP_VERSION, $assignedVariables['php_version']);
+        static::assertArrayHasKey('php_has_reached_eol', $assignedVariables);
+        static::assertArrayHasKey('php_eol', $assignedVariables);
+        static::assertArrayHasKey('php_extensions', $assignedVariables);
+        static::assertArrayHasKey('permissions', $assignedVariables);
+        static::assertEmpty($assignedVariables['permissions']);
+
+        static::assertRegExp(
+            '#https://github\.com/shaarli/Shaarli/releases/tag/v\d+\.\d+\.\d+#',
+            $assignedVariables['release_url']
+        );
+        static::assertRegExp('#v\d+\.\d+\.\d+#', $assignedVariables['latest_version']);
+        static::assertRegExp('#(v\d+\.\d+\.\d+|dev)#', $assignedVariables['current_version']);
+        static::assertArrayHasKey('index_url', $assignedVariables);
+        static::assertArrayHasKey('client_ip', $assignedVariables);
+        static::assertArrayHasKey('trusted_proxies', $assignedVariables);
+
+        static::assertSame('Server administration - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test clearing the main cache
+     */
+    public function testClearMainCache(): void
+    {
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'resource.page_cache') {
+                return 'sandbox/pagecache';
+            } elseif ($key === 'resource.raintpl_tmp') {
+                return 'sandbox/tmp';
+            } elseif ($key === 'resource.thumbnails_cache') {
+                return 'sandbox/cache';
+            } else {
+                return $default;
+            }
+        });
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['Shaarli\'s cache folder has been cleared!'])
+        ;
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('type')->willReturn('main');
+        $response = new Response();
+
+        $result = $this->controller->clearCache($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location'));
+
+        static::assertFileNotExists('sandbox/pagecache/1');
+        static::assertFileNotExists('sandbox/pagecache/2');
+        static::assertFileNotExists('sandbox/tmp/1');
+        static::assertFileNotExists('sandbox/tmp/2');
+
+        static::assertFileExists('sandbox/pagecache/.htaccess');
+        static::assertFileExists('sandbox/tmp/.htaccess');
+        static::assertFileExists('sandbox/cache');
+        static::assertFileExists('sandbox/cache/.htaccess');
+        static::assertFileExists('sandbox/cache/1');
+        static::assertFileExists('sandbox/cache/2');
+    }
+
+    /**
+     * Test clearing thumbnails cache
+     */
+    public function testClearThumbnailsCache(): void
+    {
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'resource.page_cache') {
+                return 'sandbox/pagecache';
+            } elseif ($key === 'resource.raintpl_tmp') {
+                return 'sandbox/tmp';
+            } elseif ($key === 'resource.thumbnails_cache') {
+                return 'sandbox/cache';
+            } else {
+                return $default;
+            }
+        });
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->willReturnCallback(function (string $key, array $value): SessionManager {
+                static::assertSame(SessionManager::KEY_WARNING_MESSAGES, $key);
+                static::assertCount(1, $value);
+                static::assertStringStartsWith('Thumbnails cache has been cleared.', $value[0]);
+
+                return $this->container->sessionManager;
+            });
+        ;
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('type')->willReturn('thumbnails');
+        $response = new Response();
+
+        $result = $this->controller->clearCache($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location'));
+
+        static::assertFileNotExists('sandbox/cache/1');
+        static::assertFileNotExists('sandbox/cache/2');
+
+        static::assertFileExists('sandbox/cache/.htaccess');
+        static::assertFileExists('sandbox/pagecache');
+        static::assertFileExists('sandbox/pagecache/.htaccess');
+        static::assertFileExists('sandbox/pagecache/1');
+        static::assertFileExists('sandbox/pagecache/2');
+        static::assertFileExists('sandbox/tmp');
+        static::assertFileExists('sandbox/tmp/.htaccess');
+        static::assertFileExists('sandbox/tmp/1');
+        static::assertFileExists('sandbox/tmp/2');
+    }
+}
diff --git a/tests/front/controller/admin/SessionFilterControllerTest.php b/tests/front/controller/admin/SessionFilterControllerTest.php
new file mode 100644 (file)
index 0000000..712a625
--- /dev/null
@@ -0,0 +1,177 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Security\LoginManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class SessionFilterControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var SessionFilterController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new SessionFilterController($this->container);
+    }
+
+    /**
+     * Visibility - Default call for private filter while logged in without current value
+     */
+    public function testVisibility(): void
+    {
+        $arg = ['visibility' => 'private'];
+
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller/?searchtag=abc';
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY, 'private')
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - Toggle off private visibility
+     */
+    public function testVisibilityToggleOff(): void
+    {
+        $arg = ['visibility' => 'private'];
+
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller/?searchtag=abc';
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+            ->willReturn('private')
+        ;
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('setSessionParameter')
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('deleteSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - Change private to public
+     */
+    public function testVisibilitySwitch(): void
+    {
+        $arg = ['visibility' => 'private'];
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+            ->willReturn('public')
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY, 'private')
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - With invalid value - should remove any visibility setting
+     */
+    public function testVisibilityInvalidValue(): void
+    {
+        $arg = ['visibility' => 'test'];
+
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller/?searchtag=abc';
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('setSessionParameter')
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('deleteSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Visibility - Try to change visibility while logged out
+     */
+    public function testVisibilityLoggedOut(): void
+    {
+        $arg = ['visibility' => 'test'];
+
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller/?searchtag=abc';
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(false);
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('setSessionParameter')
+        ;
+        $this->container->sessionManager
+            ->expects(static::never())
+            ->method('deleteSessionParameter')
+            ->with(SessionManager::KEY_VISIBILITY)
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->visibility($request, $response, $arg);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaareAddControllerTest.php b/tests/front/controller/admin/ShaareAddControllerTest.php
new file mode 100644 (file)
index 0000000..a27ebe6
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Formatter\BookmarkMarkdownFormatter;
+use Shaarli\Http\HttpAccess;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ShaareAddControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaareAddController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaareAddController($this->container);
+    }
+
+    /**
+     * Test displaying add link page
+     */
+    public function testAddShaare(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $expectedTags = [
+            'tag1' => 32,
+            'tag2' => 24,
+            'tag3' => 1,
+        ];
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($expectedTags)
+        ;
+        $expectedTags = array_merge($expectedTags, [BookmarkMarkdownFormatter::NO_MD_TAG => 1]);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            return $key === 'formatter' ? 'markdown' : $default;
+        });
+
+        $result = $this->controller->addShaare($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('addlink', (string) $result->getBody());
+
+        static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']);
+        static::assertFalse($assignedVariables['default_private_links']);
+        static::assertTrue($assignedVariables['async_metadata']);
+        static::assertSame($expectedTags, $assignedVariables['tags']);
+    }
+
+    /**
+     * Test displaying add link page
+     */
+    public function testAddShaareWithoutMd(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $expectedTags = [
+            'tag1' => 32,
+            'tag2' => 24,
+            'tag3' => 1,
+        ];
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($expectedTags)
+        ;
+
+        $result = $this->controller->addShaare($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('addlink', (string) $result->getBody());
+
+        static::assertSame($expectedTags, $assignedVariables['tags']);
+    }
+}
diff --git a/tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php
new file mode 100644 (file)
index 0000000..28b1c02
--- /dev/null
@@ -0,0 +1,418 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkRawFormatter;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ChangeVisibilityBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaareManageController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaareManageController($this->container);
+    }
+
+    /**
+     * Change bookmark visibility - Set private - Single public bookmark with valid parameters
+     */
+    public function testSetSingleBookmarkPrivate(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(false);
+
+        static::assertFalse($bookmark->isPrivate());
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmark): BookmarkFormatter {
+                return new BookmarkRawFormatter($this->container->conf, true);
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Set public - Single private bookmark with valid parameters
+     */
+    public function testSetSingleBookmarkPublic(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'public'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(true);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertFalse($bookmark->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Set private on single already private bookmark
+     */
+    public function testSetSinglePrivateBookmarkPrivate(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(true);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertTrue($bookmark->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Set multiple bookmarks private
+     */
+    public function testSetMultipleBookmarksPrivate(): void
+    {
+        $parameters = ['id' => '123 456 789', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(false),
+            (new Bookmark())->setId(456)->setUrl('http://domain.tld')->setTitle('Title 456')->setPrivate(true),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789')->setPrivate(false),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnOnConsecutiveCalls(...$bookmarks)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('set')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(3))
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertTrue($bookmarks[0]->isPrivate());
+        static::assertTrue($bookmarks[1]->isPrivate());
+        static::assertTrue($bookmarks[2]->isPrivate());
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Single bookmark not found.
+     */
+    public function testChangeVisibilitySingleBookmarkNotFound(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+        $this->container->bookmarkService->expects(static::never())->method('set');
+        $this->container->bookmarkService->expects(static::never())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturn(new BookmarkRawFormatter($this->container->conf, true))
+        ;
+
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::never())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Multiple bookmarks with one not found.
+     */
+    public function testChangeVisibilityMultipleBookmarksOneNotFound(): void
+    {
+        $parameters = ['id' => '123 456 789', 'newVisibility' => 'public'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')->setPrivate(true),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789')->setPrivate(false),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnCallback(function (int $id) use ($bookmarks): Bookmark {
+                if ($id === 123) {
+                    return $bookmarks[0];
+                }
+                if ($id === 789) {
+                    return $bookmarks[1];
+                }
+                throw new BookmarkNotFoundException();
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(2))
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 456 could not be found.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Invalid ID
+     */
+    public function testChangeVisibilityInvalidId(): void
+    {
+        $parameters = ['id' => 'nope not an ID', 'newVisibility' => 'private'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - Empty ID
+     */
+    public function testChangeVisibilityEmptyId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Change bookmark visibility - with invalid visibility
+     */
+    public function testChangeVisibilityWithInvalidVisibility(): void
+    {
+        $parameters = ['id' => '123', 'newVisibility' => 'invalid'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid visibility provided.'])
+        ;
+
+        $result = $this->controller->changeVisibility($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php
new file mode 100644 (file)
index 0000000..770a16d
--- /dev/null
@@ -0,0 +1,380 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DeleteBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaareManageController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaareManageController($this->container);
+    }
+
+    /**
+     * Delete bookmark - Single bookmark with valid parameters
+     */
+    public function testDeleteSingleBookmark(): void
+    {
+        $parameters = ['id' => '123'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmark = (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123');
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('remove')->with($bookmark, false);
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmark): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+                $formatter
+                    ->expects(static::once())
+                    ->method('format')
+                    ->with($bookmark)
+                    ->willReturn(['formatted' => $bookmark])
+                ;
+
+                return $formatter;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('delete_link', ['formatted' => $bookmark])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Multiple bookmarks with valid parameters
+     */
+    public function testDeleteMultipleBookmarks(): void
+    {
+        $parameters = ['id' => '123 456 789'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123'),
+            (new Bookmark())->setId(456)->setUrl('http://domain.tld')->setTitle('Title 456'),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789'),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnOnConsecutiveCalls(...$bookmarks)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('remove')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmarks): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+
+                $formatter
+                    ->expects(static::exactly(3))
+                    ->method('format')
+                    ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                        return [$bookmark];
+                    }, $bookmarks))
+                    ->willReturnOnConsecutiveCalls(...array_map(function (Bookmark $bookmark): array {
+                        return ['formatted' => $bookmark];
+                    }, $bookmarks))
+                ;
+
+                return $formatter;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(3))
+            ->method('executeHooks')
+            ->with('delete_link')
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Single bookmark not found in the data store
+     */
+    public function testDeleteSingleBookmarkNotFound(): void
+    {
+        $parameters = ['id' => '123'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+        $this->container->bookmarkService->expects(static::never())->method('remove');
+        $this->container->bookmarkService->expects(static::never())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function (): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+
+                $formatter->expects(static::never())->method('format');
+
+                return $formatter;
+            })
+        ;
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::never())
+            ->method('executeHooks')
+            ->with('delete_link')
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Multiple bookmarks with one not found in the data store
+     */
+    public function testDeleteMultipleBookmarksOneNotFound(): void
+    {
+        $parameters = ['id' => '123 456 789'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $bookmarks = [
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123'),
+            (new Bookmark())->setId(789)->setUrl('http://domain.tld')->setTitle('Title 789'),
+        ];
+
+        $this->container->bookmarkService
+            ->expects(static::exactly(3))
+            ->method('get')
+            ->withConsecutive([123], [456], [789])
+            ->willReturnCallback(function (int $id) use ($bookmarks): Bookmark {
+                if ($id === 123) {
+                    return $bookmarks[0];
+                }
+                if ($id === 789) {
+                    return $bookmarks[1];
+                }
+                throw new BookmarkNotFoundException();
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('remove')
+            ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                return [$bookmark, false];
+            }, $bookmarks))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->with('raw')
+            ->willReturnCallback(function () use ($bookmarks): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+
+                $formatter
+                    ->expects(static::exactly(2))
+                    ->method('format')
+                    ->withConsecutive(...array_map(function (Bookmark $bookmark): array {
+                        return [$bookmark];
+                    }, $bookmarks))
+                    ->willReturnOnConsecutiveCalls(...array_map(function (Bookmark $bookmark): array {
+                        return ['formatted' => $bookmark];
+                    }, $bookmarks))
+                ;
+
+                return $formatter;
+            })
+        ;
+
+        // Make sure that PluginManager hook is not triggered
+        $this->container->pluginManager
+            ->expects(static::exactly(2))
+            ->method('executeHooks')
+            ->with('delete_link')
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 456 could not be found.'])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Invalid ID
+     */
+    public function testDeleteInvalidId(): void
+    {
+        $parameters = ['id' => 'nope not an ID'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - Empty ID
+     */
+    public function testDeleteEmptyId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Invalid bookmark ID provided.'])
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Delete bookmark - from bookmarklet
+     */
+    public function testDeleteBookmarkFromBookmarklet(): void
+    {
+        $parameters = [
+            'id' => '123',
+            'source' => 'bookmarklet',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->bookmarkService->method('get')->with('123')->willReturn(
+            (new Bookmark())->setId(123)->setUrl('http://domain.tld')->setTitle('Title 123')
+        );
+
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->expects(static::once())
+            ->method('getFormatter')
+            ->willReturnCallback(function (): BookmarkFormatter {
+                $formatter = $this->createMock(BookmarkFormatter::class);
+                $formatter->method('format')->willReturn(['formatted']);
+
+                return $formatter;
+            })
+        ;
+
+        $result = $this->controller->deleteBookmark($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('<script>self.close();</script>', (string) $result->getBody('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php b/tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php
new file mode 100644 (file)
index 0000000..b89206c
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PinBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaareManageController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaareManageController($this->container);
+    }
+
+    /**
+     * Test pin bookmark - with valid input
+     *
+     * @dataProvider initialStickyValuesProvider()
+     */
+    public function testPinBookmarkIsStickyNull(?bool $sticky, bool $expectedValue): void
+    {
+        $id = 123;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setSticky($sticky)
+        ;
+
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(123)->willReturn($bookmark);
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, true);
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::once())
+            ->method('executeHooks')
+            ->with('save_link')
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+
+        static::assertSame($expectedValue, $bookmark->isSticky());
+    }
+
+    public function initialStickyValuesProvider(): array
+    {
+        // [initialStickyState, isStickyAfterPin]
+        return [[null, true], [false, true], [true, false]];
+    }
+
+    /**
+     * Test pin bookmark - invalid bookmark ID
+     */
+    public function testDisplayEditFormInvalidId(): void
+    {
+        $id = 'invalid';
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier invalid could not be found.'])
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, ['id' => $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test pin bookmark - Bookmark ID not provided
+     */
+    public function testDisplayEditFormIdNotProvided(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier  could not be found.'])
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, []);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test pin bookmark - bookmark not found
+     */
+    public function testDisplayEditFormBookmarkNotFound(): void
+    {
+        $id = 123;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 123 could not be found.'])
+        ;
+
+        $result = $this->controller->pinBookmark($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php b/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php
new file mode 100644 (file)
index 0000000..ae61dfb
--- /dev/null
@@ -0,0 +1,139 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Test GET /admin/shaare/private/{hash}
+ */
+class SharePrivateTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaareManageController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaareManageController($this->container);
+    }
+
+    /**
+     * Test shaare private with a private bookmark which does not have a key yet.
+     */
+    public function testSharePrivateWithNewPrivateBookmark(): void
+    {
+        $hash = 'abcdcef';
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setPrivate(true)
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->with($bookmark, true)
+            ->willReturnCallback(function (Bookmark $bookmark): Bookmark {
+                static::assertSame(32, strlen($bookmark->getAdditionalContentEntry('private_key')));
+
+                return $bookmark;
+            })
+        ;
+
+        $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertRegExp('#/subfolder/shaare/' . $hash . '\?key=\w{32}#', $result->getHeaderLine('Location'));
+    }
+
+    /**
+     * Test shaare private with a private bookmark which does already have a key.
+     */
+    public function testSharePrivateWithExistingPrivateBookmark(): void
+    {
+        $hash = 'abcdcef';
+        $existingKey = 'this is a private key';
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setPrivate(true)
+            ->addAdditionalContentEntry('private_key', $existingKey)
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::never())
+            ->method('set')
+        ;
+
+        $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/shaare/' . $hash . '?key=' . $existingKey, $result->getHeaderLine('Location'));
+    }
+
+    /**
+     * Test shaare private with a public bookmark.
+     */
+    public function testSharePrivateWithPublicBookmark(): void
+    {
+        $hash = 'abcdcef';
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setPrivate(false)
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::never())
+            ->method('set')
+        ;
+
+        $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/shaare/' . $hash, $result->getHeaderLine('Location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php
new file mode 100644 (file)
index 0000000..ce8e112
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
+
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DisplayCreateBatchFormTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaarePublishController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
+        $this->controller = new ShaarePublishController($this->container);
+    }
+
+    /**
+     * TODO
+     */
+    public function testDisplayCreateFormBatch(): void
+    {
+        $urls = [
+            'https://domain1.tld/url1',
+            'https://domain2.tld/url2',
+            ' ',
+            'https://domain3.tld/url3',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($urls): ?string {
+            return $key === 'urls' ? implode(PHP_EOL, $urls) : null;
+        });
+        $response = new Response();
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->displayCreateBatchForms($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink.batch', (string) $result->getBody());
+
+        static::assertTrue($assignedVariables['batch_mode']);
+        static::assertCount(3, $assignedVariables['links']);
+        static::assertSame($urls[0], $assignedVariables['links'][0]['link']['url']);
+        static::assertSame($urls[1], $assignedVariables['links'][1]['link']['url']);
+        static::assertSame($urls[3], $assignedVariables['links'][2]['link']['url']);
+    }
+}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
new file mode 100644 (file)
index 0000000..f20b1de
--- /dev/null
@@ -0,0 +1,367 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DisplayCreateFormTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaarePublishController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
+        $this->controller = new ShaarePublishController($this->container);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * Ensure that every step of the standard workflow works properly.
+     */
+    public function testDisplayCreateFormWithUrlAndWithMetadataRetrieval(): void
+    {
+        $this->container->environment = [
+            'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
+        ];
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
+        $expectedUrl = str_replace('&utm_ad=pay', '', $url);
+        $remoteTitle = 'Remote Title';
+        $remoteDesc = 'Sometimes the meta description is relevant.';
+        $remoteTags = 'abc def';
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
+            return $key === 'post' ? $url : null;
+        });
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $param, $default) {
+            if ($param === 'general.enable_async_metadata') {
+                return false;
+            }
+
+            return $default;
+        });
+
+        $this->container->metadataRetriever->expects(static::once())->method('retrieve')->willReturn([
+            'title' => $remoteTitle,
+            'description' => $remoteDesc,
+            'tags' => $remoteTags,
+        ]);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_editlink'], ['render_includes'])
+            ->willReturnCallback(function (string $hook, array $data) use ($remoteTitle, $remoteDesc): array {
+                if ('render_editlink' === $hook) {
+                    static::assertSame($remoteTitle, $data['link']['title']);
+                    static::assertSame($remoteDesc, $data['link']['description']);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame($remoteTitle, $assignedVariables['link']['title']);
+        static::assertSame($remoteDesc, $assignedVariables['link']['description']);
+        static::assertSame($remoteTags, $assignedVariables['link']['tags']);
+        static::assertFalse($assignedVariables['link']['private']);
+
+        static::assertTrue($assignedVariables['link_is_new']);
+        static::assertSame($referer, $assignedVariables['http_referer']);
+        static::assertSame($tags, $assignedVariables['tags']);
+        static::assertArrayHasKey('source', $assignedVariables);
+        static::assertArrayHasKey('default_private_links', $assignedVariables);
+        static::assertArrayHasKey('async_metadata', $assignedVariables);
+        static::assertArrayHasKey('retrieve_description', $assignedVariables);
+    }
+
+    /**
+     * Test displaying bookmark create form without any external metadata retrieval attempt
+     */
+    public function testDisplayCreateFormWithUrlAndWithoutMetadata(): void
+    {
+        $this->container->environment = [
+            'HTTP_REFERER' => $referer = 'http://shaarli/subfolder/controller/?searchtag=abc'
+        ];
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
+        $expectedUrl = str_replace('&utm_ad=pay', '', $url);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($url): ?string {
+            return $key === 'post' ? $url : null;
+        });
+        $response = new Response();
+
+        $this->container->metadataRetriever->expects(static::never())->method('retrieve');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($tags = ['tag1' => 2, 'tag2' => 1])
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_editlink'], ['render_includes'])
+            ->willReturnCallback(function (string $hook, array $data): array {
+                if ('render_editlink' === $hook) {
+                    static::assertSame('', $data['link']['title']);
+                    static::assertSame('', $data['link']['description']);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame('', $assignedVariables['link']['title']);
+        static::assertSame('', $assignedVariables['link']['description']);
+        static::assertSame('', $assignedVariables['link']['tags']);
+        static::assertFalse($assignedVariables['link']['private']);
+
+        static::assertTrue($assignedVariables['link_is_new']);
+        static::assertSame($referer, $assignedVariables['http_referer']);
+        static::assertSame($tags, $assignedVariables['tags']);
+        static::assertArrayHasKey('source', $assignedVariables);
+        static::assertArrayHasKey('default_private_links', $assignedVariables);
+        static::assertArrayHasKey('async_metadata', $assignedVariables);
+        static::assertArrayHasKey('retrieve_description', $assignedVariables);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * Ensure all available query parameters are handled properly.
+     */
+    public function testDisplayCreateFormWithFullParameters(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $parameters = [
+            'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash',
+            'title' => 'Provided Title',
+            'description' => 'Provided description.',
+            'tags' => 'abc def',
+            'private' => '1',
+            'source' => 'apps',
+        ];
+        $expectedUrl = str_replace('&utm_ad=pay', '', $parameters['post']);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            });
+        $response = new Response();
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Shaare - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame($parameters['title'], $assignedVariables['link']['title']);
+        static::assertSame($parameters['description'], $assignedVariables['link']['description']);
+        static::assertSame($parameters['tags'], $assignedVariables['link']['tags']);
+        static::assertTrue($assignedVariables['link']['private']);
+        static::assertTrue($assignedVariables['link_is_new']);
+        static::assertSame($parameters['source'], $assignedVariables['source']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * Without any parameter.
+     */
+    public function testDisplayCreateFormEmpty(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+        static::assertSame('', $assignedVariables['link']['url']);
+        static::assertSame('Note: ', $assignedVariables['link']['title']);
+        static::assertSame('', $assignedVariables['link']['description']);
+        static::assertSame('', $assignedVariables['link']['tags']);
+        static::assertFalse($assignedVariables['link']['private']);
+        static::assertTrue($assignedVariables['link_is_new']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * URL not using HTTP protocol: do not try to retrieve the title
+     */
+    public function testDisplayCreateFormNotHttp(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'magnet://kubuntu.torrent';
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($url): ?string {
+                return $key === 'post' ? $url : null;
+            });
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+        static::assertSame($url, $assignedVariables['link']['url']);
+        static::assertTrue($assignedVariables['link_is_new']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * When markdown formatter is enabled, the no markdown tag should be added to existing tags.
+     */
+    public function testDisplayCreateFormWithMarkdownEnabled(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->expects(static::atLeastOnce())
+            ->method('get')->willReturnCallback(function (string $key): ?string {
+                if ($key === 'formatter') {
+                    return 'markdown';
+                }
+
+                return $key;
+            })
+        ;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+        static::assertSame(['nomarkdown' => 1], $assignedVariables['tags']);
+    }
+
+    /**
+     * Test displaying bookmark create form
+     * When an existing URL is submitted, we want to edit the existing link.
+     */
+    public function testDisplayCreateFormWithExistingUrl(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $url = 'http://url.tld/other?part=3&utm_ad=pay#hash';
+        $expectedUrl = str_replace('&utm_ad=pay', '', $url);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($url): ?string {
+                return $key === 'post' ? $url : null;
+            });
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByUrl')
+            ->with($expectedUrl)
+            ->willReturn(
+                (new Bookmark())
+                    ->setId($id = 23)
+                    ->setUrl($expectedUrl)
+                    ->setTitle($title = 'Bookmark Title')
+                    ->setDescription($description = 'Bookmark description.')
+                    ->setTags($tags = ['abc', 'def'])
+                    ->setPrivate(true)
+                    ->setCreated($createdAt = new \DateTime('2020-06-10 18:45:44'))
+            )
+        ;
+
+        $result = $this->controller->displayCreateForm($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Edit Shaare - Shaarli', $assignedVariables['pagetitle']);
+        static::assertFalse($assignedVariables['link_is_new']);
+
+        static::assertSame($id, $assignedVariables['link']['id']);
+        static::assertSame($expectedUrl, $assignedVariables['link']['url']);
+        static::assertSame($title, $assignedVariables['link']['title']);
+        static::assertSame($description, $assignedVariables['link']['description']);
+        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertTrue($assignedVariables['link']['private']);
+        static::assertSame($createdAt, $assignedVariables['link']['created']);
+    }
+}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
new file mode 100644 (file)
index 0000000..da393e4
--- /dev/null
@@ -0,0 +1,155 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DisplayEditFormTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaarePublishController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaarePublishController($this->container);
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * When an existing ID is provided, ensure that default workflow works properly.
+     */
+    public function testDisplayEditFormDefault(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $id = 11;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->httpAccess->expects(static::never())->method('getHttpResponse');
+        $this->container->httpAccess->expects(static::never())->method('getCurlDownloadCallback');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willReturn(
+                (new Bookmark())
+                    ->setId($id)
+                    ->setUrl($url = 'http://domain.tld')
+                    ->setTitle($title = 'Bookmark Title')
+                    ->setDescription($description = 'Bookmark description.')
+                    ->setTags($tags = ['abc', 'def'])
+                    ->setPrivate(true)
+                    ->setCreated($createdAt = new \DateTime('2020-06-10 18:45:44'))
+            )
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink', (string) $result->getBody());
+
+        static::assertSame('Edit Shaare - Shaarli', $assignedVariables['pagetitle']);
+        static::assertFalse($assignedVariables['link_is_new']);
+
+        static::assertSame($id, $assignedVariables['link']['id']);
+        static::assertSame($url, $assignedVariables['link']['url']);
+        static::assertSame($title, $assignedVariables['link']['title']);
+        static::assertSame($description, $assignedVariables['link']['description']);
+        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertTrue($assignedVariables['link']['private']);
+        static::assertSame($createdAt, $assignedVariables['link']['created']);
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * Invalid ID provided.
+     */
+    public function testDisplayEditFormInvalidId(): void
+    {
+        $id = 'invalid';
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier invalid could not be found.'])
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, ['id' => $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * ID not provided.
+     */
+    public function testDisplayEditFormIdNotProvided(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier  could not be found.'])
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, []);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test displaying bookmark edit form
+     * Bookmark not found.
+     */
+    public function testDisplayEditFormBookmarkNotFound(): void
+    {
+        $id = 123;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Bookmark with identifier 123 could not be found.'])
+        ;
+
+        $result = $this->controller->displayEditForm($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php
new file mode 100644 (file)
index 0000000..b6a861b
--- /dev/null
@@ -0,0 +1,369 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class SaveBookmarkTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaarePublishController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaarePublishController($this->container);
+    }
+
+    /**
+     * Test save a new bookmark
+     */
+    public function testSaveBookmark(): void
+    {
+        $id = 21;
+        $parameters = [
+            'lf_url' => 'http://url.tld/other?part=3#hash',
+            'lf_title' => 'Provided Title',
+            'lf_description' => 'Provided description.',
+            'lf_tags' => 'abc def',
+            'lf_private' => '1',
+            'returnurl' => 'http://shaarli/subfolder/admin/add-shaare'
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $checkBookmark = function (Bookmark $bookmark) use ($parameters) {
+            static::assertSame($parameters['lf_url'], $bookmark->getUrl());
+            static::assertSame($parameters['lf_title'], $bookmark->getTitle());
+            static::assertSame($parameters['lf_description'], $bookmark->getDescription());
+            static::assertSame($parameters['lf_tags'], $bookmark->getTagsString());
+            static::assertTrue($bookmark->isPrivate());
+        };
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
+                static::assertFalse($save);
+
+                $checkBookmark($bookmark);
+
+                $bookmark->setId($id);
+
+                return $bookmark;
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
+                static::assertTrue($save);
+
+                $checkBookmark($bookmark);
+
+                static::assertSame($id, $bookmark->getId());
+
+                return $bookmark;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['save_link'])
+            ->willReturnCallback(function (string $hook, array $data) use ($parameters, $id): array {
+                if ('save_link' === $hook) {
+                    static::assertSame($id, $data['id']);
+                    static::assertSame($parameters['lf_url'], $data['url']);
+                    static::assertSame($parameters['lf_title'], $data['title']);
+                    static::assertSame($parameters['lf_description'], $data['description']);
+                    static::assertSame($parameters['lf_tags'], $data['tags']);
+                    static::assertTrue($data['private']);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertRegExp('@/subfolder/#[\w\-]{6}@', $result->getHeader('location')[0]);
+    }
+
+
+    /**
+     * Test save an existing bookmark
+     */
+    public function testSaveExistingBookmark(): void
+    {
+        $id = 21;
+        $parameters = [
+            'lf_id' => (string) $id,
+            'lf_url' => 'http://url.tld/other?part=3#hash',
+            'lf_title' => 'Provided Title',
+            'lf_description' => 'Provided description.',
+            'lf_tags' => 'abc def',
+            'lf_private' => '1',
+            'returnurl' => 'http://shaarli/subfolder/?page=2'
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $checkBookmark = function (Bookmark $bookmark) use ($parameters, $id) {
+            static::assertSame($id, $bookmark->getId());
+            static::assertSame($parameters['lf_url'], $bookmark->getUrl());
+            static::assertSame($parameters['lf_title'], $bookmark->getTitle());
+            static::assertSame($parameters['lf_description'], $bookmark->getDescription());
+            static::assertSame($parameters['lf_tags'], $bookmark->getTagsString());
+            static::assertTrue($bookmark->isPrivate());
+        };
+
+        $this->container->bookmarkService->expects(static::atLeastOnce())->method('exists')->willReturn(true);
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->willReturn((new Bookmark())->setId($id)->setUrl('http://other.url'))
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
+                static::assertFalse($save);
+
+                $checkBookmark($bookmark);
+
+                return $bookmark;
+            })
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($checkBookmark, $id): Bookmark {
+                static::assertTrue($save);
+
+                $checkBookmark($bookmark);
+
+                static::assertSame($id, $bookmark->getId());
+
+                return $bookmark;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['save_link'])
+            ->willReturnCallback(function (string $hook, array $data) use ($parameters, $id): array {
+                if ('save_link' === $hook) {
+                    static::assertSame($id, $data['id']);
+                    static::assertSame($parameters['lf_url'], $data['url']);
+                    static::assertSame($parameters['lf_title'], $data['title']);
+                    static::assertSame($parameters['lf_description'], $data['description']);
+                    static::assertSame($parameters['lf_tags'], $data['tags']);
+                    static::assertTrue($data['private']);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertRegExp('@/subfolder/\?page=2#[\w\-]{6}@', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test save a bookmark - try to retrieve the thumbnail
+     */
+    public function testSaveBookmarkWithThumbnailSync(): void
+    {
+        $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'thumbnails.mode') {
+                return Thumbnailer::MODE_ALL;
+            } elseif ($key === 'general.enable_async_metadata') {
+                return false;
+            }
+
+            return $default;
+        });
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer
+            ->expects(static::once())
+            ->method('get')
+            ->with($parameters['lf_url'])
+            ->willReturn($thumb = 'http://thumb.url')
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark, bool $save) use ($thumb): Bookmark {
+                static::assertSame($thumb, $bookmark->getThumbnail());
+
+                return $bookmark;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+    }
+
+    /**
+     * Test save a bookmark - with ID #0
+     */
+    public function testSaveBookmarkWithIdZero(): void
+    {
+        $parameters = ['lf_id' => '0'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('exists')->with(0)->willReturn(true);
+        $this->container->bookmarkService->expects(static::once())->method('get')->with(0)->willReturn(new Bookmark());
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+    }
+
+    /**
+     * Test save a bookmark - do not attempt to retrieve thumbnails if async mode is enabled.
+     */
+    public function testSaveBookmarkWithThumbnailAsync(): void
+    {
+        $parameters = ['lf_url' => 'http://url.tld/other?part=3#hash'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'thumbnails.mode') {
+                return Thumbnailer::MODE_ALL;
+            } elseif ($key === 'general.enable_async_metadata') {
+                return true;
+            }
+
+            return $default;
+        });
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer->expects(static::never())->method('get');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('addOrSet')
+            ->willReturnCallback(function (Bookmark $bookmark): Bookmark {
+                static::assertNull($bookmark->getThumbnail());
+
+                return $bookmark;
+            })
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testSaveBookmarkFromBookmarklet(): void
+    {
+        $parameters = ['source' => 'bookmarklet'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters): ?string {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('<script>self.close();</script>', (string) $result->getBody());
+    }
+
+    /**
+     * Change the password with a wrong existing password
+     */
+    public function testSaveBookmarkWrongToken(): void
+    {
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->container->bookmarkService->expects(static::never())->method('addOrSet');
+        $this->container->bookmarkService->expects(static::never())->method('set');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->save($request, $response);
+    }
+
+}
diff --git a/tests/front/controller/admin/ShaarliAdminControllerTest.php b/tests/front/controller/admin/ShaarliAdminControllerTest.php
new file mode 100644 (file)
index 0000000..486d5d2
--- /dev/null
@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+
+/**
+ * Class ShaarliControllerTest
+ *
+ * This class is used to test default behavior of ShaarliAdminController abstract class.
+ * It uses a dummy non abstract controller.
+ */
+class ShaarliAdminControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaarliAdminController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new class($this->container) extends ShaarliAdminController
+        {
+            public function checkToken(Request $request): bool
+            {
+                return parent::checkToken($request);
+            }
+
+            public function saveSuccessMessage(string $message): void
+            {
+                parent::saveSuccessMessage($message);
+            }
+
+            public function saveWarningMessage(string $message): void
+            {
+                parent::saveWarningMessage($message);
+            }
+
+            public function saveErrorMessage(string $message): void
+            {
+                parent::saveErrorMessage($message);
+            }
+        };
+    }
+
+    /**
+     * Trigger controller's checkToken with a valid token.
+     */
+    public function testCheckTokenWithValidToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('token')->willReturn($token = '12345');
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->with($token)->willReturn(true);
+
+        static::assertTrue($this->controller->checkToken($request));
+    }
+
+    /**
+     * Trigger controller's checkToken with na valid token should raise an exception.
+     */
+    public function testCheckTokenWithNotValidToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('token')->willReturn($token = '12345');
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->with($token)->willReturn(false);
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->checkToken($request);
+    }
+
+    /**
+     * Test saveSuccessMessage() with a first message.
+     */
+    public function testSaveSuccessMessage(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, [$message = 'bravo!'])
+        ;
+
+        $this->controller->saveSuccessMessage($message);
+    }
+
+    /**
+     * Test saveSuccessMessage() with existing messages.
+     */
+    public function testSaveSuccessMessageWithExistingMessages(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES)
+            ->willReturn(['success1', 'success2'])
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['success1', 'success2', $message = 'bravo!'])
+        ;
+
+        $this->controller->saveSuccessMessage($message);
+    }
+
+    /**
+     * Test saveWarningMessage() with a first message.
+     */
+    public function testSaveWarningMessage(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_WARNING_MESSAGES, [$message = 'warning!'])
+        ;
+
+        $this->controller->saveWarningMessage($message);
+    }
+
+    /**
+     * Test saveWarningMessage() with existing messages.
+     */
+    public function testSaveWarningMessageWithExistingMessages(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_WARNING_MESSAGES)
+            ->willReturn(['warning1', 'warning2'])
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_WARNING_MESSAGES, ['warning1', 'warning2', $message = 'warning!'])
+        ;
+
+        $this->controller->saveWarningMessage($message);
+    }
+
+    /**
+     * Test saveErrorMessage() with a first message.
+     */
+    public function testSaveErrorMessage(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, [$message = 'error!'])
+        ;
+
+        $this->controller->saveErrorMessage($message);
+    }
+
+    /**
+     * Test saveErrorMessage() with existing messages.
+     */
+    public function testSaveErrorMessageWithExistingMessages(): void
+    {
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES)
+            ->willReturn(['error1', 'error2'])
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['error1', 'error2', $message = 'error!'])
+        ;
+
+        $this->controller->saveErrorMessage($message);
+    }
+}
diff --git a/tests/front/controller/admin/ThumbnailsControllerTest.php b/tests/front/controller/admin/ThumbnailsControllerTest.php
new file mode 100644 (file)
index 0000000..e574965
--- /dev/null
@@ -0,0 +1,156 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\TestCase;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ThumbnailsControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ThumbnailsController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ThumbnailsController($this->container);
+    }
+
+    /**
+     * Test displaying the thumbnails update page
+     * Note that only non-note and HTTP bookmarks should be returned.
+     */
+    public function testIndex(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('?abcdef')->setTitle('Note 1'),
+                (new Bookmark())->setId(3)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(4)->setUrl('ftp://domain.tld', ['ftp'])->setTitle('FTP'),
+            ])
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('thumbnails', (string) $result->getBody());
+
+        static::assertSame('Thumbnails update - Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame([1, 3], $assignedVariables['ids']);
+    }
+
+    /**
+     * Test updating a bookmark thumbnail with valid parameters
+     */
+    public function testAjaxUpdateValid(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId($id = 123)
+            ->setUrl($url = 'http://url1.tld')
+            ->setTitle('Title 1')
+            ->setThumbnail(false)
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer
+            ->expects(static::once())
+            ->method('get')
+            ->with($url)
+            ->willReturn($thumb = 'http://img.tld/pic.png')
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->willReturnCallback(function (Bookmark $bookmark) use ($thumb): Bookmark {
+                static::assertSame($thumb, $bookmark->getThumbnail());
+
+                return $bookmark;
+            })
+        ;
+
+        $result = $this->controller->ajaxUpdate($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(200, $result->getStatusCode());
+
+        $payload = json_decode((string) $result->getBody(), true);
+
+        static::assertSame($id, $payload['id']);
+        static::assertSame($url, $payload['url']);
+        static::assertSame($thumb, $payload['thumbnail']);
+    }
+
+    /**
+     * Test updating a bookmark thumbnail - Invalid ID
+     */
+    public function testAjaxUpdateInvalidId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->ajaxUpdate($request, $response, ['id' => 'nope']);
+
+        static::assertSame(400, $result->getStatusCode());
+    }
+
+    /**
+     * Test updating a bookmark thumbnail - No ID
+     */
+    public function testAjaxUpdateNoId(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->ajaxUpdate($request, $response, []);
+
+        static::assertSame(400, $result->getStatusCode());
+    }
+
+    /**
+     * Test updating a bookmark thumbnail with valid parameters
+     */
+    public function testAjaxUpdateBookmarkNotFound(): void
+    {
+        $id = 123;
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('get')
+            ->with($id)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $result = $this->controller->ajaxUpdate($request, $response, ['id' => (string) $id]);
+
+        static::assertSame(404, $result->getStatusCode());
+    }
+}
diff --git a/tests/front/controller/admin/TokenControllerTest.php b/tests/front/controller/admin/TokenControllerTest.php
new file mode 100644 (file)
index 0000000..d2f0907
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class TokenControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var TokenController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new TokenController($this->container);
+    }
+
+    public function testGetToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('generateToken')
+            ->willReturn($token = 'token1234')
+        ;
+
+        $result = $this->controller->getToken($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame($token, (string) $result->getBody());
+    }
+}
diff --git a/tests/front/controller/admin/ToolsControllerTest.php b/tests/front/controller/admin/ToolsControllerTest.php
new file mode 100644 (file)
index 0000000..e82f8b1
--- /dev/null
@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ToolsControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ToolsController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ToolsController($this->container);
+    }
+
+    public function testDefaultInvokeWithHttps(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->environment = [
+            'SERVER_NAME' => 'shaarli',
+            'SERVER_PORT' => 443,
+            'HTTPS' => 'on',
+        ];
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tools', (string) $result->getBody());
+        static::assertSame('https://shaarli/', $assignedVariables['pageabsaddr']);
+        static::assertTrue($assignedVariables['sslenabled']);
+    }
+
+    public function testDefaultInvokeWithoutHttps(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->environment = [
+            'SERVER_NAME' => 'shaarli',
+            'SERVER_PORT' => 80,
+        ];
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tools', (string) $result->getBody());
+        static::assertSame('http://shaarli/', $assignedVariables['pageabsaddr']);
+        static::assertFalse($assignedVariables['sslenabled']);
+    }
+}
diff --git a/tests/front/controller/visitor/BookmarkListControllerTest.php b/tests/front/controller/visitor/BookmarkListControllerTest.php
new file mode 100644 (file)
index 0000000..5cbc8c7
--- /dev/null
@@ -0,0 +1,532 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\TestCase;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class BookmarkListControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var BookmarkListController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new BookmarkListController($this->container);
+    }
+
+    /**
+     * Test rendering list of bookmarks with default parameters (first page).
+     */
+    public function testIndexDefaultFirstPage(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(
+                ['searchtags' => '', 'searchterm' => ''],
+                null,
+                false,
+                false
+            )
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setTitle('Title 3'),
+            ]
+        );
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $parameter, $default = null) {
+                if ('LINKS_PER_PAGE' === $parameter) {
+                    return 2;
+                }
+
+                return $default;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame('?page=2', $assignedVariables['previous_page_url']);
+        static::assertSame('', $assignedVariables['next_page_url']);
+        static::assertSame(2, $assignedVariables['page_max']);
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertSame(3, $assignedVariables['result_count']);
+        static::assertSame(1, $assignedVariables['page_current']);
+        static::assertSame('', $assignedVariables['search_term']);
+        static::assertNull($assignedVariables['visibility']);
+        static::assertCount(2, $assignedVariables['links']);
+
+        $link = $assignedVariables['links'][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url1.tld', $link['url']);
+        static::assertSame('Title 1', $link['title']);
+
+        $link = $assignedVariables['links'][1];
+
+        static::assertSame(2, $link['id']);
+        static::assertSame('http://url2.tld', $link['url']);
+        static::assertSame('Title 2', $link['title']);
+    }
+
+    /**
+     * Test rendering list of bookmarks with default parameters (second page).
+     */
+    public function testIndexDefaultSecondPage(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) {
+            if ('page' === $key) {
+                return '2';
+            }
+
+            return null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(
+                ['searchtags' => '', 'searchterm' => ''],
+                null,
+                false,
+                false
+            )
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setTitle('Title 3'),
+            ])
+        ;
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $parameter, $default = null) {
+                if ('LINKS_PER_PAGE' === $parameter) {
+                    return 2;
+                }
+
+                return $default;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame('', $assignedVariables['previous_page_url']);
+        static::assertSame('?page=1', $assignedVariables['next_page_url']);
+        static::assertSame(2, $assignedVariables['page_max']);
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertSame(3, $assignedVariables['result_count']);
+        static::assertSame(2, $assignedVariables['page_current']);
+        static::assertSame('', $assignedVariables['search_term']);
+        static::assertNull($assignedVariables['visibility']);
+        static::assertCount(1, $assignedVariables['links']);
+
+        $link = $assignedVariables['links'][2];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertSame('Title 3', $link['title']);
+    }
+
+    /**
+     * Test rendering list of bookmarks with filters.
+     */
+    public function testIndexDefaultWithFilters(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) {
+            if ('searchtags' === $key) {
+                return 'abc def';
+            }
+            if ('searchterm' === $key) {
+                return 'ghi jkl';
+            }
+
+            return null;
+        });
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $key, $default) {
+                if ('LINKS_PER_PAGE' === $key) {
+                    return 2;
+                }
+                if ('visibility' === $key) {
+                    return 'private';
+                }
+                if ('untaggedonly' === $key) {
+                    return true;
+                }
+
+                return $default;
+            })
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->with(
+                ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'],
+                'private',
+                false,
+                true
+            )
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('http://url1.tld')->setTitle('Title 1'),
+                (new Bookmark())->setId(2)->setUrl('http://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setTitle('Title 3'),
+            ])
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']);
+        static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc+def', $assignedVariables['previous_page_url']);
+    }
+
+    /**
+     * Test displaying a permalink with valid parameters
+     */
+    public function testPermalinkValid(): void
+    {
+        $hash = 'abcdef';
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn((new Bookmark())->setId(123)->setTitle('Title 1')->setUrl('http://url1.tld'))
+        ;
+
+        $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+
+        static::assertSame('Title 1 - Shaarli', $assignedVariables['pagetitle']);
+        static::assertCount(1, $assignedVariables['links']);
+
+        $link = $assignedVariables['links'][0];
+
+        static::assertSame(123, $link['id']);
+        static::assertSame('http://url1.tld', $link['url']);
+        static::assertSame('Title 1', $link['title']);
+    }
+
+    /**
+     * Test displaying a permalink with an unknown small hash : renders a 404 template error
+     */
+    public function testPermalinkNotFound(): void
+    {
+        $hash = 'abcdef';
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willThrowException(new BookmarkNotFoundException())
+        ;
+
+        $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('404', (string) $result->getBody());
+
+        static::assertSame(
+            'The link you are trying to reach does not exist or has been deleted.',
+            $assignedVariables['error_message']
+        );
+    }
+
+    /**
+     * Test GET /shaare/{hash}?key={key} - Find a link by hash using a private link.
+     */
+    public function testPermalinkWithPrivateKey(): void
+    {
+        $hash = 'abcdef';
+        $privateKey = 'this is a private key';
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key, $default = null) use ($privateKey) {
+            return $key === 'key' ? $privateKey : $default;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash, $privateKey)
+            ->willReturn((new Bookmark())->setId(123)->setTitle('Title 1')->setUrl('http://url1.tld'))
+        ;
+
+        $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+        static::assertCount(1, $assignedVariables['links']);
+    }
+
+    /**
+     * Test getting link list with thumbnail updates.
+     *   -> 2 thumbnails update, only 1 datastore write
+     */
+    public function testThumbnailUpdateFromLinkList(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $default) {
+                if ($key === 'thumbnails.mode') {
+                    return Thumbnailer::MODE_ALL;
+                } elseif ($key === 'general.enable_async_metadata') {
+                    return false;
+                }
+
+                return $default;
+            })
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer
+            ->expects(static::exactly(2))
+            ->method('get')
+            ->withConsecutive(['https://url2.tld'], ['https://url4.tld'])
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->willReturn([
+                (new Bookmark())->setId(1)->setUrl('https://url1.tld')->setTitle('Title 1')->setThumbnail(false),
+                $b1 = (new Bookmark())->setId(2)->setUrl('https://url2.tld')->setTitle('Title 2'),
+                (new Bookmark())->setId(3)->setUrl('https://url3.tld')->setTitle('Title 3')->setThumbnail(false),
+                $b2 = (new Bookmark())->setId(2)->setUrl('https://url4.tld')->setTitle('Title 4'),
+                (new Bookmark())->setId(2)->setUrl('ftp://url5.tld', ['ftp'])->setTitle('Title 5'),
+            ])
+        ;
+        $this->container->bookmarkService
+            ->expects(static::exactly(2))
+            ->method('set')
+            ->withConsecutive([$b1, false], [$b2, false])
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('save');
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+    }
+
+    /**
+     * Test getting a permalink with thumbnail update.
+     */
+    public function testThumbnailUpdateFromPermalink(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $default) {
+                if ($key === 'thumbnails.mode') {
+                    return Thumbnailer::MODE_ALL;
+                } elseif ($key === 'general.enable_async_metadata') {
+                    return false;
+                }
+
+                return $default;
+            })
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer->expects(static::once())->method('get')->withConsecutive(['https://url.tld']);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->willReturn($bookmark = (new Bookmark())->setId(2)->setUrl('https://url.tld')->setTitle('Title 1'))
+        ;
+        $this->container->bookmarkService->expects(static::once())->method('set')->with($bookmark, true);
+        $this->container->bookmarkService->expects(static::never())->method('save');
+
+        $result = $this->controller->permalink($request, $response, ['hash' => 'abc']);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+    }
+
+    /**
+     * Test getting a permalink with thumbnail update with async setting: no update should run.
+     */
+    public function testThumbnailUpdateFromPermalinkAsync(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $default) {
+                if ($key === 'thumbnails.mode') {
+                    return Thumbnailer::MODE_ALL;
+                } elseif ($key === 'general.enable_async_metadata') {
+                    return true;
+                }
+
+                return $default;
+            })
+        ;
+
+        $this->container->thumbnailer = $this->createMock(Thumbnailer::class);
+        $this->container->thumbnailer->expects(static::never())->method('get');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->willReturn((new Bookmark())->setId(2)->setUrl('https://url.tld')->setTitle('Title 1'))
+        ;
+        $this->container->bookmarkService->expects(static::never())->method('set');
+        $this->container->bookmarkService->expects(static::never())->method('save');
+
+        $result = $this->controller->permalink($request, $response, ['hash' => 'abc']);
+
+        static::assertSame(200, $result->getStatusCode());
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: permalink
+     */
+    public function testLegacyControllerPermalink(): void
+    {
+        $hash = 'abcdef';
+        $this->container->environment['QUERY_STRING'] = $hash;
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/shaare/' . $hash, $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: ?do= query parameter
+     */
+    public function testLegacyControllerDoPage(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('do')->willReturn('picwall');
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/picture-wall', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: ?do= query parameter with unknown legacy route
+     */
+    public function testLegacyControllerUnknownDoPage(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('do')->willReturn('nope');
+        $response = new Response();
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+    }
+
+    /**
+     * Trigger legacy controller in link list controller: other GET route (e.g. ?post)
+     */
+    public function testLegacyControllerGetParameter(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParams')->willReturn(['post' => $url = 'http://url.tld']);
+        $response = new Response();
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(
+            '/subfolder/admin/shaare?post=' . urlencode($url),
+            $result->getHeader('location')[0]
+        );
+    }
+}
diff --git a/tests/front/controller/visitor/DailyControllerTest.php b/tests/front/controller/visitor/DailyControllerTest.php
new file mode 100644 (file)
index 0000000..758e721
--- /dev/null
@@ -0,0 +1,716 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Feed\CachedPage;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DailyControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var DailyController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new DailyController($this->container);
+        DailyController::$DAILY_RSS_NB_DAYS = 2;
+    }
+
+    public function testValidIndexControllerInvokeDefault(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+        $previousDate = new \DateTime('2 days ago 00:00:00');
+        $nextDate = new \DateTime('today 00:00:00');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'day' ? $currentDay->format('Ymd') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(
+                function ($from, $to, &$previous, &$next) use ($currentDay, $previousDate, $nextDate): array {
+                    $previous = $previousDate;
+                    $next = $nextDate;
+
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(3)
+                            ->setUrl('http://url3.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_daily'])
+            ->willReturnCallback(
+                function (string $hook, array $data, array $param) use ($currentDay, $previousDate, $nextDate): array {
+                    if ('render_daily' === $hook) {
+                        static::assertArrayHasKey('linksToDisplay', $data);
+                        static::assertCount(3, $data['linksToDisplay']);
+                        static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                        static::assertSame($currentDay->getTimestamp(), $data['day']);
+                        static::assertSame($previousDate->format('Ymd'), $data['previousday']);
+                        static::assertSame($nextDate->format('Ymd'), $data['nextday']);
+
+                        static::assertArrayHasKey('loggedin', $param);
+                    }
+
+                    return $data;
+                }
+            )
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Daily - '. format_date($currentDay, false, true) .' - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+        static::assertEquals($currentDay, $assignedVariables['dayDate']);
+        static::assertEquals($currentDay->getTimestamp(), $assignedVariables['day']);
+        static::assertSame($previousDate->format('Ymd'), $assignedVariables['previousday']);
+        static::assertSame($nextDate->format('Ymd'), $assignedVariables['nextday']);
+        static::assertSame('day', $assignedVariables['type']);
+        static::assertSame('May 13, 2020', $assignedVariables['dayDesc']);
+        static::assertSame('Daily', $assignedVariables['localizedType']);
+        static::assertCount(3, $assignedVariables['linksToDisplay']);
+
+        $link = $assignedVariables['linksToDisplay'][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['linksToDisplay'][1];
+
+        static::assertSame(2, $link['id']);
+        static::assertSame('http://url2.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['linksToDisplay'][2];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        static::assertCount(3, $assignedVariables['cols']);
+        static::assertCount(1, $assignedVariables['cols'][0]);
+        static::assertCount(1, $assignedVariables['cols'][1]);
+        static::assertCount(1, $assignedVariables['cols'][2]);
+
+        $link = $assignedVariables['cols'][0][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['cols'][1][0];
+
+        static::assertSame(2, $link['id']);
+        static::assertSame('http://url2.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+
+        $link = $assignedVariables['cols'][2][0];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertNotEmpty($link['title']);
+        static::assertNotEmpty($link['description']);
+        static::assertNotEmpty($link['formatedDescription']);
+    }
+
+    /**
+     * Daily page - test that everything goes fine with no future or past bookmarks
+     */
+    public function testValidIndexControllerInvokeNoFutureOrPast(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'day' ? $currentDay->format('Ymd') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(function () use ($currentDay): array {
+                return [
+                    (new Bookmark())
+                        ->setId(1)
+                        ->setUrl('http://url.tld')
+                        ->setTitle(static::generateString(50))
+                        ->setDescription(static::generateString(500))
+                    ,
+                ];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_daily'])
+            ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array {
+                if ('render_daily' === $hook) {
+                    static::assertArrayHasKey('linksToDisplay', $data);
+                    static::assertCount(1, $data['linksToDisplay']);
+                    static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                    static::assertSame($currentDay->getTimestamp(), $data['day']);
+                    static::assertEmpty($data['previousday']);
+                    static::assertEmpty($data['nextday']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                }
+
+                return $data;
+            });
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Daily - '. format_date($currentDay, false, true) .' - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+        static::assertCount(1, $assignedVariables['linksToDisplay']);
+
+        $link = $assignedVariables['linksToDisplay'][0];
+        static::assertSame(1, $link['id']);
+    }
+
+    /**
+     * Daily page - test that height adjustment in columns is working
+     */
+    public function testValidIndexControllerInvokeHeightAdjustment(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(function () use ($currentDay): array {
+                return [
+                    (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())
+                        ->setId(2)
+                        ->setUrl('http://url.tld')
+                        ->setTitle(static::generateString(50))
+                        ->setDescription(static::generateString(5000))
+                    ,
+                    (new Bookmark())->setId(3)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(4)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(5)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(6)->setUrl('http://url.tld')->setTitle('title'),
+                    (new Bookmark())->setId(7)->setUrl('http://url.tld')->setTitle('title'),
+                ];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertCount(7, $assignedVariables['linksToDisplay']);
+
+        $columnIds = function (array $column): array {
+            return array_map(function (array $item): int { return $item['id']; }, $column);
+        };
+
+        static::assertSame([1, 4, 6], $columnIds($assignedVariables['cols'][0]));
+        static::assertSame([2], $columnIds($assignedVariables['cols'][1]));
+        static::assertSame([3, 5, 7], $columnIds($assignedVariables['cols'][2]));
+    }
+
+    /**
+     * Daily page - no bookmark
+     */
+    public function testValidIndexControllerInvokeNoBookmark(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        // Links dataset: 2 links with thumbnails
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(function (): array {
+                return [];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertCount(0, $assignedVariables['linksToDisplay']);
+        static::assertSame('Today - ' . (new \DateTime())->format('F d, Y'), $assignedVariables['dayDesc']);
+        static::assertEquals((new \DateTime())->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertEquals((new \DateTime())->setTime(0, 0), $assignedVariables['dayDate']);
+    }
+
+    /**
+     * Daily RSS - default behaviour
+     */
+    public function testValidRssControllerInvokeDefault(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-17'),
+            new \DateTimeImmutable('2020-05-15'),
+            new \DateTimeImmutable('2020-05-13'),
+            new \DateTimeImmutable('+1 month'),
+        ];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+            (new Bookmark())->setId(4)->setCreated($dates[2])->setUrl('http://domain.tld/4'),
+            (new Bookmark())->setId(5)->setCreated($dates[3])->setUrl('http://domain.tld/5'),
+        ]);
+
+        $this->container->pageCacheManager
+            ->expects(static::once())
+            ->method('getCachePage')
+            ->willReturnCallback(function (): CachedPage {
+                $cachedPage = $this->createMock(CachedPage::class);
+                $cachedPage->expects(static::once())->method('cache')->with('dailyrss');
+
+                return $cachedPage;
+            }
+        );
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(3, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('Ymd')];
+        $date = $dates[0]->setTime(23, 59, 59);
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?day='. $dates[0]->format('Ymd'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+        static::assertSame(1, $day['links'][0]['id']);
+        static::assertSame('http://domain.tld/1', $day['links'][0]['url']);
+        static::assertEquals($dates[0], $day['links'][0]['created']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('Ymd')];
+        $date = $dates[1]->setTime(23, 59, 59);
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?day='. $dates[1]->format('Ymd'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+
+        static::assertSame(2, $day['links'][0]['id']);
+        static::assertSame('http://domain.tld/2', $day['links'][0]['url']);
+        static::assertEquals($dates[1], $day['links'][0]['created']);
+        static::assertSame(3, $day['links'][1]['id']);
+        static::assertSame('http://domain.tld/3', $day['links'][1]['url']);
+        static::assertEquals($dates[1], $day['links'][1]['created']);
+
+        $day = $assignedVariables['days'][$dates[2]->format('Ymd')];
+        $date = $dates[2]->setTime(23, 59, 59);
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?day='. $dates[2]->format('Ymd'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+        static::assertSame(4, $day['links'][0]['id']);
+        static::assertSame('http://domain.tld/4', $day['links'][0]['url']);
+        static::assertEquals($dates[2], $day['links'][0]['created']);
+    }
+
+    /**
+     * Daily RSS - trigger cache rendering
+     */
+    public function testValidRssControllerInvokeTriggerCache(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->pageCacheManager->method('getCachePage')->willReturnCallback(function (): CachedPage {
+            $cachedPage = $this->createMock(CachedPage::class);
+            $cachedPage->method('cachedVersion')->willReturn('this is cache!');
+
+            return $cachedPage;
+        });
+
+        $this->container->bookmarkService->expects(static::never())->method('search');
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('this is cache!', (string) $result->getBody());
+    }
+
+    /**
+     * Daily RSS - No bookmark
+     */
+    public function testValidRssControllerInvokeNoBookmark(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(0, $assignedVariables['days']);
+    }
+
+    /**
+     * Test simple display index with week parameter
+     */
+    public function testSimpleIndexWeekly(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+        $expectedDay = new \DateTimeImmutable('2020-05-11');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'week' ? $currentDay->format('YW') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(
+                function (): array {
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Weekly - Week 20 (May 11, 2020) - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+        static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
+        static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertSame('', $assignedVariables['previousday']);
+        static::assertSame('', $assignedVariables['nextday']);
+        static::assertSame('Week 20 (May 11, 2020)', $assignedVariables['dayDesc']);
+        static::assertSame('week', $assignedVariables['type']);
+        static::assertSame('Weekly', $assignedVariables['localizedType']);
+    }
+
+    /**
+     * Test simple display index with month parameter
+     */
+    public function testSimpleIndexMonthly(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+        $expectedDay = new \DateTimeImmutable('2020-05-01');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'month' ? $currentDay->format('Ym') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(
+                function (): array {
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Monthly - May, 2020 - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+        static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
+        static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertSame('', $assignedVariables['previousday']);
+        static::assertSame('', $assignedVariables['nextday']);
+        static::assertSame('May, 2020', $assignedVariables['dayDesc']);
+        static::assertSame('month', $assignedVariables['type']);
+        static::assertSame('Monthly', $assignedVariables['localizedType']);
+    }
+
+    /**
+     * Test simple display RSS with week parameter
+     */
+    public function testSimpleRssWeekly(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-19'),
+            new \DateTimeImmutable('2020-05-13'),
+        ];
+        $expectedDates = [
+            new \DateTimeImmutable('2020-05-24 23:59:59'),
+            new \DateTimeImmutable('2020-05-17 23:59:59'),
+        ];
+
+        $this->container->environment['QUERY_STRING'] = 'week';
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
+            return $key === 'week' ? '' : null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+        ]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss?week', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(2, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('YW')];
+        $date = $expectedDates[0];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('Week 21 (May 18, 2020)', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?week='. $dates[0]->format('YW'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('YW')];
+        $date = $expectedDates[1];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('Week 20 (May 11, 2020)', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?week='. $dates[1]->format('YW'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+    }
+
+    /**
+     * Test simple display RSS with month parameter
+     */
+    public function testSimpleRssMonthly(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-19'),
+            new \DateTimeImmutable('2020-04-13'),
+        ];
+        $expectedDates = [
+            new \DateTimeImmutable('2020-05-31 23:59:59'),
+            new \DateTimeImmutable('2020-04-30 23:59:59'),
+        ];
+
+        $this->container->environment['QUERY_STRING'] = 'month';
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
+            return $key === 'month' ? '' : null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+        ]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss?month', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(2, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('Ym')];
+        $date = $expectedDates[0];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('May, 2020', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?month='. $dates[0]->format('Ym'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('Ym')];
+        $date = $expectedDates[1];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('April, 2020', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?month='. $dates[1]->format('Ym'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+    }
+}
diff --git a/tests/front/controller/visitor/ErrorControllerTest.php b/tests/front/controller/visitor/ErrorControllerTest.php
new file mode 100644 (file)
index 0000000..75408cf
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Front\Exception\ShaarliFrontException;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ErrorControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var ErrorController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ErrorController($this->container);
+    }
+
+    /**
+     * Test displaying error with a ShaarliFrontException: display exception message and use its code for HTTTP code
+     */
+    public function testDisplayFrontExceptionError(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $message = 'error message';
+        $errorCode = 418;
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = ($this->controller)(
+            $request,
+            $response,
+            new class($message, $errorCode) extends ShaarliFrontException {}
+        );
+
+        static::assertSame($errorCode, $result->getStatusCode());
+        static::assertSame($message, $assignedVariables['message']);
+        static::assertArrayNotHasKey('stacktrace', $assignedVariables);
+    }
+
+    /**
+     * Test displaying error with any exception (no debug): only display an error occurred with HTTP 500.
+     */
+    public function testDisplayAnyExceptionErrorNoDebug(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = ($this->controller)($request, $response, new \Exception('abc'));
+
+        static::assertSame(500, $result->getStatusCode());
+        static::assertSame('An unexpected error occurred.', $assignedVariables['message']);
+        static::assertArrayNotHasKey('stacktrace', $assignedVariables);
+    }
+}
diff --git a/tests/front/controller/visitor/ErrorNotFoundControllerTest.php b/tests/front/controller/visitor/ErrorNotFoundControllerTest.php
new file mode 100644 (file)
index 0000000..a1cbbec
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+use Slim\Http\Uri;
+
+class ErrorNotFoundControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var ErrorNotFoundController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ErrorNotFoundController($this->container);
+    }
+
+    /**
+     * Test displaying 404 error
+     */
+    public function testDisplayNotFoundError(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->expects(static::once())->method('getRequestTarget')->willReturn('/');
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = ($this->controller)(
+            $request,
+            $response
+        );
+
+        static::assertSame(404, $result->getStatusCode());
+        static::assertSame('404', (string) $result->getBody());
+        static::assertSame('Requested page could not be found.', $assignedVariables['error_message']);
+    }
+
+    /**
+     * Test displaying 404 error from REST API
+     */
+    public function testDisplayNotFoundErrorFromAPI(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->expects(static::once())->method('getRequestTarget')->willReturn('/sufolder/api/v1/links');
+        $request->method('getUri')->willReturnCallback(function (): Uri {
+            $uri = $this->createMock(Uri::class);
+            $uri->method('getBasePath')->willReturn('/subfolder');
+
+            return $uri;
+        });
+
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = ($this->controller)($request, $response);
+
+        static::assertSame(404, $result->getStatusCode());
+        static::assertSame([], $assignedVariables);
+    }
+}
diff --git a/tests/front/controller/visitor/FeedControllerTest.php b/tests/front/controller/visitor/FeedControllerTest.php
new file mode 100644 (file)
index 0000000..4ae7c92
--- /dev/null
@@ -0,0 +1,151 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Feed\FeedBuilder;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class FeedControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var FeedController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->feedBuilder = $this->createMock(FeedBuilder::class);
+
+        $this->controller = new FeedController($this->container);
+    }
+
+    /**
+     * Feed Controller - RSS default behaviour
+     */
+    public function testDefaultRssController(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->feedBuilder->expects(static::once())->method('setLocale');
+        $this->container->feedBuilder->expects(static::once())->method('setHideDates')->with(false);
+        $this->container->feedBuilder->expects(static::once())->method('setUsePermalinks')->with(true);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->feedBuilder->method('buildData')->willReturn(['content' => 'data']);
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_feed'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): void {
+                if ('render_feed' === $hook) {
+                    static::assertSame('data', $data['content']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                    static::assertSame('feed.rss', $param['target']);
+                }
+            })
+        ;
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('feed.rss', (string) $result->getBody());
+        static::assertSame('data', $assignedVariables['content']);
+    }
+
+    /**
+     * Feed Controller - ATOM default behaviour
+     */
+    public function testDefaultAtomController(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->feedBuilder->expects(static::once())->method('setLocale');
+        $this->container->feedBuilder->expects(static::once())->method('setHideDates')->with(false);
+        $this->container->feedBuilder->expects(static::once())->method('setUsePermalinks')->with(true);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->feedBuilder->method('buildData')->willReturn(['content' => 'data']);
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_feed'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): void {
+                if ('render_feed' === $hook) {
+                    static::assertSame('data', $data['content']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                    static::assertSame('feed.atom', $param['target']);
+                }
+            })
+        ;
+
+        $result = $this->controller->atom($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/atom', $result->getHeader('Content-Type')[0]);
+        static::assertSame('feed.atom', (string) $result->getBody());
+        static::assertSame('data', $assignedVariables['content']);
+    }
+
+    /**
+     * Feed Controller - ATOM with parameters
+     */
+    public function testAtomControllerWithParameters(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParams')->willReturn(['parameter' => 'value']);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->feedBuilder
+            ->method('buildData')
+            ->with('atom', ['parameter' => 'value'])
+            ->willReturn(['content' => 'data'])
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_feed'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): void {
+                if ('render_feed' === $hook) {
+                    static::assertSame('data', $data['content']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                    static::assertSame('feed.atom', $param['target']);
+                }
+            })
+        ;
+
+        $result = $this->controller->atom($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/atom', $result->getHeader('Content-Type')[0]);
+        static::assertSame('feed.atom', (string) $result->getBody());
+        static::assertSame('data', $assignedVariables['content']);
+    }
+}
diff --git a/tests/front/controller/visitor/FrontControllerMockHelper.php b/tests/front/controller/visitor/FrontControllerMockHelper.php
new file mode 100644 (file)
index 0000000..fc0bb7d
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\BookmarkServiceInterface;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Container\ShaarliTestContainer;
+use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\BookmarkRawFormatter;
+use Shaarli\Formatter\FormatterFactory;
+use Shaarli\Plugin\PluginManager;
+use Shaarli\Render\PageBuilder;
+use Shaarli\Render\PageCacheManager;
+use Shaarli\Security\LoginManager;
+use Shaarli\Security\SessionManager;
+
+/**
+ * Trait FrontControllerMockHelper
+ *
+ * Helper trait used to initialize the ShaarliContainer and mock its services for controller tests.
+ *
+ * @property ShaarliTestContainer $container
+ * @package Shaarli\Front\Controller
+ */
+trait FrontControllerMockHelper
+{
+    /** @var ShaarliTestContainer */
+    protected $container;
+
+    /**
+     * Mock the container instance and initialize container's services used by tests
+     */
+    protected function createContainer(): void
+    {
+        $this->container = $this->createMock(ShaarliTestContainer::class);
+
+        $this->container->loginManager = $this->createMock(LoginManager::class);
+
+        // Config
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            return $default === null ? $parameter : $default;
+        });
+
+        // PageBuilder
+        $this->container->pageBuilder = $this->createMock(PageBuilder::class);
+        $this->container->pageBuilder
+            ->method('render')
+            ->willReturnCallback(function (string $template): string {
+                return $template;
+            })
+        ;
+
+        // Plugin Manager
+        $this->container->pluginManager = $this->createMock(PluginManager::class);
+
+        // BookmarkService
+        $this->container->bookmarkService = $this->createMock(BookmarkServiceInterface::class);
+
+        // Formatter
+        $this->container->formatterFactory = $this->createMock(FormatterFactory::class);
+        $this->container->formatterFactory
+            ->method('getFormatter')
+            ->willReturnCallback(function (): BookmarkFormatter {
+                return new BookmarkRawFormatter($this->container->conf, true);
+            })
+        ;
+
+        // CacheManager
+        $this->container->pageCacheManager = $this->createMock(PageCacheManager::class);
+
+        // SessionManager
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+
+        // $_SERVER
+        $this->container->environment = [
+            'SERVER_NAME' => 'shaarli',
+            'SERVER_PORT' => '80',
+            'REQUEST_URI' => '/subfolder/daily-rss',
+            'REMOTE_ADDR' => '1.2.3.4',
+            'SCRIPT_NAME' => '/subfolder/index.php',
+        ];
+
+        $this->container->basePath = '/subfolder';
+    }
+
+    /**
+     * Pass a reference of an array which will be populated by `pageBuilder->assign` calls during execution.
+     *
+     * @param mixed $variables Array reference to populate.
+     */
+    protected function assignTemplateVars(array &$variables): void
+    {
+        $this->container->pageBuilder
+            ->method('assign')
+            ->willReturnCallback(function ($key, $value) use (&$variables) {
+                $variables[$key] = $value;
+
+                return $this;
+            })
+        ;
+    }
+
+    protected static function generateString(int $length): string
+    {
+        // bin2hex(random_bytes) generates string twice as long as given parameter
+        $length = (int) ceil($length / 2);
+
+        return bin2hex(random_bytes($length));
+    }
+
+    /**
+     * Force to be used in PHPUnit context.
+     */
+    protected abstract function isInTestsContext(): bool;
+}
diff --git a/tests/front/controller/visitor/InstallControllerTest.php b/tests/front/controller/visitor/InstallControllerTest.php
new file mode 100644 (file)
index 0000000..2105ed7
--- /dev/null
@@ -0,0 +1,304 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\AlreadyInstalledException;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class InstallControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    const MOCK_FILE = '.tmp';
+
+    /** @var InstallController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('getConfigFileExt')->willReturn(static::MOCK_FILE);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'resource.raintpl_tpl') {
+                return '.';
+            }
+
+            return $default ?? $key;
+        });
+
+        $this->controller = new InstallController($this->container);
+    }
+
+    protected function tearDown(): void
+    {
+        if (file_exists(static::MOCK_FILE)) {
+            unlink(static::MOCK_FILE);
+        }
+    }
+
+    /**
+     * Test displaying install page with valid session.
+     */
+    public function testInstallIndexWithValidSession(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->willReturnCallback(function (string $key, $default) {
+                return $key === 'session_tested' ? 'Working' : $default;
+            })
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('install', (string) $result->getBody());
+
+        static::assertIsArray($assignedVariables['continents']);
+        static::assertSame('Africa', $assignedVariables['continents'][0]);
+        static::assertSame('UTC', $assignedVariables['continents']['selected']);
+
+        static::assertIsArray($assignedVariables['cities']);
+        static::assertSame(['continent' => 'Africa', 'city' => 'Abidjan'], $assignedVariables['cities'][0]);
+        static::assertSame('UTC', $assignedVariables['continents']['selected']);
+
+        static::assertIsArray($assignedVariables['languages']);
+        static::assertSame('Automatic', $assignedVariables['languages']['auto']);
+        static::assertSame('French', $assignedVariables['languages']['fr']);
+
+        static::assertSame(PHP_VERSION, $assignedVariables['php_version']);
+        static::assertArrayHasKey('php_has_reached_eol', $assignedVariables);
+        static::assertArrayHasKey('php_eol', $assignedVariables);
+        static::assertArrayHasKey('php_extensions', $assignedVariables);
+        static::assertArrayHasKey('permissions', $assignedVariables);
+        static::assertEmpty($assignedVariables['permissions']);
+
+        static::assertSame('Install Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Instantiate the install controller with an existing config file: exception.
+     */
+    public function testInstallWithExistingConfigFile(): void
+    {
+        $this->expectException(AlreadyInstalledException::class);
+
+        touch(static::MOCK_FILE);
+
+        $this->controller = new InstallController($this->container);
+    }
+
+    /**
+     * Call controller without session yet defined, redirect to test session install page.
+     */
+    public function testInstallRedirectToSessionTest(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(InstallController::SESSION_TEST_KEY, InstallController::SESSION_TEST_VALUE)
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/install/session-test', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Call controller in session test mode: valid session then redirect to install page.
+     */
+    public function testInstallSessionTestValid(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(InstallController::SESSION_TEST_KEY)
+            ->willReturn(InstallController::SESSION_TEST_VALUE)
+        ;
+
+        $result = $this->controller->sessionTest($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/install', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Call controller in session test mode: invalid session then redirect to error page.
+     */
+    public function testInstallSessionTestError(): void
+    {
+        $assignedVars = [];
+        $this->assignTemplateVars($assignedVars);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(InstallController::SESSION_TEST_KEY)
+            ->willReturn('KO')
+        ;
+
+        $result = $this->controller->sessionTest($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('error', (string) $result->getBody());
+        static::assertStringStartsWith(
+            '<pre>Sessions do not seem to work correctly on your server',
+            $assignedVars['message']
+        );
+    }
+
+    /**
+     * Test saving valid data from install form. Also initialize datastore.
+     */
+    public function testSaveInstallValid(): void
+    {
+        $providedParameters = [
+            'continent' => 'Europe',
+            'city' => 'Berlin',
+            'setlogin' => 'bob',
+            'setpassword' => 'password',
+            'title' => 'Shaarli',
+            'language' => 'fr',
+            'updateCheck' => true,
+            'enableApi' => true,
+        ];
+
+        $expectedSettings = [
+            'general.timezone' => 'Europe/Berlin',
+            'credentials.login' => 'bob',
+            'credentials.salt' => '_NOT_EMPTY',
+            'credentials.hash' => '_NOT_EMPTY',
+            'general.title' => 'Shaarli',
+            'translation.language' => 'en',
+            'updates.check_updates' => true,
+            'api.enabled' => true,
+            'api.secret' => '_NOT_EMPTY',
+            'general.header_link' => '/subfolder',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($providedParameters) {
+            return $providedParameters[$key] ?? null;
+        });
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf
+            ->method('get')
+            ->willReturnCallback(function (string $key, $value) {
+                if ($key === 'credentials.login') {
+                    return 'bob';
+                } elseif ($key === 'credentials.salt') {
+                    return 'salt';
+                }
+
+                return $value;
+            })
+        ;
+        $this->container->conf
+            ->expects(static::exactly(count($expectedSettings)))
+            ->method('set')
+            ->willReturnCallback(function (string $key, $value) use ($expectedSettings) {
+                if ($expectedSettings[$key] ?? null === '_NOT_EMPTY') {
+                    static::assertNotEmpty($value);
+                } else {
+                    static::assertSame($expectedSettings[$key], $value);
+                }
+            })
+        ;
+        $this->container->conf->expects(static::once())->method('write');
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES)
+        ;
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/login', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test default settings (timezone and title).
+     * Also check that bookmarks are not initialized if
+     */
+    public function testSaveInstallDefaultValues(): void
+    {
+        $confSettings = [];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf->method('set')->willReturnCallback(function (string $key, $value) use (&$confSettings) {
+            $confSettings[$key] = $value;
+        });
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/login', $result->getHeader('location')[0]);
+
+        static::assertSame('UTC', $confSettings['general.timezone']);
+        static::assertSame('Shared bookmarks on http://shaarli/subfolder/', $confSettings['general.title']);
+    }
+
+    /**
+     * Same test  as testSaveInstallDefaultValues() but for an instance install in root directory.
+     */
+    public function testSaveInstallDefaultValuesWithoutSubfolder(): void
+    {
+        $confSettings = [];
+
+        $this->container->environment = [
+            'SERVER_NAME' => 'shaarli',
+            'SERVER_PORT' => '80',
+            'REQUEST_URI' => '/install',
+            'REMOTE_ADDR' => '1.2.3.4',
+            'SCRIPT_NAME' => '/index.php',
+        ];
+
+        $this->container->basePath = '';
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf->method('set')->willReturnCallback(function (string $key, $value) use (&$confSettings) {
+            $confSettings[$key] = $value;
+        });
+
+        $result = $this->controller->save($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/login', $result->getHeader('location')[0]);
+
+        static::assertSame('UTC', $confSettings['general.timezone']);
+        static::assertSame('Shared bookmarks on http://shaarli/', $confSettings['general.title']);
+    }
+}
diff --git a/tests/front/controller/visitor/LoginControllerTest.php b/tests/front/controller/visitor/LoginControllerTest.php
new file mode 100644 (file)
index 0000000..00d9eab
--- /dev/null
@@ -0,0 +1,404 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\LoginBannedException;
+use Shaarli\Front\Exception\WrongTokenException;
+use Shaarli\Render\TemplatePage;
+use Shaarli\Security\CookieManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class LoginControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var LoginController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->cookieManager = $this->createMock(CookieManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(true);
+
+        $this->controller = new LoginController($this->container);
+    }
+
+    /**
+     * Test displaying login form with valid parameters.
+     */
+    public function testValidControllerInvoke(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) {
+                return 'returnurl' === $key ? '> referer' : null;
+            })
+        ;
+        $response = new Response();
+
+        $assignedVariables = [];
+        $this->container->pageBuilder
+            ->method('assign')
+            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
+                $assignedVariables[$key] = $value;
+
+                return $this;
+            })
+        ;
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame(TemplatePage::LOGIN, (string) $result->getBody());
+
+        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
+        static::assertSame(true, $assignedVariables['remember_user_default']);
+        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test displaying login form with username defined in the request.
+     */
+    public function testValidControllerInvokeWithUserName(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => '> referer'];
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key, $default) {
+                if ('login' === $key) {
+                    return 'myUser>';
+                }
+
+                return $default;
+            })
+        ;
+        $response = new Response();
+
+        $assignedVariables = [];
+        $this->container->pageBuilder
+            ->method('assign')
+            ->willReturnCallback(function ($key, $value) use (&$assignedVariables) {
+                $assignedVariables[$key] = $value;
+
+                return $this;
+            })
+        ;
+
+        $this->container->loginManager->expects(static::once())->method('canLogin')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('loginform', (string) $result->getBody());
+
+        static::assertSame('myUser&gt;', $assignedVariables['username']);
+        static::assertSame('&gt; referer', $assignedVariables['returnurl']);
+        static::assertSame(true, $assignedVariables['remember_user_default']);
+        static::assertSame('Login - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test displaying login page while being logged in.
+     */
+    public function testLoginControllerWhileLoggedIn(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->expects(static::once())->method('isLoggedIn')->willReturn(true);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('Location'));
+    }
+
+    /**
+     * Test displaying login page with open shaarli configured: redirect to homepage.
+     */
+    public function testLoginControllerOpenShaarli(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $conf = $this->createMock(ConfigManager::class);
+        $conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'security.open_shaarli') {
+                return true;
+            }
+            return $default;
+        });
+        $this->container->conf = $conf;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('Location'));
+    }
+
+    /**
+     * Test displaying login page while being banned.
+     */
+    public function testLoginControllerWhileBanned(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(false);
+        $this->container->loginManager->method('canLogin')->willReturn(false);
+
+        $this->expectException(LoginBannedException::class);
+
+        $this->controller->index($request, $response);
+    }
+
+    /**
+     * Test processing login with valid parameters.
+     */
+    public function testProcessLoginWithValidParameters(): void
+    {
+        $parameters = [
+            'login' => 'bob',
+            'password' => 'pass',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleSuccessfulLogin');
+        $this->container->loginManager
+            ->expects(static::once())
+            ->method('checkCredentials')
+            ->with('1.2.3.4', 'bob', 'pass')
+            ->willReturn(true)
+        ;
+        $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
+
+        $this->container->sessionManager->expects(static::never())->method('extendSession');
+        $this->container->sessionManager->expects(static::once())->method('destroy');
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('cookieParameters')
+            ->with(0, '/subfolder/', 'shaarli')
+        ;
+        $this->container->sessionManager->expects(static::once())->method('start');
+        $this->container->sessionManager->expects(static::once())->method('regenerateId')->with(true);
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with return URL.
+     */
+    public function testProcessLoginWithReturnUrl(): void
+    {
+        $parameters = [
+            'returnurl' => 'http://shaarli/subfolder/admin/shaare',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::once())->method('checkCredentials')->willReturn(true);
+        $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/admin/shaare', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with remember me session enabled.
+     */
+    public function testProcessLoginLongLastingSession(): void
+    {
+        $parameters = [
+            'longlastingsession' => true,
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::once())->method('checkCredentials')->willReturn(true);
+        $this->container->loginManager->method('getStaySignedInToken')->willReturn(bin2hex(random_bytes(8)));
+
+        $this->container->sessionManager->expects(static::once())->method('destroy');
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('cookieParameters')
+            ->with(42, '/subfolder/', 'shaarli')
+        ;
+        $this->container->sessionManager->expects(static::once())->method('start');
+        $this->container->sessionManager->expects(static::once())->method('regenerateId')->with(true);
+        $this->container->sessionManager->expects(static::once())->method('extendSession')->willReturn(42);
+
+        $this->container->cookieManager = $this->createMock(CookieManager::class);
+        $this->container->cookieManager
+            ->expects(static::once())
+            ->method('setCookieParameter')
+            ->willReturnCallback(function (string $name): CookieManager {
+                static::assertSame(CookieManager::STAY_SIGNED_IN, $name);
+
+                return $this->container->cookieManager;
+            })
+        ;
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with invalid credentials
+     */
+    public function testProcessLoginWrongCredentials(): void
+    {
+        $parameters = [
+            'returnurl' => 'http://shaarli/subfolder/admin/shaare',
+        ];
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($parameters) {
+                return $parameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(true);
+        $this->container->loginManager->expects(static::once())->method('handleFailedLogin');
+        $this->container->loginManager->expects(static::once())->method('checkCredentials')->willReturn(false);
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_ERROR_MESSAGES, ['Wrong login/password.'])
+        ;
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame(TemplatePage::LOGIN, (string) $result->getBody());
+    }
+
+    /**
+     * Test processing login with wrong token
+     */
+    public function testProcessLoginWrongToken(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager = $this->createMock(SessionManager::class);
+        $this->container->sessionManager->method('checkToken')->willReturn(false);
+
+        $this->expectException(WrongTokenException::class);
+
+        $this->controller->login($request, $response);
+    }
+
+    /**
+     * Test processing login with wrong token
+     */
+    public function testProcessLoginAlreadyLoggedIn(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+        $this->container->loginManager->expects(static::never())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::never())->method('handleFailedLogin');
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login with wrong token
+     */
+    public function testProcessLoginInOpenShaarli(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $value) {
+            return 'security.open_shaarli' === $key ? true : $value;
+        });
+
+        $this->container->loginManager->expects(static::never())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::never())->method('handleFailedLogin');
+
+        $result = $this->controller->login($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/', $result->getHeader('location')[0]);
+    }
+
+    /**
+     * Test processing login while being banned
+     */
+    public function testProcessLoginWhileBanned(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->loginManager->method('canLogin')->willReturn(false);
+        $this->container->loginManager->expects(static::never())->method('handleSuccessfulLogin');
+        $this->container->loginManager->expects(static::never())->method('handleFailedLogin');
+
+        $this->expectException(LoginBannedException::class);
+
+        $this->controller->login($request, $response);
+    }
+}
diff --git a/tests/front/controller/visitor/OpenSearchControllerTest.php b/tests/front/controller/visitor/OpenSearchControllerTest.php
new file mode 100644 (file)
index 0000000..42d876c
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class OpenSearchControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var OpenSearchController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new OpenSearchController($this->container);
+    }
+
+    public function testOpenSearchController(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString(
+            'application/opensearchdescription+xml',
+            $result->getHeader('Content-Type')[0]
+        );
+        static::assertSame('opensearch', (string) $result->getBody());
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['serverurl']);
+    }
+}
diff --git a/tests/front/controller/visitor/PictureWallControllerTest.php b/tests/front/controller/visitor/PictureWallControllerTest.php
new file mode 100644 (file)
index 0000000..b868231
--- /dev/null
@@ -0,0 +1,123 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Front\Exception\ThumbnailsDisabledException;
+use Shaarli\TestCase;
+use Shaarli\Thumbnailer;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PictureWallControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var PictureWallController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new PictureWallController($this->container);
+    }
+
+    public function testValidControllerInvokeDefault(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->expects(static::once())->method('getQueryParams')->willReturn([]);
+        $response = new Response();
+
+        // ConfigManager: thumbnails are enabled
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'thumbnails.mode') {
+                return Thumbnailer::MODE_COMMON;
+            }
+
+            return $default;
+        });
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        // Links dataset: 2 links with thumbnails
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('search')
+            ->willReturnCallback(function (array $parameters, ?string $visibility): array {
+                // Visibility is set through the container, not the call
+                static::assertNull($visibility);
+
+                // No query parameters
+                if (count($parameters) === 0) {
+                    return [
+                        (new Bookmark())->setId(1)->setUrl('http://url.tld')->setThumbnail('thumb1'),
+                        (new Bookmark())->setId(2)->setUrl('http://url2.tld'),
+                        (new Bookmark())->setId(3)->setUrl('http://url3.tld')->setThumbnail('thumb2'),
+                    ];
+                }
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_picwall'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                if ('render_picwall' === $hook) {
+                    static::assertArrayHasKey('linksToDisplay', $data);
+                    static::assertCount(2, $data['linksToDisplay']);
+                    static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                    static::assertSame(3, $data['linksToDisplay'][1]['id']);
+                    static::assertArrayHasKey('loggedin', $param);
+                }
+
+                return $data;
+            });
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('picwall', (string) $result->getBody());
+        static::assertSame('Picture wall - Shaarli', $assignedVariables['pagetitle']);
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+
+        $link = $assignedVariables['linksToDisplay'][0];
+
+        static::assertSame(1, $link['id']);
+        static::assertSame('http://url.tld', $link['url']);
+        static::assertSame('thumb1', $link['thumbnail']);
+
+        $link = $assignedVariables['linksToDisplay'][1];
+
+        static::assertSame(3, $link['id']);
+        static::assertSame('http://url3.tld', $link['url']);
+        static::assertSame('thumb2', $link['thumbnail']);
+    }
+
+    public function testControllerWithThumbnailsDisabled(): void
+    {
+        $this->expectException(ThumbnailsDisabledException::class);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // ConfigManager: thumbnails are disabled
+        $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'thumbnails.mode') {
+                return Thumbnailer::MODE_NONE;
+            }
+
+            return $default;
+        });
+
+        $this->controller->index($request, $response);
+    }
+}
diff --git a/tests/front/controller/visitor/PublicSessionFilterControllerTest.php b/tests/front/controller/visitor/PublicSessionFilterControllerTest.php
new file mode 100644 (file)
index 0000000..7e3b00a
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class PublicSessionFilterControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var PublicSessionFilterController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new PublicSessionFilterController($this->container);
+    }
+
+    /**
+     * Link per page - Default call with valid parameter and a referer.
+     */
+    public function testLinksPerPage(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller/?searchtag=abc';
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('nb')->willReturn('8');
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_LINKS_PER_PAGE, 8)
+        ;
+
+        $result = $this->controller->linksPerPage($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Link per page - Invalid value, should use default value (20)
+     */
+    public function testLinksPerPageNotValid(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->with('nb')->willReturn('test');
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_LINKS_PER_PAGE, 20)
+        ;
+
+        $result = $this->controller->linksPerPage($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Untagged only - valid call
+     */
+    public function testUntaggedOnly(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller/?searchtag=abc';
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_UNTAGGED_ONLY, true)
+        ;
+
+        $result = $this->controller->untaggedOnly($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    /**
+     * Untagged only - toggle off
+     */
+    public function testUntaggedOnlyToggleOff(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller/?searchtag=abc';
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->container->sessionManager
+            ->method('getSessionParameter')
+            ->with(SessionManager::KEY_UNTAGGED_ONLY)
+            ->willReturn(true)
+        ;
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_UNTAGGED_ONLY, false)
+        ;
+
+        $result = $this->controller->untaggedOnly($request, $response);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/visitor/ShaarliVisitorControllerTest.php b/tests/front/controller/visitor/ShaarliVisitorControllerTest.php
new file mode 100644 (file)
index 0000000..935ec24
--- /dev/null
@@ -0,0 +1,246 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Class ShaarliControllerTest
+ *
+ * This class is used to test default behavior of ShaarliVisitorController abstract class.
+ * It uses a dummy non abstract controller.
+ */
+class ShaarliVisitorControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var LoginController */
+    protected $controller;
+
+    /** @var mixed[] List of variable assigned to the template */
+    protected $assignedValues;
+
+    /** @var Request */
+    protected $request;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new class($this->container) extends ShaarliVisitorController
+        {
+            public function assignView(string $key, $value): ShaarliVisitorController
+            {
+                return parent::assignView($key, $value);
+            }
+
+            public function render(string $template): string
+            {
+                return parent::render($template);
+            }
+
+            public function redirectFromReferer(
+                Request $request,
+                Response $response,
+                array $loopTerms = [],
+                array $clearParams = [],
+                string $anchor = null
+            ): Response {
+                return parent::redirectFromReferer($request, $response, $loopTerms, $clearParams, $anchor);
+            }
+        };
+        $this->assignedValues = [];
+
+        $this->request = $this->createMock(Request::class);
+    }
+
+    public function testAssignView(): void
+    {
+        $this->assignTemplateVars($this->assignedValues);
+
+        $self = $this->controller->assignView('variableName', 'variableValue');
+
+        static::assertInstanceOf(ShaarliVisitorController::class, $self);
+        static::assertSame('variableValue', $this->assignedValues['variableName']);
+    }
+
+    public function testRender(): void
+    {
+        $this->assignTemplateVars($this->assignedValues);
+
+        $this->container->bookmarkService
+            ->method('count')
+            ->willReturnCallback(function (string $visibility): int {
+                return $visibility === BookmarkFilter::$PRIVATE ? 5 : 10;
+            })
+        ;
+
+        $this->container->pluginManager
+            ->method('executeHooks')
+            ->willReturnCallback(function (string $hook, array &$data, array $params): array {
+                return $data[$hook] = $params;
+            });
+        $this->container->pluginManager->method('getErrors')->willReturn(['error']);
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn(true);
+
+        $render = $this->controller->render('templateName');
+
+        static::assertSame('templateName', $render);
+
+        static::assertSame(10, $this->assignedValues['linkcount']);
+        static::assertSame(5, $this->assignedValues['privateLinkcount']);
+        static::assertSame(['error'], $this->assignedValues['plugin_errors']);
+
+        static::assertSame('templateName', $this->assignedValues['plugins_includes']['render_includes']['target']);
+        static::assertTrue($this->assignedValues['plugins_includes']['render_includes']['loggedin']);
+        static::assertSame('templateName', $this->assignedValues['plugins_header']['render_header']['target']);
+        static::assertTrue($this->assignedValues['plugins_header']['render_header']['loggedin']);
+        static::assertSame('templateName', $this->assignedValues['plugins_footer']['render_footer']['target']);
+        static::assertTrue($this->assignedValues['plugins_footer']['render_footer']['loggedin']);
+    }
+
+    /**
+     * Test redirectFromReferer() - Default behaviour
+     */
+    public function testRedirectFromRefererDefault(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term not matched in the referer
+     */
+    public function testRedirectFromRefererWithUnmatchedLoopTerm(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its path -> redirect to default
+     */
+    public function testRedirectFromRefererWithMatchingLoopTermInPath(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope', 'controller']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its query parameters -> redirect to default
+     */
+    public function testRedirectFromRefererWithMatchingLoopTermInQueryParam(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope', 'other']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its query value
+     *                              -> we do not block redirection for query parameter values.
+     */
+    public function testRedirectFromRefererWithMatchingLoopTermInQueryValue(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['nope', 'param']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching the referer in its domain name
+     *                              -> we do not block redirection for shaarli's hosts
+     */
+    public function testRedirectFromRefererWithLoopTermInDomain(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['shaarli']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?query=param&other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - With a loop term matching a query parameter AND clear this query param
+     *                              -> the param should be cleared before checking if it matches the redir loop terms
+     */
+    public function testRedirectFromRefererWithMatchingClearedParam(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://shaarli/subfolder/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['query'], ['query']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/controller?other=2'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - From another domain -> we ignore the given referrer.
+     */
+    public function testRedirectExternalReferer(): void
+    {
+        $this->container->environment['HTTP_REFERER'] = 'http://other.domain.tld/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['query'], ['query']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    /**
+     * Test redirectFromReferer() - From another domain -> we ignore the given referrer.
+     */
+    public function testRedirectExternalRefererExplicitDomainName(): void
+    {
+        $this->container->environment['SERVER_NAME'] = 'my.shaarli.tld';
+        $this->container->environment['HTTP_REFERER'] = 'http://your.shaarli.tld/controller?query=param&other=2';
+
+        $response = new Response();
+
+        $result = $this->controller->redirectFromReferer($this->request, $response, ['query'], ['query']);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
diff --git a/tests/front/controller/visitor/TagCloudControllerTest.php b/tests/front/controller/visitor/TagCloudControllerTest.php
new file mode 100644 (file)
index 0000000..9305612
--- /dev/null
@@ -0,0 +1,381 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class TagCloudControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var TagCloudController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new TagCloudController($this->container);
+    }
+
+    /**
+     * Tag Cloud - default parameters
+     */
+    public function testValidCloudControllerInvokeDefault(): void
+    {
+        $allTags = [
+            'ghi' => 1,
+            'abc' => 3,
+            'def' => 12,
+        ];
+        $expectedOrder = ['abc', 'def', 'ghi'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function () use ($allTags): array {
+                return $allTags;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_tagcloud'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                if ('render_tagcloud' === $hook) {
+                    static::assertSame('', $data['search_tags']);
+                    static::assertCount(3, $data['tags']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->cloud($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.cloud', (string) $result->getBody());
+        static::assertSame('Tag cloud - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(3, $assignedVariables['tags']);
+        static::assertSame($expectedOrder, array_keys($assignedVariables['tags']));
+
+        foreach ($allTags as $tag => $count) {
+            static::assertArrayHasKey($tag, $assignedVariables['tags']);
+            static::assertSame($count, $assignedVariables['tags'][$tag]['count']);
+            static::assertGreaterThan(0, $assignedVariables['tags'][$tag]['size']);
+            static::assertLessThan(5, $assignedVariables['tags'][$tag]['size']);
+        }
+    }
+
+    /**
+     * Tag Cloud - Additional parameters:
+     *   - logged in
+     *   - visibility private
+     *   - search tags: `ghi` and `def` (note that filtered tags are not displayed anymore)
+     */
+    public function testValidCloudControllerInvokeWithParameters(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getQueryParam')
+            ->with()
+            ->willReturnCallback(function (string $key): ?string {
+                if ('searchtags' === $key) {
+                    return 'ghi def';
+                }
+
+                return null;
+            })
+        ;
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->loginManager->method('isLoggedin')->willReturn(true);
+        $this->container->sessionManager->expects(static::once())->method('getSessionParameter')->willReturn('private');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with(['ghi', 'def'], BookmarkFilter::$PRIVATE)
+            ->willReturnCallback(function (): array {
+                return ['abc' => 3];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_tagcloud'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+               if ('render_tagcloud' === $hook) {
+                   static::assertSame('ghi def', $data['search_tags']);
+                   static::assertCount(1, $data['tags']);
+
+                   static::assertArrayHasKey('loggedin', $param);
+               }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->cloud($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.cloud', (string) $result->getBody());
+        static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertCount(1, $assignedVariables['tags']);
+
+        static::assertArrayHasKey('abc', $assignedVariables['tags']);
+        static::assertSame(3, $assignedVariables['tags']['abc']['count']);
+        static::assertGreaterThan(0, $assignedVariables['tags']['abc']['size']);
+        static::assertLessThan(5, $assignedVariables['tags']['abc']['size']);
+    }
+
+    /**
+     * Tag Cloud - empty
+     */
+    public function testEmptyCloud(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function (array $parameters, ?string $visibility): array {
+                return [];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_tagcloud'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                if ('render_tagcloud' === $hook) {
+                    static::assertSame('', $data['search_tags']);
+                    static::assertCount(0, $data['tags']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->cloud($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.cloud', (string) $result->getBody());
+        static::assertSame('Tag cloud - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(0, $assignedVariables['tags']);
+    }
+
+    /**
+     * Tag List - Default sort is by usage DESC
+     */
+    public function testValidListControllerInvokeDefault(): void
+    {
+        $allTags = [
+            'def' => 12,
+            'abc' => 3,
+            'ghi' => 1,
+        ];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function () use ($allTags): array {
+                return $allTags;
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_taglist'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                if ('render_taglist' === $hook) {
+                    static::assertSame('', $data['search_tags']);
+                    static::assertCount(3, $data['tags']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->list($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.list', (string) $result->getBody());
+        static::assertSame('Tag list - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(3, $assignedVariables['tags']);
+
+        foreach ($allTags as $tag => $count) {
+            static::assertSame($count, $assignedVariables['tags'][$tag]);
+        }
+    }
+
+    /**
+     * Tag List - Additional parameters:
+     *   - logged in
+     *   - visibility private
+     *   - search tags: `ghi` and `def` (note that filtered tags are not displayed anymore)
+     *   - sort alphabetically
+     */
+    public function testValidListControllerInvokeWithParameters(): void
+    {
+        $request = $this->createMock(Request::class);
+        $request
+            ->method('getQueryParam')
+            ->with()
+            ->willReturnCallback(function (string $key): ?string {
+                if ('searchtags' === $key) {
+                    return 'ghi def';
+                } elseif ('sort' === $key) {
+                    return 'alpha';
+                }
+
+                return null;
+            })
+        ;
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->loginManager->method('isLoggedin')->willReturn(true);
+        $this->container->sessionManager->expects(static::once())->method('getSessionParameter')->willReturn('private');
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with(['ghi', 'def'], BookmarkFilter::$PRIVATE)
+            ->willReturnCallback(function (): array {
+                return ['abc' => 3];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_taglist'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                if ('render_taglist' === $hook) {
+                    static::assertSame('ghi def', $data['search_tags']);
+                    static::assertCount(1, $data['tags']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->list($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.list', (string) $result->getBody());
+        static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertCount(1, $assignedVariables['tags']);
+        static::assertSame(3, $assignedVariables['tags']['abc']);
+    }
+
+    /**
+     * Tag List - empty
+     */
+    public function testEmptyList(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->with([], null)
+            ->willReturnCallback(function (array $parameters, ?string $visibility): array {
+                return [];
+            })
+        ;
+
+        // Make sure that PluginManager hook is triggered
+        $this->container->pluginManager
+            ->expects(static::atLeastOnce())
+            ->method('executeHooks')
+            ->withConsecutive(['render_taglist'])
+            ->willReturnCallback(function (string $hook, array $data, array $param): array {
+                if ('render_taglist' === $hook) {
+                    static::assertSame('', $data['search_tags']);
+                    static::assertCount(0, $data['tags']);
+
+                    static::assertArrayHasKey('loggedin', $param);
+                }
+
+                return $data;
+            })
+        ;
+
+        $result = $this->controller->list($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('tag.list', (string) $result->getBody());
+        static::assertSame('Tag list - Shaarli', $assignedVariables['pagetitle']);
+
+        static::assertSame('', $assignedVariables['search_tags']);
+        static::assertCount(0, $assignedVariables['tags']);
+    }
+}
diff --git a/tests/front/controller/visitor/TagControllerTest.php b/tests/front/controller/visitor/TagControllerTest.php
new file mode 100644 (file)
index 0000000..750ea02
--- /dev/null
@@ -0,0 +1,215 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Visitor;
+
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class TagControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var TagController */    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new TagController($this->container);
+    }
+
+    public function testAddTagWithReferer(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithRefererAndExistingSearch(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithoutRefererAndExistingSearch(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/?searchtags=abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagRemoveLegacyQueryParam(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def&addtag=abc'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagResetPagination(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def&page=12'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithRefererAndEmptySearch(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags='];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['newTag' => 'abc'];
+
+        $result = $this->controller->addTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=abc'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithoutNewTagWithReferer(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->addTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def'], $result->getHeader('location'));
+    }
+
+    public function testAddTagWithoutNewTagWithoutReferer(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->addTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutMatchingTag(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtags=def'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['tag' => 'abc'];
+
+        $result = $this->controller->removeTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtags=def'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutTagsearch(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['tag' => 'abc'];
+
+        $result = $this->controller->removeTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutReferer(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $tags = ['tag' => 'abc'];
+
+        $result = $this->controller->removeTag($request, $response, $tags);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutTag(): void
+    {
+        $this->container->environment = ['HTTP_REFERER' => 'http://shaarli/controller/?searchtag=abc'];
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->removeTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/controller/?searchtag=abc'], $result->getHeader('location'));
+    }
+
+    public function testRemoveTagWithoutTagWithoutReferer(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $result = $this->controller->removeTag($request, $response, []);
+
+        static::assertInstanceOf(Response::class, $result);
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/'], $result->getHeader('location'));
+    }
+}
similarity index 77%
rename from tests/ApplicationUtilsTest.php
rename to tests/helper/ApplicationUtilsTest.php
index 15388970a0b0680e4a2e0ecb7fbf5d7d6bb2d90d..654857b944e7925cfd81b1cd915600495fea9f2a 100644 (file)
@@ -1,14 +1,15 @@
 <?php
-namespace Shaarli;
+namespace Shaarli\Helper;
 
 use Shaarli\Config\ConfigManager;
+use Shaarli\FakeApplicationUtils;
 
 require_once 'tests/utils/FakeApplicationUtils.php';
 
 /**
  * Unitary tests for Shaarli utilities
  */
-class ApplicationUtilsTest extends \PHPUnit\Framework\TestCase
+class ApplicationUtilsTest extends \Shaarli\TestCase
 {
     protected static $testUpdateFile = 'sandbox/update.txt';
     protected static $testVersion = '0.5.0';
@@ -17,7 +18,7 @@ class ApplicationUtilsTest extends \PHPUnit\Framework\TestCase
     /**
      * Reset test data for each test
      */
-    public function setUp()
+    protected function setUp(): void
     {
         FakeApplicationUtils::$VERSION_CODE = '';
         if (file_exists(self::$testUpdateFile)) {
@@ -28,7 +29,7 @@ class ApplicationUtilsTest extends \PHPUnit\Framework\TestCase
     /**
      * Remove test version file if it exists
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         if (is_file('sandbox/version.php')) {
             unlink('sandbox/version.php');
@@ -144,11 +145,12 @@ class ApplicationUtilsTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test update checks - invalid Git branch
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Invalid branch selected for updates/
      */
     public function testCheckUpdateInvalidGitBranch()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Invalid branch selected for updates/');
+
         ApplicationUtils::checkUpdate('', 'null', 0, true, true, 'unstable');
     }
 
@@ -260,21 +262,23 @@ class ApplicationUtilsTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Check a unsupported PHP version
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Your PHP version is obsolete/
      */
     public function testCheckSupportedPHPVersion51()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Your PHP version is obsolete/');
+
         $this->assertTrue(ApplicationUtils::checkPHPVersion('5.3', '5.1.0'));
     }
 
     /**
      * Check another unsupported PHP version
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Your PHP version is obsolete/
      */
     public function testCheckSupportedPHPVersion52()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Your PHP version is obsolete/');
+
         $this->assertTrue(ApplicationUtils::checkPHPVersion('5.3', '5.2'));
     }
 
@@ -336,6 +340,35 @@ class ApplicationUtilsTest extends \PHPUnit\Framework\TestCase
         );
     }
 
+    /**
+     * Checks resource permissions in minimal mode.
+     */
+    public function testCheckCurrentResourcePermissionsErrorsMinimalMode(): void
+    {
+        $conf = new ConfigManager('');
+        $conf->set('resource.thumbnails_cache', 'null/cache');
+        $conf->set('resource.config', 'null/data/config.php');
+        $conf->set('resource.data_dir', 'null/data');
+        $conf->set('resource.datastore', 'null/data/store.php');
+        $conf->set('resource.ban_file', 'null/data/ipbans.php');
+        $conf->set('resource.log', 'null/data/log.txt');
+        $conf->set('resource.page_cache', 'null/pagecache');
+        $conf->set('resource.raintpl_tmp', 'null/tmp');
+        $conf->set('resource.raintpl_tpl', 'null/tpl');
+        $conf->set('resource.raintpl_theme', 'null/tpl/default');
+        $conf->set('resource.update_check', 'null/data/lastupdatecheck.txt');
+
+        static::assertSame(
+            [
+                '"null/tpl" directory is not readable',
+                '"null/tpl/default" directory is not readable',
+                '"null/tmp" directory is not readable',
+                '"null/tmp" directory is not writable'
+            ],
+            ApplicationUtils::checkResourcePermissions($conf, true)
+        );
+    }
+
     /**
      * Check update with 'dev' as curent version (master branch).
      * It should always return false.
@@ -346,4 +379,37 @@ class ApplicationUtilsTest extends \PHPUnit\Framework\TestCase
             ApplicationUtils::checkUpdate('dev', self::$testUpdateFile, 100, true, true)
         );
     }
+
+    /**
+     * Basic test of getPhpExtensionsRequirement()
+     */
+    public function testGetPhpExtensionsRequirementSimple(): void
+    {
+        static::assertCount(8, ApplicationUtils::getPhpExtensionsRequirement());
+        static::assertSame([
+            'name' => 'json',
+            'required' => true,
+            'desc' => 'Configuration parsing',
+            'loaded' => true,
+        ], ApplicationUtils::getPhpExtensionsRequirement()[0]);
+    }
+
+    /**
+     * Test getPhpEol with a known version: 7.4 -> 2022
+     */
+    public function testGetKnownPhpEol(): void
+    {
+        static::assertSame('2022-11-28', ApplicationUtils::getPhpEol('7.4.7'));
+    }
+
+    /**
+     * Test getPhpEol with an unknown version: 7.4 -> 2022
+     */
+    public function testGetUnknownPhpEol(): void
+    {
+        static::assertSame(
+            (((int) (new \DateTime())->format('Y')) + 2) . (new \DateTime())->format('-m-d'),
+            ApplicationUtils::getPhpEol('7.51.34')
+        );
+    }
 }
diff --git a/tests/helper/DailyPageHelperTest.php b/tests/helper/DailyPageHelperTest.php
new file mode 100644 (file)
index 0000000..e037849
--- /dev/null
@@ -0,0 +1,262 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Helper;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+
+class DailyPageHelperTest extends TestCase
+{
+    /**
+     * @dataProvider getRequestedTypes
+     */
+    public function testExtractRequestedType(array $queryParams, string $expectedType): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function ($key) use ($queryParams): ?string {
+            return $queryParams[$key] ?? null;
+        });
+
+        $type = DailyPageHelper::extractRequestedType($request);
+
+        static::assertSame($type, $expectedType);
+    }
+
+    /**
+     * @dataProvider getRequestedDateTimes
+     */
+    public function testExtractRequestedDateTime(
+        string $type,
+        string $input,
+        ?Bookmark $bookmark,
+        \DateTimeInterface $expectedDateTime,
+        string $compareFormat = 'Ymd'
+    ): void {
+        $dateTime = DailyPageHelper::extractRequestedDateTime($type, $input, $bookmark);
+
+        static::assertSame($dateTime->format($compareFormat), $expectedDateTime->format($compareFormat));
+    }
+
+    public function testExtractRequestedDateTimeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::extractRequestedDateTime('nope', null, null);
+    }
+
+    /**
+     * @dataProvider getFormatsByType
+     */
+    public function testGetFormatByType(string $type, string $expectedFormat): void
+    {
+        $format = DailyPageHelper::getFormatByType($type);
+
+        static::assertSame($expectedFormat, $format);
+    }
+
+    public function testGetFormatByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getFormatByType('nope');
+    }
+
+    /**
+     * @dataProvider getStartDatesByType
+     */
+    public function testGetStartDatesByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        \DateTimeInterface $expectedDateTime
+    ): void {
+        $startDateTime = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
+
+        static::assertEquals($expectedDateTime, $startDateTime);
+    }
+
+    public function testGetStartDatesByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getStartDateTimeByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getEndDatesByType
+     */
+    public function testGetEndDatesByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        \DateTimeInterface $expectedDateTime
+    ): void {
+        $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
+
+        static::assertEquals($expectedDateTime, $endDateTime);
+    }
+
+    public function testGetEndDatesByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getEndDateTimeByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getDescriptionsByType
+     */
+    public function testGeDescriptionsByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        string $expectedDescription
+    ): void {
+        $description = DailyPageHelper::getDescriptionByType($type, $dateTime);
+
+        static::assertEquals($expectedDescription, $description);
+    }
+
+    public function getDescriptionByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getDescriptionByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getRssLengthsByType
+     */
+    public function testGeRssLengthsByType(string $type): void {
+        $length = DailyPageHelper::getRssLengthByType($type);
+
+        static::assertIsInt($length);
+    }
+
+    public function testGeRssLengthsByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getRssLengthByType('nope');
+    }
+
+    /**
+     * Data provider for testExtractRequestedType() test method.
+     */
+    public function getRequestedTypes(): array
+    {
+        return [
+            [['month' => null], DailyPageHelper::DAY],
+            [['month' => ''], DailyPageHelper::MONTH],
+            [['month' => 'content'], DailyPageHelper::MONTH],
+            [['week' => null], DailyPageHelper::DAY],
+            [['week' => ''], DailyPageHelper::WEEK],
+            [['week' => 'content'], DailyPageHelper::WEEK],
+            [['day' => null], DailyPageHelper::DAY],
+            [['day' => ''], DailyPageHelper::DAY],
+            [['day' => 'content'], DailyPageHelper::DAY],
+        ];
+    }
+
+    /**
+     * Data provider for testExtractRequestedDateTime() test method.
+     */
+    public function getRequestedDateTimes(): array
+    {
+        return [
+            [DailyPageHelper::DAY, '20201013', null, new \DateTime('2020-10-13')],
+            [
+                DailyPageHelper::DAY,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                $date,
+            ],
+            [DailyPageHelper::DAY, '', null, new \DateTime()],
+            [DailyPageHelper::WEEK, '202030', null, new \DateTime('2020-07-20')],
+            [
+                DailyPageHelper::WEEK,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                new \DateTime('2020-10-13'),
+            ],
+            [DailyPageHelper::WEEK, '', null, new \DateTime(), 'Ym'],
+            [DailyPageHelper::MONTH, '202008', null, new \DateTime('2020-08-01'), 'Ym'],
+            [
+                DailyPageHelper::MONTH,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                new \DateTime('2020-10-13'),
+                'Ym'
+            ],
+            [DailyPageHelper::MONTH, '', null, new \DateTime(), 'Ym'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetFormatByType() test method.
+     */
+    public function getFormatsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, 'Ymd'],
+            [DailyPageHelper::WEEK, 'YW'],
+            [DailyPageHelper::MONTH, 'Ym'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetStartDatesByType() test method.
+     */
+    public function getStartDatesByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 00:00:00')],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-05 00:00:00')],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-01 00:00:00')],
+        ];
+    }
+
+    /**
+     * Data provider for testGetEndDatesByType() test method.
+     */
+    public function getEndDatesByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 23:59:59')],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-11 23:59:59')],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-31 23:59:59')],
+        ];
+    }
+
+    /**
+     * Data provider for testGetDescriptionsByType() test method.
+     */
+    public function getDescriptionsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, $date = new \DateTimeImmutable(), 'Today - ' . $date->format('F d, Y')],
+            [DailyPageHelper::DAY, $date = new \DateTimeImmutable('-1 day'), 'Yesterday - ' . $date->format('F d, Y')],
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October 9, 2020'],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), 'Week 41 (October 5, 2020)'],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October, 2020'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetDescriptionsByType() test method.
+     */
+    public function getRssLengthsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY],
+            [DailyPageHelper::WEEK],
+            [DailyPageHelper::MONTH],
+        ];
+    }
+}
diff --git a/tests/helper/FileUtilsTest.php b/tests/helper/FileUtilsTest.php
new file mode 100644 (file)
index 0000000..8035f79
--- /dev/null
@@ -0,0 +1,197 @@
+<?php
+
+namespace Shaarli\Helper;
+
+use Exception;
+use Shaarli\Exceptions\IOException;
+use Shaarli\TestCase;
+
+/**
+ * Class FileUtilsTest
+ *
+ * Test file utility class.
+ */
+class FileUtilsTest extends TestCase
+{
+    /**
+     * @var string Test file path.
+     */
+    protected static $file = 'sandbox/flat.db';
+
+    protected function setUp(): void
+    {
+        @mkdir('sandbox');
+        mkdir('sandbox/folder2');
+        touch('sandbox/file1');
+        touch('sandbox/file2');
+        mkdir('sandbox/folder1');
+        touch('sandbox/folder1/file1');
+        touch('sandbox/folder1/file2');
+        mkdir('sandbox/folder3');
+        mkdir('/tmp/shaarli-to-delete');
+    }
+
+    /**
+     * Delete test file after every test.
+     */
+    protected function tearDown(): void
+    {
+        @unlink(self::$file);
+
+        @unlink('sandbox/folder1/file1');
+        @unlink('sandbox/folder1/file2');
+        @rmdir('sandbox/folder1');
+        @unlink('sandbox/file1');
+        @unlink('sandbox/file2');
+        @rmdir('sandbox/folder2');
+        @rmdir('sandbox/folder3');
+        @rmdir('/tmp/shaarli-to-delete');
+    }
+
+    /**
+     * Test writeDB, then readDB with different data.
+     */
+    public function testSimpleWriteRead()
+    {
+        $data = ['blue', 'red'];
+        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+        $this->assertTrue(startsWith(file_get_contents(self::$file), '<?php /*'));
+        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+
+        $data = 0;
+        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+
+        $data = null;
+        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+
+        $data = false;
+        $this->assertTrue(FileUtils::writeFlatDB(self::$file, $data) > 0);
+        $this->assertEquals($data, FileUtils::readFlatDB(self::$file));
+    }
+
+    /**
+     * File not writable: raise an exception.
+     */
+    public function testWriteWithoutPermission()
+    {
+        $this->expectException(\Shaarli\Exceptions\IOException::class);
+        $this->expectExceptionMessage('Error accessing "sandbox/flat.db"');
+
+        touch(self::$file);
+        chmod(self::$file, 0440);
+        FileUtils::writeFlatDB(self::$file, null);
+    }
+
+    /**
+     * Folder non existent: raise an exception.
+     */
+    public function testWriteFolderDoesNotExist()
+    {
+        $this->expectException(\Shaarli\Exceptions\IOException::class);
+        $this->expectExceptionMessage('Error accessing "nopefolder"');
+
+        FileUtils::writeFlatDB('nopefolder/file', null);
+    }
+
+    /**
+     * Folder non writable: raise an exception.
+     */
+    public function testWriteFolderPermission()
+    {
+        $this->expectException(\Shaarli\Exceptions\IOException::class);
+        $this->expectExceptionMessage('Error accessing "sandbox"');
+
+        chmod(dirname(self::$file), 0555);
+        try {
+            FileUtils::writeFlatDB(self::$file, null);
+        } catch (Exception $e) {
+            chmod(dirname(self::$file), 0755);
+            throw $e;
+        }
+    }
+
+    /**
+     * Read non existent file, use default parameter.
+     */
+    public function testReadNotExistentFile()
+    {
+        $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
+        $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
+    }
+
+    /**
+     * Read non readable file, use default parameter.
+     */
+    public function testReadNotReadable()
+    {
+        touch(self::$file);
+        chmod(self::$file, 0220);
+        $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
+        $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
+    }
+
+    /**
+     * Test clearFolder with self delete and excluded files
+     */
+    public function testClearFolderSelfDeleteWithExclusion(): void
+    {
+        FileUtils::clearFolder('sandbox', true, ['file2']);
+
+        static::assertFileExists('sandbox/folder1/file2');
+        static::assertFileExists('sandbox/folder1');
+        static::assertFileExists('sandbox/file2');
+        static::assertFileExists('sandbox');
+
+        static::assertFileNotExists('sandbox/folder1/file1');
+        static::assertFileNotExists('sandbox/file1');
+        static::assertFileNotExists('sandbox/folder3');
+    }
+
+    /**
+     * Test clearFolder with self delete and excluded files
+     */
+    public function testClearFolderSelfDeleteWithoutExclusion(): void
+    {
+        FileUtils::clearFolder('sandbox', true);
+
+        static::assertFileNotExists('sandbox');
+    }
+
+    /**
+     * Test clearFolder with self delete and excluded files
+     */
+    public function testClearFolderNoSelfDeleteWithoutExclusion(): void
+    {
+        FileUtils::clearFolder('sandbox', false);
+
+        static::assertFileExists('sandbox');
+
+        // 2 because '.' and '..'
+        static::assertCount(2, new \DirectoryIterator('sandbox'));
+    }
+
+    /**
+     * Test clearFolder on a file instead of a folder
+     */
+    public function testClearFolderOnANonDirectory(): void
+    {
+        $this->expectException(IOException::class);
+        $this->expectExceptionMessage('Provided path is not a directory.');
+
+        FileUtils::clearFolder('sandbox/file1', false);
+    }
+
+    /**
+     * Test clearFolder on a file instead of a folder
+     */
+    public function testClearFolderOutsideOfShaarliDirectory(): void
+    {
+        $this->expectException(IOException::class);
+        $this->expectExceptionMessage('Trying to delete a folder outside of Shaarli path.');
+
+
+        FileUtils::clearFolder('/tmp/shaarli-to-delete', true);
+    }
+}
index 982e57e0b6ff37ae8b42b1a5a11707ddf0bb08b9..3a0fcf30c084ffdaa2d80c5232d7fcd9583112f9 100644 (file)
@@ -10,7 +10,7 @@ require_once 'application/http/HttpUtils.php';
 /**
  * Unitary tests for client_ip_id()
  */
-class ClientIpIdTest extends \PHPUnit\Framework\TestCase
+class ClientIpIdTest extends \Shaarli\TestCase
 {
     /**
      * Get a remote client ID based on its IP
index 3dc5bc9b03c38c9b0cad7763b2578b6e0ed017c5..a868ac0225d1c3e86a869f23dbe6f9bb5b23c284 100644 (file)
@@ -10,7 +10,7 @@ require_once 'application/http/HttpUtils.php';
 /**
  * Unitary tests for get_http_response()
  */
-class GetHttpUrlTest extends \PHPUnit\Framework\TestCase
+class GetHttpUrlTest extends \Shaarli\TestCase
 {
     /**
      * Get an invalid local URL
index fe3a639e296ed3fe6512c7186f43f9d49fb7694c..60cdb9927130a3689ba63145bd8d766c19ba1592 100644 (file)
@@ -7,7 +7,7 @@ require_once 'application/http/HttpUtils.php';
 /**
  * Unitary tests for getIpAddressFromProxy()
  */
-class GetIpAdressFromProxyTest extends \PHPUnit\Framework\TestCase
+class GetIpAdressFromProxyTest extends \Shaarli\TestCase
 {
 
     /**
index bcbe59cbf6abe493a392f429fb96f1c54cb2d46c..f283d119e388404bd4864f72675fb4d52cf334bd 100644 (file)
@@ -5,12 +5,14 @@
 
 namespace Shaarli\Http;
 
+use Shaarli\TestCase;
+
 require_once 'application/http/HttpUtils.php';
 
 /**
  * Unitary tests for index_url()
  */
-class IndexUrlTest extends \PHPUnit\Framework\TestCase
+class IndexUrlTest extends TestCase
 {
     /**
      * If on the main page, remove "index.php" from the URL resource
@@ -71,4 +73,68 @@ class IndexUrlTest extends \PHPUnit\Framework\TestCase
             )
         );
     }
+
+    /**
+     * The route is stored in REQUEST_URI
+     */
+    public function testPageUrlWithRoute()
+    {
+        $this->assertEquals(
+            'http://host.tld/picture-wall',
+            page_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/index.php',
+                    'REQUEST_URI' => '/picture-wall',
+                )
+            )
+        );
+
+        $this->assertEquals(
+            'http://host.tld/admin/picture-wall',
+            page_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/admin/index.php',
+                    'REQUEST_URI' => '/admin/picture-wall',
+                )
+            )
+        );
+    }
+
+    /**
+     * The route is stored in REQUEST_URI and subfolder
+     */
+    public function testPageUrlWithRouteUnderSubfolder()
+    {
+        $this->assertEquals(
+            'http://host.tld/subfolder/picture-wall',
+            page_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/subfolder/index.php',
+                    'REQUEST_URI' => '/subfolder/picture-wall',
+                )
+            )
+        );
+
+        $this->assertEquals(
+            'http://host.tld/subfolder/admin/picture-wall',
+            page_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/subfolder/admin/index.php',
+                    'REQUEST_URI' => '/subfolder/admin/picture-wall',
+                )
+            )
+        );
+    }
 }
diff --git a/tests/http/HttpUtils/IndexUrlTestWithConstant.php b/tests/http/HttpUtils/IndexUrlTestWithConstant.php
new file mode 100644 (file)
index 0000000..ecaea72
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use Shaarli\TestCase;
+
+/**
+ * Test index_url with SHAARLI_ROOT_URL defined to override automatic retrieval.
+ * This should stay in its dedicated class to make sure to not alter other tests of the suite.
+ */
+class IndexUrlTestWithConstant extends TestCase
+{
+    public static function setUpBeforeClass(): void
+    {
+        define('SHAARLI_ROOT_URL', 'http://other-host.tld/subfolder/');
+    }
+
+    /**
+     * The route is stored in REQUEST_URI and subfolder
+     */
+    public function testIndexUrlWithConstantDefined()
+    {
+        $this->assertEquals(
+            'http://other-host.tld/subfolder/',
+            index_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/index.php',
+                    'REQUEST_URI' => '/picture-wall',
+                )
+            )
+        );
+
+        $this->assertEquals(
+            'http://other-host.tld/subfolder/',
+            index_url(
+                array(
+                    'HTTPS' => 'Off',
+                    'SERVER_NAME' => 'host.tld',
+                    'SERVER_PORT' => '80',
+                    'SCRIPT_NAME' => '/admin/index.php',
+                    'REQUEST_URI' => '/admin/picture-wall',
+                )
+            )
+        );
+    }
+}
index 348956c645e6c40891ab42655237e75d7b810403..8b3fd93dbfb38130513c3257a2d64a17f8267a15 100644 (file)
@@ -9,7 +9,7 @@ require_once 'application/http/HttpUtils.php';
  *
  * Test class for is_https() function.
  */
-class IsHttpsTest extends \PHPUnit\Framework\TestCase
+class IsHttpsTest extends \Shaarli\TestCase
 {
 
     /**
index f199171684bad9694e6d3545b13b2a55c14d14ef..ebb3e617bd0584d6d1126104fa1e1c013095cf8b 100644 (file)
@@ -10,7 +10,7 @@ require_once 'application/http/HttpUtils.php';
 /**
  * Unitary tests for page_url()
  */
-class PageUrlTest extends \PHPUnit\Framework\TestCase
+class PageUrlTest extends \Shaarli\TestCase
 {
     /**
      * If on the main page, remove "index.php" from the URL resource
index 9caf104983ffd75427cb6a9a9c35cb1a29f042d0..339664e1720bc080bc0252669bbb4eb3baefb37b 100644 (file)
@@ -10,7 +10,7 @@ require_once 'application/http/HttpUtils.php';
 /**
  * Unitary tests for server_url()
  */
-class ServerUrlTest extends \PHPUnit\Framework\TestCase
+class ServerUrlTest extends \Shaarli\TestCase
 {
     /**
      * Detect if the server uses SSL
diff --git a/tests/http/MetadataRetrieverTest.php b/tests/http/MetadataRetrieverTest.php
new file mode 100644 (file)
index 0000000..3c9eaa0
--- /dev/null
@@ -0,0 +1,154 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Http;
+
+use PHPUnit\Framework\TestCase;
+use Shaarli\Config\ConfigManager;
+
+class MetadataRetrieverTest extends TestCase
+{
+    /** @var MetadataRetriever */
+    protected $retriever;
+
+    /** @var ConfigManager */
+    protected $conf;
+
+    /** @var HttpAccess */
+    protected $httpAccess;
+
+    public function setUp(): void
+    {
+        $this->conf = $this->createMock(ConfigManager::class);
+        $this->httpAccess = $this->createMock(HttpAccess::class);
+        $this->retriever = new MetadataRetriever($this->conf, $this->httpAccess);
+
+        $this->conf->method('get')->willReturnCallback(function (string $param, $default) {
+            return $default === null ? $param : $default;
+        });
+    }
+
+    /**
+     * Test metadata retrieve() with values returned
+     */
+    public function testFullRetrieval(): void
+    {
+        $url = 'https://domain.tld/link';
+        $remoteTitle = 'Remote Title ';
+        $remoteDesc = 'Sometimes the meta description is relevant.';
+        $remoteTags = 'abc def';
+        $remoteCharset = 'utf-8';
+
+        $expectedResult = [
+            'title' => $remoteTitle,
+            'description' => $remoteDesc,
+            'tags' => $remoteTags,
+        ];
+
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getCurlHeaderCallback')
+            ->willReturnCallback(
+                function (&$charset) use (
+                    $remoteCharset
+                ): callable {
+                    return function () use (
+                        &$charset,
+                        $remoteCharset
+                    ): void {
+                        $charset = $remoteCharset;
+                    };
+                }
+            )
+        ;
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getCurlDownloadCallback')
+            ->willReturnCallback(
+                function (&$charset, &$title, &$description, &$tags) use (
+                    $remoteCharset,
+                    $remoteTitle,
+                    $remoteDesc,
+                    $remoteTags
+                ): callable {
+                    return function () use (
+                        &$charset,
+                        &$title,
+                        &$description,
+                        &$tags,
+                        $remoteCharset,
+                        $remoteTitle,
+                        $remoteDesc,
+                        $remoteTags
+                    ): void {
+                        static::assertSame($remoteCharset, $charset);
+
+                        $title = $remoteTitle;
+                        $description = $remoteDesc;
+                        $tags = $remoteTags;
+                    };
+                }
+            )
+        ;
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getHttpResponse')
+            ->with($url, 30, 4194304)
+            ->willReturnCallback(function($url, $timeout, $maxBytes, $headerCallback, $dlCallback): void {
+                $headerCallback();
+                $dlCallback();
+            })
+        ;
+
+        $result = $this->retriever->retrieve($url);
+
+        static::assertSame($expectedResult, $result);
+    }
+
+    /**
+     * Test metadata retrieve() without any value
+     */
+    public function testEmptyRetrieval(): void
+    {
+        $url = 'https://domain.tld/link';
+
+        $expectedResult = [
+            'title' => null,
+            'description' => null,
+            'tags' => null,
+        ];
+
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getCurlDownloadCallback')
+            ->willReturnCallback(
+                function (): callable {
+                    return function (): void {};
+                }
+            )
+        ;
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getCurlHeaderCallback')
+            ->willReturnCallback(
+                function (): callable {
+                    return function (): void {};
+                }
+            )
+        ;
+        $this->httpAccess
+            ->expects(static::once())
+            ->method('getHttpResponse')
+            ->with($url, 30, 4194304)
+            ->willReturnCallback(function($url, $timeout, $maxBytes, $headerCallback, $dlCallback): void {
+                $headerCallback();
+                $dlCallback();
+            })
+        ;
+
+        $result = $this->retriever->retrieve($url);
+
+        static::assertSame($expectedResult, $result);
+    }
+}
index ae92f73a4d4a2a62a851b9d99d5b523bc9b303e7..c6b39c29753b47f23ac521ec824a4f9165e9e21e 100644 (file)
@@ -8,7 +8,7 @@ namespace Shaarli\Http;
 /**
  * Unitary tests for URL utilities
  */
-class UrlTest extends \PHPUnit\Framework\TestCase
+class UrlTest extends \Shaarli\TestCase
 {
     // base URL for tests
     protected static $baseUrl = 'http://domain.tld:3000';
index 6c4d124bed845f4d5205dda31df2f61b0a0d99d0..45690ecfb196e46b2aa90b110d8877d63611fb7b 100644 (file)
@@ -7,7 +7,7 @@ namespace Shaarli\Http;
 
 require_once 'application/http/UrlUtils.php';
 
-class CleanupUrlTest extends \PHPUnit\Framework\TestCase
+class CleanupUrlTest extends \Shaarli\TestCase
 {
     /**
      * @var string reference URL
index 2b97f7be649a4c120fb31c24f13ab19f70b7c3a1..18a9a5e50691f768cbe593f0bde8a83e233dc0cc 100644 (file)
@@ -7,7 +7,7 @@ namespace Shaarli\Http;
 
 require_once 'application/http/UrlUtils.php';
 
-class GetUrlSchemeTest extends \PHPUnit\Framework\TestCase
+class GetUrlSchemeTest extends \Shaarli\TestCase
 {
     /**
      * Get empty scheme string for empty UrlUtils
index 040d8c54ed5a1fca14490ec36ebdb2994005252f..5e6246cceef2c1550b0ba2ff5f707835965902e7 100644 (file)
@@ -10,7 +10,7 @@ require_once 'application/http/UrlUtils.php';
 /**
  * Unitary tests for unparse_url()
  */
-class UnparseUrlTest extends \PHPUnit\Framework\TestCase
+class UnparseUrlTest extends \Shaarli\TestCase
 {
     /**
      * Thanks for building nothing
index 69512dbd18787a2129b83c97662cf31fbba91f9a..b8a6baaa769118dda0272a996f6e5deccc57f729 100644 (file)
@@ -9,7 +9,7 @@ require_once 'application/http/UrlUtils.php';
  *
  * Test whitelist_protocols() function of UrlUtils.
  */
-class WhitelistProtocolsTest extends \PHPUnit\Framework\TestCase
+class WhitelistProtocolsTest extends \Shaarli\TestCase
 {
     /**
      * Test whitelist_protocols() on a note (relative URL).
index b8b7ca3a548358e98946e5d8833b748a40ecf27c..d84feed135366f3e122785839d24f2eb9dd93e2d 100644 (file)
@@ -12,7 +12,7 @@ use Shaarli\Config\ConfigManager;
  *
  * @package Shaarli
  */
-class LanguagesFrTest extends \PHPUnit\Framework\TestCase
+class LanguagesFrTest extends \Shaarli\TestCase
 {
     /**
      * @var string Config file path (without extension).
@@ -27,7 +27,7 @@ class LanguagesFrTest extends \PHPUnit\Framework\TestCase
     /**
      * Init: force French
      */
-    public function setUp()
+    protected function setUp(): void
     {
         $this->conf = new ConfigManager(self::$configFile);
         $this->conf->set('translation.language', 'fr');
@@ -36,7 +36,7 @@ class LanguagesFrTest extends \PHPUnit\Framework\TestCase
     /**
      * Reset the locale since gettext seems to mess with it, making it too long
      */
-    public static function tearDownAfterClass()
+    public static function tearDownAfterClass(): void
     {
         if (! empty(getenv('UT_LOCALE'))) {
             setlocale(LC_ALL, getenv('UT_LOCALE'));
diff --git a/tests/legacy/LegacyControllerTest.php b/tests/legacy/LegacyControllerTest.php
new file mode 100644 (file)
index 0000000..1a2549a
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Legacy;
+
+use Shaarli\Front\Controller\Visitor\FrontControllerMockHelper;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class LegacyControllerTest extends TestCase
+{
+    use FrontControllerMockHelper;
+
+    /** @var LegacyController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new LegacyController($this->container);
+    }
+
+    /**
+     * @dataProvider getProcessProvider
+     */
+    public function testProcess(string $legacyRoute, array $queryParameters, string $slimRoute, bool $isLoggedIn): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParams')->willReturn($queryParameters);
+        $request
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($queryParameters): ?string {
+                return $queryParameters[$key] ?? null;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->loginManager->method('isLoggedIn')->willReturn($isLoggedIn);
+
+        $result = $this->controller->process($request, $response, $legacyRoute);
+
+        static::assertSame('/subfolder' . $slimRoute, $result->getHeader('location')[0]);
+    }
+
+    public function testProcessNotFound(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->expectException(UnknowLegacyRouteException::class);
+
+        $this->controller->process($request, $response, 'nope');
+    }
+
+    /**
+     * @return array[] Parameters:
+     *                   - string legacyRoute
+     *                   - array  queryParameters
+     *                   - string slimRoute
+     *                   - bool   isLoggedIn
+     */
+    public function getProcessProvider(): array
+    {
+        return [
+            ['post', [], '/admin/shaare', true],
+            ['post', [], '/login?returnurl=/subfolder/admin/shaare', false],
+            ['post', ['title' => 'test'], '/admin/shaare?title=test', true],
+            ['post', ['title' => 'test'], '/login?returnurl=/subfolder/admin/shaare?title=test', false],
+            ['addlink', [], '/admin/add-shaare', true],
+            ['addlink', [], '/login?returnurl=/subfolder/admin/add-shaare', false],
+            ['login', [], '/login', true],
+            ['login', [], '/login', false],
+            ['logout', [], '/admin/logout', true],
+            ['logout', [], '/admin/logout', false],
+            ['picwall', [], '/picture-wall', false],
+            ['picwall', [], '/picture-wall', true],
+            ['tagcloud', [], '/tags/cloud', false],
+            ['tagcloud', [], '/tags/cloud', true],
+            ['taglist', [], '/tags/list', false],
+            ['taglist', [], '/tags/list', true],
+            ['daily', [], '/daily', false],
+            ['daily', [], '/daily', true],
+            ['daily', ['day' => '123456789', 'discard' => '1'], '/daily?day=123456789', false],
+            ['rss', [], '/feed/rss', false],
+            ['rss', [], '/feed/rss', true],
+            ['rss', ['search' => 'filter123', 'other' => 'param'], '/feed/rss?search=filter123&other=param', false],
+            ['atom', [], '/feed/atom', false],
+            ['atom', [], '/feed/atom', true],
+            ['atom', ['search' => 'filter123', 'other' => 'param'], '/feed/atom?search=filter123&other=param', false],
+            ['opensearch', [], '/open-search', false],
+            ['opensearch', [], '/open-search', true],
+            ['dailyrss', [], '/daily-rss', false],
+            ['dailyrss', [], '/daily-rss', true],
+            ['configure', [], '/login?returnurl=/subfolder/admin/configure', false],
+            ['configure', [], '/admin/configure', true],
+        ];
+    }
+}
index 17b2b0e6cc35d277c87ce9949bfa8a7ee525f27d..5c3fd425f968c856b31d1e76dc96c47666be55d7 100644 (file)
@@ -11,7 +11,6 @@ use ReflectionClass;
 use Shaarli;
 use Shaarli\Bookmark\Bookmark;
 
-require_once 'application/feed/Cache.php';
 require_once 'application/Utils.php';
 require_once 'tests/utils/ReferenceLinkDB.php';
 
@@ -19,7 +18,7 @@ require_once 'tests/utils/ReferenceLinkDB.php';
 /**
  * Unitary tests for LegacyLinkDBTest
  */
-class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
+class LegacyLinkDBTest extends \Shaarli\TestCase
 {
     // datastore to test write operations
     protected static $testDatastore = 'sandbox/datastore.php';
@@ -53,7 +52,7 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
      *
      * Resets test data for each test
      */
-    protected function setUp()
+    protected function setUp(): void
     {
         if (file_exists(self::$testDatastore)) {
             unlink(self::$testDatastore);
@@ -100,12 +99,12 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Attempt to instantiate a LinkDB whereas the datastore is not writable
-     *
-     * @expectedException              Shaarli\Exceptions\IOException
-     * @expectedExceptionMessageRegExp /Error accessing "null"/
      */
     public function testConstructDatastoreNotWriteable()
     {
+        $this->expectException(\Shaarli\Exceptions\IOException::class);
+        $this->expectExceptionMessageRegExp('/Error accessing "null"/');
+
         new LegacyLinkDB('null/store.db', false, false);
     }
 
@@ -258,7 +257,7 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
         $link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/');
 
         $this->assertNotEquals(false, $link);
-        $this->assertContains(
+        $this->assertContainsPolyfill(
             'A free software media publishing platform',
             $link['description']
         );
@@ -297,6 +296,10 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
                 // They need to be grouped with the first case found - order by date DESC: `sTuff`.
                 'sTuff' => 2,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ),
             self::$publicLinkDB->linksCountPerTag()
         );
@@ -325,6 +328,10 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
                 'tag3' => 1,
                 'tag4' => 1,
                 'ut' => 1,
+                'assurance' => 1,
+                'coding-style' => 1,
+                'quality' => 1,
+                'standards' => 1,
             ),
             self::$privateLinkDB->linksCountPerTag()
         );
@@ -421,22 +428,22 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test filterHash() with an invalid smallhash.
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterHashInValid1()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         $request = 'blabla';
         self::$publicLinkDB->filterHash($request);
     }
 
     /**
      * Test filterHash() with an empty smallhash.
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterHashInValid()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         self::$publicLinkDB->filterHash('');
     }
 
@@ -471,9 +478,9 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
 
         $res = $linkDB->renameTag('cartoon', 'Taz');
         $this->assertEquals(3, count($res));
-        $this->assertContains(' Taz ', $linkDB[4]['tags']);
-        $this->assertContains(' Taz ', $linkDB[1]['tags']);
-        $this->assertContains(' Taz ', $linkDB[0]['tags']);
+        $this->assertContainsPolyfill(' Taz ', $linkDB[4]['tags']);
+        $this->assertContainsPolyfill(' Taz ', $linkDB[1]['tags']);
+        $this->assertContainsPolyfill(' Taz ', $linkDB[0]['tags']);
     }
 
     /**
@@ -513,7 +520,7 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
 
         $res = $linkDB->renameTag('cartoon', null);
         $this->assertEquals(3, count($res));
-        $this->assertNotContains('cartoon', $linkDB[4]['tags']);
+        $this->assertNotContainsPolyfill('cartoon', $linkDB[4]['tags']);
     }
 
     /**
@@ -545,6 +552,10 @@ class LegacyLinkDBTest extends \PHPUnit\Framework\TestCase
             'tag4' => 1,
             'ut' => 1,
             'w3c' => 1,
+            'assurance' => 1,
+            'coding-style' => 1,
+            'quality' => 1,
+            'standards' => 1,
         ];
         $tags = self::$privateLinkDB->linksCountPerTag();
 
index ba9ec529bf208a1dcb6a5f98accea7ad0c909048..45d7754d34a82cd5ff22fe52f12663c268bd3c46 100644 (file)
@@ -10,7 +10,7 @@ use Shaarli\Legacy\LegacyLinkFilter;
 /**
  * Class LegacyLinkFilterTest.
  */
-class LegacyLinkFilterTest extends \PHPUnit\Framework\TestCase
+class LegacyLinkFilterTest extends \Shaarli\TestCase
 {
     /**
      * @var string Test datastore path.
@@ -34,7 +34,7 @@ class LegacyLinkFilterTest extends \PHPUnit\Framework\TestCase
     /**
      * Instantiate linkFilter with ReferenceLinkDB data.
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
         self::$refDB = new ReferenceLinkDB(true);
         self::$refDB->write(self::$testDatastore);
@@ -197,21 +197,23 @@ class LegacyLinkFilterTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Use an invalid date format
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Invalid date format/
      */
     public function testFilterInvalidDayWithChars()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Invalid date format/');
+
         self::$linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, 'Rainy day, dream away');
     }
 
     /**
      * Use an invalid date format
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Invalid date format/
      */
     public function testFilterInvalidDayDigits()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Invalid date format/');
+
         self::$linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, '20');
     }
 
@@ -235,11 +237,11 @@ class LegacyLinkFilterTest extends \PHPUnit\Framework\TestCase
 
     /**
      * No link for this hash
-     *
-     * @expectedException \Shaarli\Bookmark\Exception\BookmarkNotFoundException
      */
     public function testFilterUnknownSmallHash()
     {
+        $this->expectException(\Shaarli\Bookmark\Exception\BookmarkNotFoundException::class);
+
         self::$linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, 'Iblaah');
     }
 
index 7c429811bb917a3fabaacd3adcc36f7b615b81ac..f7391b867f593efd932aba8ae66f46ab6a1685fd 100644 (file)
@@ -20,7 +20,7 @@ require_once 'inc/rain.tpl.class.php';
  * Class UpdaterTest.
  * Runs unit tests against the updater class.
  */
-class LegacyUpdaterTest extends \PHPUnit\Framework\TestCase
+class LegacyUpdaterTest extends \Shaarli\TestCase
 {
     /**
      * @var string Path to test datastore.
@@ -40,7 +40,7 @@ class LegacyUpdaterTest extends \PHPUnit\Framework\TestCase
     /**
      * Executed before each test.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         copy('tests/utils/config/configJson.json.php', self::$configFile .'.json.php');
         $this->conf = new ConfigManager(self::$configFile);
@@ -80,23 +80,23 @@ class LegacyUpdaterTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test errors in UpdaterUtils::write_updates_file(): empty updates file.
-     *
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Updates file path is not set(.*)/
      */
     public function testWriteEmptyUpdatesFile()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
+
         UpdaterUtils::write_updates_file('', array('test'));
     }
 
     /**
      * Test errors in UpdaterUtils::write_updates_file(): not writable updates file.
-     *
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Unable to write(.*)/
      */
     public function testWriteUpdatesFileNotWritable()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Unable to write(.*)/');
+
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         touch($updatesFile);
         chmod($updatesFile, 0444);
@@ -161,11 +161,11 @@ class LegacyUpdaterTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test Update failed.
-     *
-     * @expectedException \Exception
      */
     public function testUpdateFailed()
     {
+        $this->expectException(\Exception::class);
+
         $updates = array(
             'updateMethodDummy1',
             'updateMethodDummy2',
@@ -723,7 +723,7 @@ $GLOBALS[\'privateLinkByDefault\'] = true;';
         $this->assertEquals(\Shaarli\Thumbnailer::MODE_ALL, $this->conf->get('thumbnails.mode'));
         $this->assertEquals(125, $this->conf->get('thumbnails.width'));
         $this->assertEquals(90, $this->conf->get('thumbnails.height'));
-        $this->assertContains('You have enabled or changed thumbnails', $_SESSION['warnings'][0]);
+        $this->assertContainsPolyfill('You have enabled or changed thumbnails', $_SESSION['warnings'][0]);
     }
 
     /**
@@ -754,7 +754,7 @@ $GLOBALS[\'privateLinkByDefault\'] = true;';
         if (isset($_SESSION['warnings'])) {
             unset($_SESSION['warnings']);
         }
-        
+
         $updater = new LegacyUpdater([], [], $this->conf, true, $_SESSION);
         $this->assertTrue($updater->updateMethodWebThumbnailer());
         $this->assertFalse($this->conf->exists('thumbnail'));
index 6c948bba4a0d805f09927af06e3e5d6d68482d57..ad288f78ef802835f238e8d40f4c6c8d9bd2059d 100644 (file)
@@ -1,25 +1,32 @@
 <?php
+
 namespace Shaarli\Netscape;
 
+use malkusch\lock\mutex\NoMutex;
 use Shaarli\Bookmark\BookmarkFileService;
-use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
-use Shaarli\Formatter\FormatterFactory;
 use Shaarli\Formatter\BookmarkFormatter;
+use Shaarli\Formatter\FormatterFactory;
 use Shaarli\History;
+use Shaarli\TestCase;
 
 require_once 'tests/utils/ReferenceLinkDB.php';
 
 /**
  * Netscape bookmark export
  */
-class BookmarkExportTest extends \PHPUnit\Framework\TestCase
+class BookmarkExportTest extends TestCase
 {
     /**
      * @var string datastore to test write operations
      */
     protected static $testDatastore = 'sandbox/datastore.php';
 
+    /**
+     * @var ConfigManager instance.
+     */
+    protected static $conf;
+
     /**
      * @var \ReferenceLinkDB instance.
      */
@@ -35,30 +42,50 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     protected static $formatter;
 
+    /**
+     * @var History instance
+     */
+    protected static $history;
+
+    /**
+     * @var NetscapeBookmarkUtils
+     */
+    protected $netscapeBookmarkUtils;
+
     /**
      * Instantiate reference data
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
+    {
+        $mutex = new NoMutex();
+        static::$conf = new ConfigManager('tests/utils/config/configJson');
+        static::$conf->set('resource.datastore', static::$testDatastore);
+        static::$refDb = new \ReferenceLinkDB();
+        static::$refDb->write(static::$testDatastore);
+        static::$history = new History('sandbox/history.php');
+        static::$bookmarkService = new BookmarkFileService(static::$conf, static::$history, $mutex, true);
+        $factory = new FormatterFactory(static::$conf, true);
+        static::$formatter = $factory->getFormatter('raw');
+    }
+
+    public function setUp(): void
     {
-        $conf = new ConfigManager('tests/utils/config/configJson');
-        $conf->set('resource.datastore', self::$testDatastore);
-        self::$refDb = new \ReferenceLinkDB();
-        self::$refDb->write(self::$testDatastore);
-        $history = new History('sandbox/history.php');
-        self::$bookmarkService = new BookmarkFileService($conf, $history, true);
-        $factory = new FormatterFactory($conf, true);
-        self::$formatter = $factory->getFormatter('raw');
+        $this->netscapeBookmarkUtils = new NetscapeBookmarkUtils(
+            static::$bookmarkService,
+            static::$conf,
+            static::$history
+        );
     }
 
     /**
      * Attempt to export an invalid link selection
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Invalid export selection/
      */
     public function testFilterAndFormatInvalid()
     {
-        NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Invalid export selection/');
+
+        $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'derp',
             false,
@@ -71,8 +98,7 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatAll()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'all',
             false,
@@ -97,8 +123,7 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatPrivate()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'private',
             false,
@@ -123,8 +148,7 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatPublic()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'public',
             false,
@@ -149,15 +173,14 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
      */
     public function testFilterAndFormatDoNotPrependNoteUrl()
     {
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'public',
             false,
             ''
         );
         $this->assertEquals(
-            '?WDWyig',
+            '/shaare/WDWyig',
             $links[2]['url']
         );
     }
@@ -168,15 +191,14 @@ class BookmarkExportTest extends \PHPUnit\Framework\TestCase
     public function testFilterAndFormatPrependNoteUrl()
     {
         $indexUrl = 'http://localhost:7469/shaarli/';
-        $links = NetscapeBookmarkUtils::filterAndFormat(
-            self::$bookmarkService,
+        $links = $this->netscapeBookmarkUtils->filterAndFormat(
             self::$formatter,
             'public',
             true,
             $indexUrl
         );
         $this->assertEquals(
-            $indexUrl . '?WDWyig',
+            $indexUrl . 'shaare/WDWyig',
             $links[2]['url']
         );
     }
index fef7f6d18450123cff395f0f5c8f510750721b59..c526d5c8382699c27a57ffbc12622e153c42c4cf 100644 (file)
@@ -1,29 +1,32 @@
 <?php
+
 namespace Shaarli\Netscape;
 
 use DateTime;
+use malkusch\lock\mutex\NoMutex;
+use Psr\Http\Message\UploadedFileInterface;
 use Shaarli\Bookmark\Bookmark;
-use Shaarli\Bookmark\BookmarkFilter;
 use Shaarli\Bookmark\BookmarkFileService;
-use Shaarli\Bookmark\LinkDB;
+use Shaarli\Bookmark\BookmarkFilter;
 use Shaarli\Config\ConfigManager;
 use Shaarli\History;
+use Shaarli\TestCase;
+use Slim\Http\UploadedFile;
 
 /**
  * Utility function to load a file's metadata in a $_FILES-like array
  *
  * @param string $filename Basename of the file
  *
- * @return array A $_FILES-like array
+ * @return UploadedFileInterface Upload file in PSR-7 compatible object
  */
 function file2array($filename)
 {
-    return array(
-        'filetoupload' => array(
-            'name'     => $filename,
-            'tmp_name' => __DIR__ . '/input/' . $filename,
-            'size'     => filesize(__DIR__ . '/input/' . $filename)
-        )
+    return new UploadedFile(
+        __DIR__ . '/input/' . $filename,
+        $filename,
+        null,
+        filesize(__DIR__ . '/input/' . $filename)
     );
 }
 
@@ -31,7 +34,7 @@ function file2array($filename)
 /**
  * Netscape bookmark import
  */
-class BookmarkImportTest extends \PHPUnit\Framework\TestCase
+class BookmarkImportTest extends TestCase
 {
     /**
      * @var string datastore to test write operations
@@ -63,12 +66,17 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
      */
     protected $history;
 
+    /**
+     * @var NetscapeBookmarkUtils
+     */
+    protected $netscapeBookmarkUtils;
+
     /**
      * @var string Save the current timezone.
      */
     protected static $defaultTimeZone;
 
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
         self::$defaultTimeZone = date_default_timezone_get();
         // Timezone without DST for test consistency
@@ -78,8 +86,9 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
     /**
      * Resets test data before each test
      */
-    protected function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
         if (file_exists(self::$testDatastore)) {
             unlink(self::$testDatastore);
         }
@@ -90,18 +99,19 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->conf->set('resource.page_cache', $this->pagecache);
         $this->conf->set('resource.datastore', self::$testDatastore);
         $this->history = new History(self::$historyFilePath);
-        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, true);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->history, $mutex, true);
+        $this->netscapeBookmarkUtils = new NetscapeBookmarkUtils($this->bookmarkService, $this->conf, $this->history);
     }
 
     /**
      * Delete history file.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         @unlink(self::$historyFilePath);
     }
 
-    public static function tearDownAfterClass()
+    public static function tearDownAfterClass(): void
     {
         date_default_timezone_set(self::$defaultTimeZone);
     }
@@ -115,7 +125,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertEquals(
             'File empty.htm (0 bytes) has an unknown file format.'
             .' Nothing was imported.',
-            NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(null, $files)
         );
         $this->assertEquals(0, $this->bookmarkService->count());
     }
@@ -128,7 +138,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $files = file2array('no_doctype.htm');
         $this->assertEquals(
             'File no_doctype.htm (350 bytes) has an unknown file format. Nothing was imported.',
-            NetscapeBookmarkUtils::import(null, $files, null, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(null, $files)
         );
         $this->assertEquals(0, $this->bookmarkService->count());
     }
@@ -142,7 +152,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File lowercase_doctype.htm (386 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import(null, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(null, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
     }
@@ -157,7 +167,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File internet_explorer_encoding.htm (356 bytes) was successfully processed in %d seconds:'
             .' 1 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import([], $files)
         );
         $this->assertEquals(1, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -185,7 +195,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_nested.htm (1337 bytes) was successfully processed in %d seconds:'
             .' 8 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import([], $files)
         );
         $this->assertEquals(8, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -306,7 +316,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import([], $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import([], $files)
         );
 
         $this->assertEquals(2, $this->bookmarkService->count());
@@ -349,7 +359,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
 
         $this->assertEquals(2, $this->bookmarkService->count());
@@ -392,7 +402,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -410,7 +420,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -430,7 +440,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -445,7 +455,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 2 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -465,7 +475,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -480,7 +490,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 2 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(2, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -498,7 +508,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -508,7 +518,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 0 bookmarks imported, 0 bookmarks overwritten, 2 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -527,7 +537,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -548,7 +558,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
             .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import($post, $files)
         );
         $this->assertEquals(2, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -573,7 +583,7 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
         $this->assertStringMatchesFormat(
             'File same_date.htm (453 bytes) was successfully processed in %d seconds:'
             .' 3 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
-            NetscapeBookmarkUtils::import(array(), $files, $this->bookmarkService, $this->conf, $this->history)
+            $this->netscapeBookmarkUtils->import(array(), $files)
         );
         $this->assertEquals(3, $this->bookmarkService->count());
         $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
@@ -589,14 +599,14 @@ class BookmarkImportTest extends \PHPUnit\Framework\TestCase
             'overwrite' => 'true',
         ];
         $files = file2array('netscape_basic.htm');
-        NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history);
+        $this->netscapeBookmarkUtils->import($post, $files);
         $history = $this->history->getHistory();
         $this->assertEquals(1, count($history));
         $this->assertEquals(History::IMPORT, $history[0]['event']);
         $this->assertTrue(new DateTime('-5 seconds') < $history[0]['datetime']);
 
         // re-import as private, enable overwriting
-        NetscapeBookmarkUtils::import($post, $files, $this->bookmarkService, $this->conf, $this->history);
+        $this->netscapeBookmarkUtils->import($post, $files);
         $history = $this->history->getHistory();
         $this->assertEquals(2, count($history));
         $this->assertEquals(History::IMPORT, $history[0]['event']);
index d052f8b9f24766911d4c295a4015b24456959f07..a3ec9fc9d28c6e51aa015cfb75838e65b2849525 100644 (file)
@@ -2,19 +2,19 @@
 namespace Shaarli\Plugin\Addlink;
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/addlink_toolbar/addlink_toolbar.php';
 
 /**
  * Unit test for the Addlink toolbar plugin
  */
-class PluginAddlinkTest extends \PHPUnit\Framework\TestCase
+class PluginAddlinkTest extends \Shaarli\TestCase
 {
     /**
      * Reset plugin path.
      */
-    public function setUp()
+    protected function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'plugins';
     }
@@ -26,8 +26,9 @@ class PluginAddlinkTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
         $data['_LOGGEDIN_'] = true;
+        $data['_BASE_PATH_'] = '/subfolder';
 
         $data = hook_addlink_toolbar_render_header($data);
         $this->assertEquals($str, $data[$str]);
@@ -36,6 +37,8 @@ class PluginAddlinkTest extends \PHPUnit\Framework\TestCase
         $data = array($str => $str);
         $data['_PAGE_'] = $str;
         $data['_LOGGEDIN_'] = true;
+        $data['_BASE_PATH_'] = '/subfolder';
+
         $data = hook_addlink_toolbar_render_header($data);
         $this->assertEquals($str, $data[$str]);
         $this->assertArrayNotHasKey('fields_toolbar', $data);
@@ -48,8 +51,9 @@ class PluginAddlinkTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
         $data['_LOGGEDIN_'] = false;
+        $data['_BASE_PATH_'] = '/subfolder';
 
         $data = hook_addlink_toolbar_render_header($data);
         $this->assertEquals($str, $data[$str]);
index b9a67adb5aac4bc207b1a7c325bac36639546d1b..467dc3d030583bed63a7382e6ea3afdd79f5be50 100644 (file)
@@ -1,4 +1,5 @@
 <?php
+
 namespace Shaarli\Plugin\Archiveorg;
 
 /**
@@ -6,6 +7,7 @@ namespace Shaarli\Plugin\Archiveorg;
  */
 
 use Shaarli\Plugin\PluginManager;
+use Shaarli\TestCase;
 
 require_once 'plugins/archiveorg/archiveorg.php';
 
@@ -13,20 +15,35 @@ require_once 'plugins/archiveorg/archiveorg.php';
  * Class PluginArchiveorgTest
  * Unit test for the archiveorg plugin
  */
-class PluginArchiveorgTest extends \PHPUnit\Framework\TestCase
+class PluginArchiveorgTest extends TestCase
 {
+    protected $savedScriptName;
+
     /**
      * Reset plugin path
      */
-    public function setUp()
+    public function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'plugins';
+
+        // plugins manipulate global vars
+        $_SERVER['SERVER_PORT'] = '80';
+        $_SERVER['SERVER_NAME'] = 'shaarli.shaarli';
+        $this->savedScriptName = $_SERVER['SCRIPT_NAME'] ?? null;
+        $_SERVER['SCRIPT_NAME'] = '/index.php';
+    }
+
+    public function tearDown(): void
+    {
+        unset($_SERVER['SERVER_PORT']);
+        unset($_SERVER['SERVER_NAME']);
+        $_SERVER['SCRIPT_NAME'] = $this->savedScriptName;
     }
 
     /**
      * Test render_linklist hook on external bookmarks.
      */
-    public function testArchiveorgLinklistOnExternalLinks()
+    public function testArchiveorgLinklistOnExternalLinks(): void
     {
         $str = 'http://randomstr.com/test';
 
@@ -56,16 +73,16 @@ class PluginArchiveorgTest extends \PHPUnit\Framework\TestCase
     /**
      * Test render_linklist hook on internal bookmarks.
      */
-    public function testArchiveorgLinklistOnInternalLinks()
+    public function testArchiveorgLinklistOnInternalLinks(): void
     {
-        $internalLink1 = 'http://shaarli.shaarli/?qvMAqg';
-        $internalLinkRealURL1 = '?qvMAqg';
+        $internalLink1 = 'http://shaarli.shaarli/shaare/qvMAqg';
+        $internalLinkRealURL1 = '/shaare/qvMAqg';
 
-        $internalLink2 = 'http://shaarli.shaarli/?2_7zww';
-        $internalLinkRealURL2 = '?2_7zww';
+        $internalLink2 = 'http://shaarli.shaarli/shaare/2_7zww';
+        $internalLinkRealURL2 = '/shaare/2_7zww';
 
-        $internalLink3 = 'http://shaarli.shaarli/?z7u-_Q';
-        $internalLinkRealURL3 = '?z7u-_Q';
+        $internalLink3 = 'http://shaarli.shaarli/shaare/z7u-_Q';
+        $internalLinkRealURL3 = '/shaare/z7u-_Q';
 
         $data = array(
             'title' => $internalLink1,
index b9951cca6cb7ce68b4e4cc2024cdce999c6894d0..cc844c60f41e34edc0b61665e97c6f1227d6b3ac 100644 (file)
@@ -2,11 +2,10 @@
 
 namespace Shaarli\Plugin\DefaultColors;
 
-use DateTime;
-use PHPUnit\Framework\TestCase;
 use Shaarli\Bookmark\LinkDB;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
+use Shaarli\TestCase;
 
 require_once 'plugins/default_colors/default_colors.php';
 
@@ -20,7 +19,7 @@ class PluginDefaultColorsTest extends TestCase
     /**
      * Reset plugin path
      */
-    public function setUp()
+    protected function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'sandbox';
         mkdir(PluginManager::$PLUGINS_PATH . '/default_colors/');
@@ -33,7 +32,7 @@ class PluginDefaultColorsTest extends TestCase
     /**
      * Remove sandbox files and folder
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         if (file_exists('sandbox/default_colors/default_colors.css.template')) {
             unlink('sandbox/default_colors/default_colors.css.template');
@@ -57,6 +56,8 @@ class PluginDefaultColorsTest extends TestCase
         $conf->set('plugins.DEFAULT_COLORS_BACKGROUND', 'value');
         $errors = default_colors_init($conf);
         $this->assertEmpty($errors);
+
+        $this->assertFileExists($file = 'sandbox/default_colors/default_colors.css');
     }
 
     /**
@@ -72,9 +73,9 @@ class PluginDefaultColorsTest extends TestCase
     /**
      * Test the save plugin parameters hook with all colors specified.
      */
-    public function testSavePluginParametersAll()
+    public function testGenerateCssFile()
     {
-        $post = [
+        $params = [
             'other1' => true,
             'DEFAULT_COLORS_MAIN' => 'blue',
             'DEFAULT_COLORS_BACKGROUND' => 'pink',
@@ -82,7 +83,7 @@ class PluginDefaultColorsTest extends TestCase
             'DEFAULT_COLORS_DARK_MAIN' => 'green',
         ];
 
-        hook_default_colors_save_plugin_parameters($post);
+        default_colors_generate_css_file($params);
         $this->assertFileExists($file = 'sandbox/default_colors/default_colors.css');
         $content = file_get_contents($file);
         $expected = ':root {
@@ -98,16 +99,16 @@ class PluginDefaultColorsTest extends TestCase
     /**
      * Test the save plugin parameters hook with only one color specified.
      */
-    public function testSavePluginParametersSingle()
+    public function testGenerateCssFileSingle()
     {
-        $post = [
+        $params = [
             'other1' => true,
             'DEFAULT_COLORS_BACKGROUND' => 'pink',
             'other2' => ['yep'],
             'DEFAULT_COLORS_DARK_MAIN' => '',
         ];
 
-        hook_default_colors_save_plugin_parameters($post);
+        default_colors_generate_css_file($params);
         $this->assertFileExists($file = 'sandbox/default_colors/default_colors.css');
         $content = file_get_contents($file);
         $expected = ':root {
@@ -121,9 +122,9 @@ class PluginDefaultColorsTest extends TestCase
     /**
      * Test the save plugin parameters hook with no color specified.
      */
-    public function testSavePluginParametersNone()
+    public function testGenerateCssFileNone()
     {
-        hook_default_colors_save_plugin_parameters([]);
+        default_colors_generate_css_file([]);
         $this->assertFileNotExists($file = 'sandbox/default_colors/default_colors.css');
     }
 
index 994772051086ae03ebe1602ec0e39cf246f42cdc..16ecf357abce0307188db19771a32981bd0682a8 100644 (file)
@@ -5,6 +5,7 @@ use DateTime;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
+use Shaarli\TestCase;
 
 require_once 'plugins/isso/isso.php';
 
@@ -13,12 +14,12 @@ require_once 'plugins/isso/isso.php';
  *
  * Test the Isso plugin (comment system).
  */
-class PluginIssoTest extends \PHPUnit\Framework\TestCase
+class PluginIssoTest extends TestCase
 {
     /**
      * Reset plugin path
      */
-    public function setUp()
+    public function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'plugins';
     }
@@ -26,7 +27,7 @@ class PluginIssoTest extends \PHPUnit\Framework\TestCase
     /**
      * Test Isso init without errors.
      */
-    public function testIssoInitNoError()
+    public function testIssoInitNoError(): void
     {
         $conf = new ConfigManager('');
         $conf->set('plugins.ISSO_SERVER', 'value');
@@ -37,7 +38,7 @@ class PluginIssoTest extends \PHPUnit\Framework\TestCase
     /**
      * Test Isso init with errors.
      */
-    public function testIssoInitError()
+    public function testIssoInitError(): void
     {
         $conf = new ConfigManager('');
         $errors = isso_init($conf);
@@ -47,7 +48,7 @@ class PluginIssoTest extends \PHPUnit\Framework\TestCase
     /**
      * Test render_linklist hook with valid settings to display the comment form.
      */
-    public function testIssoDisplayed()
+    public function testIssoDisplayed(): void
     {
         $conf = new ConfigManager('');
         $conf->set('plugins.ISSO_SERVER', 'value');
@@ -87,7 +88,7 @@ class PluginIssoTest extends \PHPUnit\Framework\TestCase
     /**
      * Test isso plugin when multiple bookmarks are displayed (shouldn't be displayed).
      */
-    public function testIssoMultipleLinks()
+    public function testIssoMultipleLinks(): void
     {
         $conf = new ConfigManager('');
         $conf->set('plugins.ISSO_SERVER', 'value');
@@ -115,14 +116,14 @@ class PluginIssoTest extends \PHPUnit\Framework\TestCase
 
         $processed = hook_isso_render_linklist($data, $conf);
         // link_plugin should be added for the icon
-        $this->assertContains('<a href="?'. $short1 .'#isso-thread">', $processed['links'][0]['link_plugin'][0]);
-        $this->assertContains('<a href="?'. $short2 .'#isso-thread">', $processed['links'][1]['link_plugin'][0]);
+        $this->assertContainsPolyfill('<a href="/shaare/'. $short1 .'#isso-thread">', $processed['links'][0]['link_plugin'][0]);
+        $this->assertContainsPolyfill('<a href="/shaare/'. $short2 .'#isso-thread">', $processed['links'][1]['link_plugin'][0]);
     }
 
     /**
      * Test isso plugin when using search (shouldn't be displayed).
      */
-    public function testIssoNotDisplayedWhenSearch()
+    public function testIssoNotDisplayedWhenSearch(): void
     {
         $conf = new ConfigManager('');
         $conf->set('plugins.ISSO_SERVER', 'value');
@@ -145,13 +146,13 @@ class PluginIssoTest extends \PHPUnit\Framework\TestCase
         $processed = hook_isso_render_linklist($data, $conf);
 
         // link_plugin should be added for the icon
-        $this->assertContains('<a href="?'. $short1 .'#isso-thread">', $processed['links'][0]['link_plugin'][0]);
+        $this->assertContainsPolyfill('<a href="/shaare/'. $short1 .'#isso-thread">', $processed['links'][0]['link_plugin'][0]);
     }
 
     /**
      * Test isso plugin without server configuration (shouldn't be displayed).
      */
-    public function testIssoWithoutConf()
+    public function testIssoWithoutConf(): void
     {
         $data = 'abc';
         $conf = new ConfigManager('');
index 51472617ad47cf21cd5fbc418ca2fd5d0982652c..338d2e351a2344969a8d30e906a54edaca0b43e9 100644 (file)
@@ -6,7 +6,7 @@ namespace Shaarli\Plugin\Playvideos;
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/playvideos/playvideos.php';
 
@@ -14,12 +14,12 @@ require_once 'plugins/playvideos/playvideos.php';
  * Class PluginPlayvideosTest
  * Unit test for the PlayVideos plugin
  */
-class PluginPlayvideosTest extends \PHPUnit\Framework\TestCase
+class PluginPlayvideosTest extends \Shaarli\TestCase
 {
     /**
      * Reset plugin path
      */
-    public function setUp()
+    protected function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'plugins';
     }
@@ -31,7 +31,7 @@ class PluginPlayvideosTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
 
         $data = hook_playvideos_render_header($data);
         $this->assertEquals($str, $data[$str]);
@@ -50,7 +50,7 @@ class PluginPlayvideosTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
 
         $data = hook_playvideos_render_footer($data);
         $this->assertEquals($str, $data[$str]);
index a7bd8fc93d79980633fd7edc71a66eb248df0b3e..d3f7b439c35955a64de558a7da69b65f022fc458 100644 (file)
@@ -3,7 +3,7 @@ namespace Shaarli\Plugin\Pubsubhubbub;
 
 use Shaarli\Config\ConfigManager;
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/pubsubhubbub/pubsubhubbub.php';
 
@@ -11,7 +11,7 @@ require_once 'plugins/pubsubhubbub/pubsubhubbub.php';
  * Class PluginPubsubhubbubTest
  * Unit test for the pubsubhubbub plugin
  */
-class PluginPubsubhubbubTest extends \PHPUnit\Framework\TestCase
+class PluginPubsubhubbubTest extends \Shaarli\TestCase
 {
     /**
      * @var string Config file path (without extension).
@@ -21,7 +21,7 @@ class PluginPubsubhubbubTest extends \PHPUnit\Framework\TestCase
     /**
      * Reset plugin path
      */
-    public function setUp()
+    protected function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'plugins';
     }
@@ -34,7 +34,7 @@ class PluginPubsubhubbubTest extends \PHPUnit\Framework\TestCase
         $hub = 'http://domain.hub';
         $conf = new ConfigManager(self::$configFile);
         $conf->set('plugins.PUBSUBHUB_URL', $hub);
-        $data['_PAGE_'] = Router::$PAGE_FEED_RSS;
+        $data['_PAGE_'] = TemplatePage::FEED_RSS;
 
         $data = hook_pubsubhubbub_render_feed($data, $conf);
         $expected = '<atom:link rel="hub" href="'. $hub .'" />';
@@ -49,7 +49,7 @@ class PluginPubsubhubbubTest extends \PHPUnit\Framework\TestCase
         $hub = 'http://domain.hub';
         $conf = new ConfigManager(self::$configFile);
         $conf->set('plugins.PUBSUBHUB_URL', $hub);
-        $data['_PAGE_'] = Router::$PAGE_FEED_ATOM;
+        $data['_PAGE_'] = TemplatePage::FEED_ATOM;
 
         $data = hook_pubsubhubbub_render_feed($data, $conf);
         $expected = '<link rel="hub" href="'. $hub .'" />';
index 0c61e14a75bc841f1be4d5362bcb4b55c8b4662d..1d85fba60ce76f26588faaab28708f0a2c1fca34 100644 (file)
@@ -6,7 +6,7 @@ namespace Shaarli\Plugin\Qrcode;
  */
 
 use Shaarli\Plugin\PluginManager;
-use Shaarli\Router;
+use Shaarli\Render\TemplatePage;
 
 require_once 'plugins/qrcode/qrcode.php';
 
@@ -14,12 +14,12 @@ require_once 'plugins/qrcode/qrcode.php';
  * Class PluginQrcodeTest
  * Unit test for the QR-Code plugin
  */
-class PluginQrcodeTest extends \PHPUnit\Framework\TestCase
+class PluginQrcodeTest extends \Shaarli\TestCase
 {
     /**
      * Reset plugin path
      */
-    public function setUp()
+    protected function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'plugins';
     }
@@ -57,7 +57,7 @@ class PluginQrcodeTest extends \PHPUnit\Framework\TestCase
     {
         $str = 'stuff';
         $data = array($str => $str);
-        $data['_PAGE_'] = Router::$PAGE_LINKLIST;
+        $data['_PAGE_'] = TemplatePage::LINKLIST;
 
         $data = hook_qrcode_render_footer($data);
         $this->assertEquals($str, $data[$str]);
index 797519210c63c5827da633d870e90de1b152d6c3..36317215976e4a5c38fe8bf721525b903d764d8d 100644 (file)
@@ -10,12 +10,12 @@ require_once 'plugins/wallabag/wallabag.php';
  * Class PluginWallabagTest
  * Unit test for the Wallabag plugin
  */
-class PluginWallabagTest extends \PHPUnit\Framework\TestCase
+class PluginWallabagTest extends \Shaarli\TestCase
 {
     /**
      * Reset plugin path
      */
-    public function setUp()
+    protected function setUp(): void
     {
         PluginManager::$PLUGINS_PATH = 'plugins';
     }
index a3cd90765676c36920a9a58d2b0140133950a54d..5ef3de1a109a9e3b871b9036078be48e4d3b87ee 100644 (file)
@@ -4,7 +4,7 @@ namespace Shaarli\Plugin\Wallabag;
 /**
  * Class WallabagInstanceTest
  */
-class WallabagInstanceTest extends \PHPUnit\Framework\TestCase
+class WallabagInstanceTest extends \Shaarli\TestCase
 {
     /**
      * @var string wallabag url.
@@ -14,7 +14,7 @@ class WallabagInstanceTest extends \PHPUnit\Framework\TestCase
     /**
      * Reset plugin path
      */
-    public function setUp()
+    protected function setUp(): void
     {
         $this->instance = 'http://some.url';
     }
diff --git a/tests/plugins/resources/hashtags.md b/tests/plugins/resources/hashtags.md
deleted file mode 100644 (file)
index 46326de..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-[#lol](?addtag=lol)
-
-    #test
-
-`#test2`
-
-```
-bla #bli blo
-#bla
-```
diff --git a/tests/plugins/resources/hashtags.raw b/tests/plugins/resources/hashtags.raw
deleted file mode 100644 (file)
index 9d2dc98..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-#lol
-
-    #test
-
-`#test2`
-
-```
-bla #bli blo
-#bla
-```
diff --git a/tests/plugins/resources/markdown.html b/tests/plugins/resources/markdown.html
deleted file mode 100644 (file)
index c3460bf..0000000
+++ /dev/null
@@ -1,33 +0,0 @@
-<div class="markdown"><ul>
-<li>test:
-<ul>
-<li><a href="http://link.tld">zero</a></li>
-<li><a href="http://link.tld">two</a></li>
-<li><a href="http://link.tld">three</a></li>
-</ul></li>
-</ul>
-<ol>
-<li><a href="http://link.tld">zero</a>
-<ol start="2">
-<li><a href="http://link.tld">two</a></li>
-<li><a href="http://link.tld">three</a></li>
-<li><a href="http://link.tld">four</a></li>
-<li>foo <a href="?addtag=foobar">#foobar</a></li>
-</ol></li>
-</ol>
-<p><a href="?addtag=foobar">#foobar</a> foo <code>lol #foo</code> <a href="?addtag=bar">#bar</a></p>
-<p>fsdfs <a href="http://link.tld">http://link.tld</a> <a href="?addtag=foobar">#foobar</a> <code>http://link.tld</code></p>
-<pre><code>http://link.tld #foobar
-next #foo</code></pre>
-<p>Block:</p>
-<pre><code>lorem ipsum #foobar http://link.tld
-#foobar http://link.tld</code></pre>
-<p><a href="?123456">link</a><br />
-<img src="/img/train.png" alt="link" /><br />
-<a href="http://test.tld/path/?query=value#hash">link</a><br />
-<a href="http://test.tld/path/?query=value#hash">link</a><br />
-<a href="https://test.tld/path/?query=value#hash">link</a><br />
-<a href="ftp://test.tld/path/?query=value#hash">link</a><br />
-<a href="magnet:test.tld/path/?query=value#hash">link</a><br />
-<a href="http://alert(&#039;xss&#039;)">link</a><br />
-<a href="http://test.tld/path/?query=value#hash">link</a></p></div>
diff --git a/tests/plugins/resources/markdown.md b/tests/plugins/resources/markdown.md
deleted file mode 100644 (file)
index 9350a8c..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-* test:
-    * [zero](http://link.tld)
-    + [two](http://link.tld)
-    - [three](http://link.tld)
-
-1. [zero](http://link.tld)
-  2. [two](http://link.tld)
-   3. [three](http://link.tld)
-    4. [four](http://link.tld)
-    5. foo #foobar
-
-#foobar foo `lol #foo` #bar
-
-fsdfs http://link.tld #foobar `http://link.tld`
-
-    http://link.tld #foobar
-    next #foo
-    
-Block:
-
-```
-lorem ipsum #foobar http://link.tld
-#foobar http://link.tld
-```
-
-[link](?123456)
-![link](/img/train.png)
-[link](test.tld/path/?query=value#hash)
-[link](http://test.tld/path/?query=value#hash)
-[link](https://test.tld/path/?query=value#hash)
-[link](ftp://test.tld/path/?query=value#hash)
-[link](magnet:test.tld/path/?query=value#hash)
-[link](javascript:alert('xss'))
-[link](other://test.tld/path/?query=value#hash)
index 2aaf51223e326e108451b0ba77b026db7828b6d0..03be4f4e8c997bd9eb875ad1f42a65bf4d294eb7 100644 (file)
@@ -13,9 +13,17 @@ function hook_test_random($data)
         $data[1] = 'page test';
     } elseif (isset($data['_LOGGEDIN_']) && $data['_LOGGEDIN_'] === true) {
         $data[1] = 'loggedin';
+    } elseif (array_key_exists('_LOGGEDIN_', $data)) {
+        $data[1] = 'loggedin';
+        $data[2] = $data['_LOGGEDIN_'];
     } else {
         $data[1] = $data[0];
     }
 
     return $data;
 }
+
+function hook_test_error()
+{
+    new Unknown();
+}
similarity index 65%
rename from tests/feed/CacheTest.php
rename to tests/render/PageCacheManagerTest.php
index c0a9f26f2dc933d7adf8644a8b580d797efe01de..08d4e5ea0bd9f7b8b37e907fc4b8a90f7c96d3e4 100644 (file)
@@ -1,18 +1,18 @@
 <?php
+
 /**
  * Cache tests
  */
-namespace Shaarli\Feed;
 
-// required to access $_SESSION array
-session_start();
+namespace Shaarli\Render;
 
-require_once 'application/feed/Cache.php';
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
 
 /**
  * Unitary tests for cached pages
  */
-class CacheTest extends \PHPUnit\Framework\TestCase
+class PageCacheManagerTest extends TestCase
 {
     // test cache directory
     protected static $testCacheDir = 'sandbox/dummycache';
@@ -20,12 +20,19 @@ class CacheTest extends \PHPUnit\Framework\TestCase
     // dummy cached file names / content
     protected static $pages = array('a', 'toto', 'd7b59c');
 
+    /** @var PageCacheManager */
+    protected $cacheManager;
+
+    /** @var SessionManager */
+    protected $sessionManager;
 
     /**
      * Populate the cache with dummy files
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $this->cacheManager = new PageCacheManager(static::$testCacheDir, true);
+
         if (!is_dir(self::$testCacheDir)) {
             mkdir(self::$testCacheDir);
         } else {
@@ -41,7 +48,7 @@ class CacheTest extends \PHPUnit\Framework\TestCase
     /**
      * Remove dummycache folder after each tests.
      */
-    public function tearDown()
+    protected function tearDown(): void
     {
         array_map('unlink', glob(self::$testCacheDir . '/*'));
         rmdir(self::$testCacheDir);
@@ -52,7 +59,7 @@ class CacheTest extends \PHPUnit\Framework\TestCase
      */
     public function testPurgeCachedPages()
     {
-        purgeCachedPages(self::$testCacheDir);
+        $this->cacheManager->purgeCachedPages();
         foreach (self::$pages as $page) {
             $this->assertFileNotExists(self::$testCacheDir . '/' . $page . '.cache');
         }
@@ -65,28 +72,14 @@ class CacheTest extends \PHPUnit\Framework\TestCase
      */
     public function testPurgeCachedPagesMissingDir()
     {
+        $this->cacheManager = new PageCacheManager(self::$testCacheDir . '_missing', true);
+
         $oldlog = ini_get('error_log');
         ini_set('error_log', '/dev/null');
         $this->assertEquals(
             'Cannot purge sandbox/dummycache_missing: no directory',
-            purgeCachedPages(self::$testCacheDir . '_missing')
+            $this->cacheManager->purgeCachedPages()
         );
         ini_set('error_log', $oldlog);
     }
-
-    /**
-     * Purge cached pages and session cache
-     */
-    public function testInvalidateCaches()
-    {
-        $this->assertArrayNotHasKey('tags', $_SESSION);
-        $_SESSION['tags'] = array('goodbye', 'cruel', 'world');
-
-        invalidateCaches(self::$testCacheDir);
-        foreach (self::$pages as $page) {
-            $this->assertFileNotExists(self::$testCacheDir . '/' . $page . '.cache');
-        }
-
-        $this->assertArrayNotHasKey('tags', $_SESSION);
-    }
 }
index 58e3426b2de3670ab40a2c12bc385c2b308652d8..7d841e4d4a2f31366a296b1e5c7da6c1057df5c5 100644 (file)
@@ -7,7 +7,7 @@ namespace Shaarli\Render;
  *
  * @package Shaarli
  */
-class ThemeUtilsTest extends \PHPUnit\Framework\TestCase
+class ThemeUtilsTest extends \Shaarli\TestCase
 {
     /**
      * Test getThemes() with existing theme directories.
index bba7c8ad76594c359b440e7d3a28f04fcdb293a3..29d2791b0198b1ec3b8787799b6f51f860e60381 100644 (file)
@@ -3,8 +3,9 @@
 
 namespace Shaarli\Security;
 
-use PHPUnit\Framework\TestCase;
-use Shaarli\FileUtils;
+use Psr\Log\LoggerInterface;
+use Shaarli\Helper\FileUtils;
+use Shaarli\TestCase;
 
 /**
  * Test coverage for BanManager
@@ -32,7 +33,7 @@ class BanManagerTest extends TestCase
     /**
      * Prepare or reset test resources
      */
-    public function setUp()
+    protected function setUp(): void
     {
         if (file_exists($this->banFile)) {
             unlink($this->banFile);
@@ -387,7 +388,7 @@ class BanManagerTest extends TestCase
             3,
             1800,
             $this->banFile,
-            $this->logFile
+            $this->createMock(LoggerInterface::class)
         );
     }
 }
index 8fd1698c1bf751043afa4ec437990242437bd2a6..f7609fc676e8601a03dc688cdf2cee6407e1fe97 100644 (file)
@@ -1,16 +1,17 @@
 <?php
-namespace Shaarli\Security;
 
-require_once 'tests/utils/FakeConfigManager.php';
+namespace Shaarli\Security;
 
-use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Shaarli\FakeConfigManager;
+use Shaarli\TestCase;
 
 /**
  * Test coverage for LoginManager
  */
 class LoginManagerTest extends TestCase
 {
-    /** @var \FakeConfigManager Configuration Manager instance */
+    /** @var FakeConfigManager Configuration Manager instance */
     protected $configManager = null;
 
     /** @var LoginManager Login Manager instance */
@@ -58,10 +59,16 @@ class LoginManagerTest extends TestCase
     /** @var string Salt used by hash functions */
     protected $salt = '669e24fa9c5a59a613f98e8e38327384504a4af2';
 
+    /** @var CookieManager */
+    protected $cookieManager;
+
+    /** @var BanManager */
+    protected $banManager;
+
     /**
      * Prepare or reset test resources
      */
-    public function setUp()
+    protected function setUp(): void
     {
         if (file_exists($this->banFile)) {
             unlink($this->banFile);
@@ -69,7 +76,7 @@ class LoginManagerTest extends TestCase
 
         $this->passwordHash = sha1($this->password . $this->login . $this->salt);
 
-        $this->configManager = new \FakeConfigManager([
+        $this->configManager = new FakeConfigManager([
             'credentials.login' => $this->login,
             'credentials.hash' => $this->passwordHash,
             'credentials.salt' => $this->salt,
@@ -84,19 +91,34 @@ class LoginManagerTest extends TestCase
         $this->cookie = [];
         $this->session = [];
 
-        $this->sessionManager = new SessionManager($this->session, $this->configManager);
-        $this->loginManager = new LoginManager($this->configManager, $this->sessionManager);
+        $this->cookieManager = $this->createMock(CookieManager::class);
+        $this->cookieManager->method('getCookieParameter')->willReturnCallback(function (string $key) {
+            return $this->cookie[$key] ?? null;
+        });
+        $this->sessionManager = new SessionManager($this->session, $this->configManager, 'session_path');
+        $this->banManager = $this->createMock(BanManager::class);
+        $this->loginManager = new LoginManager(
+            $this->configManager,
+            $this->sessionManager,
+            $this->cookieManager,
+            $this->banManager,
+            $this->createMock(LoggerInterface::class)
+        );
         $this->server['REMOTE_ADDR'] = $this->ipAddr;
     }
 
     /**
      * Record a failed login attempt
      */
-    public function testHandleFailedLogin()
+    public function testHandleFailedLogin(): void
     {
+        $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt');
+        $this->banManager->method('isBanned')->willReturn(true);
+
         $this->loginManager->handleFailedLogin($this->server);
         $this->loginManager->handleFailedLogin($this->server);
-        $this->assertFalse($this->loginManager->canLogin($this->server));
+
+        static::assertFalse($this->loginManager->canLogin($this->server));
     }
 
     /**
@@ -108,8 +130,13 @@ class LoginManagerTest extends TestCase
             'REMOTE_ADDR' => $this->trustedProxy,
             'HTTP_X_FORWARDED_FOR' => $this->ipAddr,
         ];
+
+        $this->banManager->expects(static::exactly(2))->method('handleFailedAttempt');
+        $this->banManager->method('isBanned')->willReturn(true);
+
         $this->loginManager->handleFailedLogin($server);
         $this->loginManager->handleFailedLogin($server);
+
         $this->assertFalse($this->loginManager->canLogin($server));
     }
 
@@ -190,11 +217,17 @@ class LoginManagerTest extends TestCase
      */
     public function testCheckLoginStateNotConfigured()
     {
-        $configManager = new \FakeConfigManager([
+        $configManager = new FakeConfigManager([
             'resource.ban_file' => $this->banFile,
         ]);
-        $loginManager = new LoginManager($configManager, null);
-        $loginManager->checkLoginState([], '');
+        $loginManager = new LoginManager(
+            $configManager,
+            $this->sessionManager,
+            $this->cookieManager,
+            $this->banManager,
+            $this->createMock(LoggerInterface::class)
+        );
+        $loginManager->checkLoginState('');
 
         $this->assertFalse($loginManager->isLoggedIn());
     }
@@ -210,9 +243,9 @@ class LoginManagerTest extends TestCase
             'expires_on' => time() + 100,
         ];
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
-        $this->cookie[LoginManager::$STAY_SIGNED_IN_COOKIE] = 'nope';
+        $this->cookie[CookieManager::STAY_SIGNED_IN] = 'nope';
 
-        $this->loginManager->checkLoginState($this->cookie, $this->clientIpAddress);
+        $this->loginManager->checkLoginState($this->clientIpAddress);
 
         $this->assertTrue($this->loginManager->isLoggedIn());
         $this->assertTrue(empty($this->session['username']));
@@ -224,9 +257,9 @@ class LoginManagerTest extends TestCase
     public function testCheckLoginStateStaySignedInWithValidToken()
     {
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
-        $this->cookie[LoginManager::$STAY_SIGNED_IN_COOKIE] = $this->loginManager->getStaySignedInToken();
+        $this->cookie[CookieManager::STAY_SIGNED_IN] = $this->loginManager->getStaySignedInToken();
 
-        $this->loginManager->checkLoginState($this->cookie, $this->clientIpAddress);
+        $this->loginManager->checkLoginState($this->clientIpAddress);
 
         $this->assertTrue($this->loginManager->isLoggedIn());
         $this->assertEquals($this->login, $this->session['username']);
@@ -241,7 +274,7 @@ class LoginManagerTest extends TestCase
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
         $this->session['expires_on'] = time() - 100;
 
-        $this->loginManager->checkLoginState($this->cookie, $this->clientIpAddress);
+        $this->loginManager->checkLoginState($this->clientIpAddress);
 
         $this->assertFalse($this->loginManager->isLoggedIn());
     }
@@ -253,7 +286,7 @@ class LoginManagerTest extends TestCase
     {
         $this->loginManager->generateStaySignedInToken($this->clientIpAddress);
 
-        $this->loginManager->checkLoginState($this->cookie, '10.7.157.98');
+        $this->loginManager->checkLoginState('10.7.157.98');
 
         $this->assertFalse($this->loginManager->isLoggedIn());
     }
@@ -264,7 +297,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsWrongLogin()
     {
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', 'b4dl0g1n', $this->password)
+            $this->loginManager->checkCredentials('', 'b4dl0g1n', $this->password)
         );
     }
 
@@ -274,7 +307,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsWrongPassword()
     {
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', $this->login, 'b4dp455wd')
+            $this->loginManager->checkCredentials('', $this->login, 'b4dp455wd')
         );
     }
 
@@ -284,7 +317,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsWrongLoginAndPassword()
     {
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', 'b4dl0g1n', 'b4dp455wd')
+            $this->loginManager->checkCredentials('', 'b4dl0g1n', 'b4dp455wd')
         );
     }
 
@@ -294,7 +327,7 @@ class LoginManagerTest extends TestCase
     public function testCheckCredentialsGoodLoginAndPassword()
     {
         $this->assertTrue(
-            $this->loginManager->checkCredentials('', '', $this->login, $this->password)
+            $this->loginManager->checkCredentials('', $this->login, $this->password)
         );
     }
 
@@ -305,7 +338,7 @@ class LoginManagerTest extends TestCase
     {
         $this->configManager->set('ldap.host', 'dummy');
         $this->assertFalse(
-            $this->loginManager->checkCredentials('', '', $this->login, $this->password)
+            $this->loginManager->checkCredentials('', $this->login, $this->password)
         );
     }
 
index f264505eaefa81159e7cf1af14fb01b22b60f027..6830d7146640c0a7b163275d6aa073e9b59eba0a 100644 (file)
@@ -1,12 +1,9 @@
 <?php
-require_once 'tests/utils/FakeConfigManager.php';
 
-// Initialize reference data _before_ PHPUnit starts a session
-require_once 'tests/utils/ReferenceSessionIdHashes.php';
-ReferenceSessionIdHashes::genAllHashes();
+namespace Shaarli\Security;
 
-use PHPUnit\Framework\TestCase;
-use Shaarli\Security\SessionManager;
+use Shaarli\FakeConfigManager;
+use Shaarli\TestCase;
 
 /**
  * Test coverage for SessionManager
@@ -16,7 +13,7 @@ class SessionManagerTest extends TestCase
     /** @var array Session ID hashes */
     protected static $sidHashes = null;
 
-    /** @var \FakeConfigManager ConfigManager substitute for testing */
+    /** @var FakeConfigManager ConfigManager substitute for testing */
     protected $conf = null;
 
     /** @var array $_SESSION array for testing */
@@ -28,15 +25,15 @@ class SessionManagerTest extends TestCase
     /**
      * Assign reference data
      */
-    public static function setUpBeforeClass()
+    public static function setUpBeforeClass(): void
     {
-        self::$sidHashes = ReferenceSessionIdHashes::getHashes();
+        self::$sidHashes = \ReferenceSessionIdHashes::getHashes();
     }
 
     /**
      * Initialize or reset test resources
      */
-    public function setUp()
+    protected function setUp(): void
     {
         $this->conf = new FakeConfigManager([
             'credentials.login' => 'johndoe',
@@ -44,7 +41,7 @@ class SessionManagerTest extends TestCase
             'security.session_protection_disabled' => false,
         ]);
         $this->session = [];
-        $this->sessionManager = new SessionManager($this->session, $this->conf);
+        $this->sessionManager = new SessionManager($this->session, $this->conf, 'session_path');
     }
 
     /**
@@ -69,7 +66,7 @@ class SessionManagerTest extends TestCase
                 $token => 1,
             ],
         ];
-        $sessionManager = new SessionManager($session, $this->conf);
+        $sessionManager = new SessionManager($session, $this->conf, 'session_path');
 
         // check and destroy the token
         $this->assertTrue($sessionManager->checkToken($token));
@@ -211,15 +208,16 @@ class SessionManagerTest extends TestCase
             'expires_on' => time() + 1000,
             'username' => 'johndoe',
             'visibility' => 'public',
-            'untaggedonly' => false,
+            'untaggedonly' => true,
         ];
         $this->sessionManager->logout();
 
-        $this->assertFalse(isset($this->session['ip']));
-        $this->assertFalse(isset($this->session['expires_on']));
-        $this->assertFalse(isset($this->session['username']));
-        $this->assertFalse(isset($this->session['visibility']));
-        $this->assertFalse(isset($this->session['untaggedonly']));
+        $this->assertArrayNotHasKey('ip', $this->session);
+        $this->assertArrayNotHasKey('expires_on', $this->session);
+        $this->assertArrayNotHasKey('username', $this->session);
+        $this->assertArrayNotHasKey('visibility', $this->session);
+        $this->assertArrayHasKey('untaggedonly', $this->session);
+        $this->assertTrue($this->session['untaggedonly']);
     }
 
     /**
@@ -269,4 +267,61 @@ class SessionManagerTest extends TestCase
         $this->session['ip'] = 'ip_id_one';
         $this->assertTrue($this->sessionManager->hasClientIpChanged('ip_id_two'));
     }
+
+    /**
+     * Test creating an entry in the session array
+     */
+    public function testSetSessionParameterCreate(): void
+    {
+        $this->sessionManager->setSessionParameter('abc', 'def');
+
+        static::assertSame('def', $this->session['abc']);
+    }
+
+    /**
+     * Test updating an entry in the session array
+     */
+    public function testSetSessionParameterUpdate(): void
+    {
+        $this->session['abc'] = 'ghi';
+
+        $this->sessionManager->setSessionParameter('abc', 'def');
+
+        static::assertSame('def', $this->session['abc']);
+    }
+
+    /**
+     * Test updating an entry in the session array with null value
+     */
+    public function testSetSessionParameterUpdateNull(): void
+    {
+        $this->session['abc'] = 'ghi';
+
+        $this->sessionManager->setSessionParameter('abc', null);
+
+        static::assertArrayHasKey('abc', $this->session);
+        static::assertNull($this->session['abc']);
+    }
+
+    /**
+     * Test deleting an existing entry in the session array
+     */
+    public function testDeleteSessionParameter(): void
+    {
+        $this->session['abc'] = 'def';
+
+        $this->sessionManager->deleteSessionParameter('abc');
+
+        static::assertArrayNotHasKey('abc', $this->session);
+    }
+
+    /**
+     * Test deleting a non existent entry in the session array
+     */
+    public function testDeleteSessionParameterNotExisting(): void
+    {
+        $this->sessionManager->deleteSessionParameter('abc');
+
+        static::assertArrayNotHasKey('abc', $this->session);
+    }
 }
index 07c7f5c48efd4b54ad35aff320a3b75b134038a6..3403233febf6d904ccefa817cb4719e5375a0ffa 100644 (file)
@@ -37,7 +37,7 @@ class DummyUpdater extends Updater
      *
      * @return bool true.
      */
-    final private function updateMethodDummy1()
+    final protected function updateMethodDummy1()
     {
         return true;
     }
@@ -47,7 +47,7 @@ class DummyUpdater extends Updater
      *
      * @return bool true.
      */
-    final private function updateMethodDummy2()
+    final protected function updateMethodDummy2()
     {
         return true;
     }
@@ -57,7 +57,7 @@ class DummyUpdater extends Updater
      *
      * @return bool true.
      */
-    final private function updateMethodDummy3()
+    final protected function updateMethodDummy3()
     {
         return true;
     }
@@ -67,7 +67,7 @@ class DummyUpdater extends Updater
      *
      * @throws Exception error.
      */
-    final private function updateMethodException()
+    final protected function updateMethodException()
     {
         throw new Exception('whatever');
     }
index c689982b49ea15551b230290b76dbf50caa9f203..47332544a7b254c8a56ab4adf6c80604daad5ac5 100644 (file)
@@ -2,17 +2,19 @@
 namespace Shaarli\Updater;
 
 use Exception;
+use malkusch\lock\mutex\NoMutex;
+use Shaarli\Bookmark\BookmarkFileService;
+use Shaarli\Bookmark\BookmarkServiceInterface;
 use Shaarli\Config\ConfigManager;
+use Shaarli\History;
+use Shaarli\TestCase;
 
-require_once 'tests/updater/DummyUpdater.php';
-require_once 'tests/utils/ReferenceLinkDB.php';
-require_once 'inc/rain.tpl.class.php';
 
 /**
  * Class UpdaterTest.
  * Runs unit tests against the updater class.
  */
-class UpdaterTest extends \PHPUnit\Framework\TestCase
+class UpdaterTest extends TestCase
 {
     /**
      * @var string Path to test datastore.
@@ -29,13 +31,28 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase
      */
     protected $conf;
 
+    /** @var BookmarkServiceInterface */
+    protected $bookmarkService;
+
+    /** @var \ReferenceLinkDB */
+    protected $refDB;
+
+    /** @var Updater */
+    protected $updater;
+
     /**
      * Executed before each test.
      */
-    public function setUp()
+    protected function setUp(): void
     {
+        $mutex = new NoMutex();
+        $this->refDB = new \ReferenceLinkDB();
+        $this->refDB->write(self::$testDatastore);
+
         copy('tests/utils/config/configJson.json.php', self::$configFile .'.json.php');
         $this->conf = new ConfigManager(self::$configFile);
+        $this->bookmarkService = new BookmarkFileService($this->conf, $this->createMock(History::class), $mutex, true);
+        $this->updater = new Updater([], $this->bookmarkService, $this->conf, true);
     }
 
     /**
@@ -72,23 +89,23 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test errors in UpdaterUtils::write_updates_file(): empty updates file.
-     *
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Updates file path is not set(.*)/
      */
     public function testWriteEmptyUpdatesFile()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Updates file path is not set(.*)/');
+
         UpdaterUtils::write_updates_file('', array('test'));
     }
 
     /**
      * Test errors in UpdaterUtils::write_updates_file(): not writable updates file.
-     *
-     * @expectedException              Exception
-     * @expectedExceptionMessageRegExp /Unable to write(.*)/
      */
     public function testWriteUpdatesFileNotWritable()
     {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessageRegExp('/Unable to write(.*)/');
+
         $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt';
         touch($updatesFile);
         chmod($updatesFile, 0444);
@@ -153,11 +170,11 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase
 
     /**
      * Test Update failed.
-     *
-     * @expectedException \Exception
      */
     public function testUpdateFailed()
     {
+        $this->expectException(\Exception::class);
+
         $updates = array(
             'updateMethodDummy1',
             'updateMethodDummy2',
@@ -167,4 +184,40 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase
         $updater = new DummyUpdater($updates, array(), $this->conf, true);
         $updater->update();
     }
+
+    public function testUpdateMethodRelativeHomeLinkRename(): void
+    {
+        $this->updater->setBasePath('/subfolder');
+        $this->conf->set('general.header_link', '?');
+
+        $this->updater->updateMethodRelativeHomeLink();
+
+        static::assertSame('/subfolder/', $this->conf->get('general.header_link'));
+    }
+
+    public function testUpdateMethodRelativeHomeLinkDoNotRename(): void
+    {
+        $this->conf->set('general.header_link', '~/my-blog');
+
+        $this->updater->updateMethodRelativeHomeLink();
+
+        static::assertSame('~/my-blog', $this->conf->get('general.header_link'));
+    }
+
+    public function testUpdateMethodMigrateExistingNotesUrl(): void
+    {
+        $this->updater->updateMethodMigrateExistingNotesUrl();
+
+        static::assertSame($this->refDB->getLinks()[0]->getUrl(), $this->bookmarkService->get(0)->getUrl());
+        static::assertSame($this->refDB->getLinks()[1]->getUrl(), $this->bookmarkService->get(1)->getUrl());
+        static::assertSame($this->refDB->getLinks()[4]->getUrl(), $this->bookmarkService->get(4)->getUrl());
+        static::assertSame($this->refDB->getLinks()[6]->getUrl(), $this->bookmarkService->get(6)->getUrl());
+        static::assertSame($this->refDB->getLinks()[7]->getUrl(), $this->bookmarkService->get(7)->getUrl());
+        static::assertSame($this->refDB->getLinks()[8]->getUrl(), $this->bookmarkService->get(8)->getUrl());
+        static::assertSame($this->refDB->getLinks()[9]->getUrl(), $this->bookmarkService->get(9)->getUrl());
+        static::assertSame('/shaare/WDWyig', $this->bookmarkService->get(42)->getUrl());
+        static::assertSame('/shaare/WDWyig', $this->bookmarkService->get(41)->getUrl());
+        static::assertSame('/shaare/0gCTjQ', $this->bookmarkService->get(10)->getUrl());
+        static::assertSame('/shaare/PCRizQ', $this->bookmarkService->get(11)->getUrl());
+    }
 }
index de83d598575a9af23c335a0aa32ee770042c2399..d5289ede2c735afcb8aceb4a14d2c7a7e5e5f9a4 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace Shaarli;
 
+use Shaarli\Helper\ApplicationUtils;
+
 /**
  * Fake ApplicationUtils class to avoid HTTP requests
  */
index 360b34a981c91d797d29d769dd858bb1c032a36a..014c2af0d8f1ae3179ad3d591cc494710bef6737 100644 (file)
@@ -1,9 +1,13 @@
 <?php
 
+namespace Shaarli;
+
+use Shaarli\Config\ConfigManager;
+
 /**
  * Fake ConfigManager
  */
-class FakeConfigManager
+class FakeConfigManager extends ConfigManager
 {
     protected $values = [];
 
@@ -23,7 +27,7 @@ class FakeConfigManager
      * @param string $key   Key of the value to set
      * @param mixed  $value Value to set
      */
-    public function set($key, $value)
+    public function set($key, $value, $write = false, $isLoggedIn = false)
     {
         $this->values[$key] = $value;
     }
@@ -35,7 +39,7 @@ class FakeConfigManager
      *
      * @return mixed The value if set, else the name of the key
      */
-    public function get($key)
+    public function get($key, $default = '')
     {
         if (isset($this->values[$key])) {
             return $this->values[$key];
index 516c9f51ea22d195bf71f70bcc9b786c083beade..aed5d2cf1a8baa85f16d8c53dff653f8cf98345c 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
 use Shaarli\History;
 
 /**
index 0095f5a15ba5f5e98614af2970ff87197cc33103..1f53dc3cd60337d2c6cbda01616ef88e7fad014b 100644 (file)
@@ -30,7 +30,7 @@ class ReferenceLinkDB
         $this->addLink(
             11,
             'Pined older',
-            '?PCRizQ',
+            '/shaare/PCRizQ',
             'This is an older pinned link',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20100309_101010'),
@@ -43,7 +43,7 @@ class ReferenceLinkDB
         $this->addLink(
             10,
             'Pined',
-            '?0gCTjQ',
+            '/shaare/0gCTjQ',
             'This is a pinned link',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121207_152312'),
@@ -56,7 +56,7 @@ class ReferenceLinkDB
         $this->addLink(
             41,
             'Link title: @website',
-            '?WDWyig',
+            '/shaare/WDWyig',
             'Stallman has a beard and is part of the Free Software Foundation (or not). Seriously, read this. #hashtag',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'),
@@ -68,7 +68,7 @@ class ReferenceLinkDB
         $this->addLink(
             42,
             'Note: I have a big ID but an old date',
-            '?WDWyig',
+            '/shaare/WDWyig',
             'Used to test bookmarks reordering.',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20100310_101010'),
@@ -82,7 +82,7 @@ class ReferenceLinkDB
             'This guide extends and expands on PSR-1, the basic coding standard.',
             0,
             DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121206_152312'),
-            ''
+            'coding-style standards quality assurance'
         );
 
         $this->addLink(
index 09737b4b0a7850686e72dacbb726d0d7757cd9de..7b696e4c480f77e2831093e91cd7a63e56f208c2 100644 (file)
@@ -8,7 +8,7 @@
   {include="page.header"}
 <div id="pageError" class="page-error-container center">
   <h2>{'Sorry, nothing to see here.'|t}</h2>
-  <img src="img/sad_star.png" alt="">
+  <img src="{$asset_path}/img/sad_star.png#" alt="">
   <p>{$error_message}</p>
 </div>
 {include="page.footer"}
index b4b4a0ec1e3a5443d6923dc72fca9535806ba132..4aac7ff1e69617df47b78f146ed739f030424c1c 100644 (file)
@@ -9,7 +9,7 @@
   <div class="pure-u-lg-1-3 pure-u-1-24"></div>
   <div id="addlink-form" class="page-form  page-form-light pure-u-lg-1-3 pure-u-22-24">
     <h2 class="window-title">{"Shaare a new link"|t}</h2>
-    <form method="GET" action="#" name="addform" class="addform">
+    <form method="GET" action="{$base_path}/admin/shaare" name="addform" class="addform">
       <div>
         <label for="shaare">{'URL or leave empty to post a note'|t}</label>
         <input type="text" name="post" id="shaare" class="autofocus">
     </form>
   </div>
 </div>
+
+<div class="pure-g addlink-batch-show-more-block pure-u-0">
+  <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+  <div class="pure-u-lg-1-3 pure-u-22-24 addlink-batch-show-more">
+    <a href="#">{'BULK CREATION'|t}&nbsp;<i class="fa fa-plus-circle" aria-hidden="true"></i></a>
+  </div>
+</div>
+
+<div class="addlink-batch-form-block">
+  {if="empty($async_metadata)"}
+    <div class="pure-g pure-alert pure-alert-warning pure-alert-closable">
+      <div class="pure-u-2-24"></div>
+      <div class="pure-u-20-24">
+        <p>
+          {'Metadata asynchronous retrieval is disabled.'|t}
+          {'We recommend that you enable the setting <em>general > enable_async_metadata</em> in your configuration file to use bulk link creation.'|t}
+        </p>
+      </div>
+      <div class="pure-u-2-24">
+        <i class="fa fa-times pure-alert-close"></i>
+      </div>
+    </div>
+  {/if}
+
+  <div class="pure-g">
+    <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+    <div id="batch-addlink-form" class="page-form  page-form-light pure-u-lg-1-3 pure-u-22-24">
+      <h2 class="window-title">{"Shaare multiple new links"|t}</h2>
+      <form method="POST" action="{$base_path}/admin/shaare-batch" name="batch-addform" class="batch-addform">
+        <div>
+          <label for="urls">{'Add one URL per line to create multiple bookmarks.'|t}</label>
+          <textarea name="urls" id="urls"></textarea>
+
+          <div>
+            <label for="tags">{'Tags'|t}</label>
+          </div>
+          <div>
+            <input type="text" name="tags" id="tags" class="lf_input"
+                   data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off">
+          </div>
+
+          <div>
+            <input type="hidden" name="private" value="0">
+            <input type="checkbox" name="private" {if="$default_private_links"} checked="checked"{/if}>
+          &nbsp; <label for="lf_private">{'Private'|t}</label>
+          </div>
+        </div>
+        <div>
+          <input type="hidden" name="token" value="{$token}">
+          <input type="submit" value="{'Add links'|t}">
+        </div>
+      </form>
+    </div>
+  </div>
+</div>
+
 {include="page.footer"}
 </body>
 </html>
index ab57943334c600d63f35871c84ea0e3cad7516b7..736774f37948a60617dbfb7a94304093b2d1a8d5 100644 (file)
@@ -9,7 +9,7 @@
   <div class="pure-u-lg-1-3 pure-u-1-24"></div>
   <div id="addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
     <h2 class="window-title">{"Change password"|t}</h2>
-    <form method="POST" action="#" name="changepasswordform" id="changepasswordform">
+    <form method="POST" action="{$base_path}/admin/password" name="changepasswordform" id="changepasswordform">
       <div>
         <input type="password" name="oldpassword" aria-label="{'Current password'|t}" placeholder="{'Current password'|t}" class="autofocus">
       </div>
index ec6e0b464d66cc91e0ce02a12cf048c2249d23f4..89d08e2cab7e16c9a2658a17b6093e72476d896c 100644 (file)
@@ -9,7 +9,7 @@
   <div class="pure-u-lg-1-3 pure-u-1-24"></div>
   <div id="addlink-form" class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
     <h2 class="window-title">{"Manage tags"|t}</h2>
-    <form method="POST" action="#" name="changetag" id="changetag">
+    <form method="POST" action="{$base_path}/admin/tags" name="changetag" id="changetag">
       <div>
         <input type="text" name="fromtag" aria-label="{'Tag'|t}" placeholder="{'Tag'|t}" value="{$fromtag}"
                list="tagsList" autocomplete="off" class="awesomplete autofocus" data-minChars="1">
       <div><i class="fa fa-info-circle" aria-hidden="true"></i> {'Case sensitive'|t}</div>
       <input type="hidden" name="token" value="{$token}">
       <div>
-        <input type="submit" value="{'Rename'|t}" name="renametag">
-        <input type="submit" value="{'Delete'|t}" name="deletetag" class="button button-red confirm-delete">
+        <input type="submit" value="{'Rename tag'|t}" name="renametag">
+        <input type="submit" value="{'Delete tag'|t}" name="deletetag" class="button button-red confirm-delete">
       </div>
     </form>
 
-    <p>{'You can also edit tags in the'|t} <a href="?do=taglist&sort=usage">{'tag list'|t}</a>.</p>
+    <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p>
   </div>
 </div>
 {include="page.footer"}
index 8b75900de2e73a1c12c6039dbd2be4d56a1423bb..bb2564afd3712dc1dbb989043c035711dc8ccc93 100644 (file)
@@ -11,7 +11,7 @@
 {$ratioInput='7-12'}
 {$ratioInputMobile='1-8'}
 
-<form method="POST" action="#" name="configform" id="configform">
+<form method="POST" action="{$base_path}/admin/configure" name="configform" id="configform">
   <div class="pure-g">
     <div class="pure-u-lg-1-8 pure-u-1-24"></div>
     <div class="pure-u-lg-3-4 pure-u-22-24 page-form page-form-complete">
@@ -35,7 +35,7 @@
           <div class="form-label">
             <label for="titleLink">
               <span class="label-name">{'Home link'|t}</span><br>
-              <span class="label-desc">{'Default value'|t}: ?</span>
+              <span class="label-desc">{'Default value'|t}: {$base_path}/</span>
             </label>
           </div>
         </div>
                 {if="! $gd_enabled"}
                   {'You need to enable the extension <code>php-gd</code> to use thumbnails.'|t}
                 {elseif="$thumbnails_enabled"}
-                  <a href="?do=thumbs_update">{'Synchronize thumbnails'|t}</a>
+                  <a href="{$base_path}/admin/thumbnails">{'Synchronize thumbnails'|t}</a>
                 {/if}
               </span>
             </label>
index 6b5103a479c8400e65ec0465be03c56a78bac890..5e038c393822105287678394fa6e1b9ab4211d77 100644 (file)
@@ -6,12 +6,25 @@
 <body>
 {include="page.header"}
 
+<div class="pure-g">
+  <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
+    <a href="{$base_path}/daily?day">{'Daily'|t}</a>
+    <a href="{$base_path}/daily?week">{'Weekly'|t}</a>
+    <a href="{$base_path}/daily?month">{'Monthly'|t}</a>
+  </div>
+</div>
+
+
 <div class="pure-g">
   <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor" id="daily">
     <h2 class="window-title">
-      {'The Daily Shaarli'|t}
-      <a href="?do=dailyrss" title="{'1 RSS entry per day'|t}"><i class="fa fa-rss"></i></a>
+      {$localizedType} Shaarli
+      <a href="{$base_path}/daily-rss?{$type}"
+         title="{function="t('1 RSS entry per :type', '', 1, 'shaarli', [':type' => t($type)])"}"
+      >
+        <i class="fa fa-rss"></i>
+      </a>
     </h2>
 
     <div id="plugin_zone_start_daily" class="plugin_zone">
       <div class="pure-g">
         <div class="pure-u-lg-1-3 pure-u-1 center">
           {if="$previousday"}
-            <a href="?do=daily&amp;day={$previousday}">
+            <a href="{$base_path}/daily?{$type}={$previousday}">
               <i class="fa fa-arrow-left"></i>
-              {'Previous day'|t}
+              {function="t('Previous :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
             </a>
           {/if}
         </div>
         <div class="daily-desc pure-u-lg-1-3 pure-u-1 center">
-          {'All links of one day in a single page.'|t}
+          {function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}
         </div>
         <div class="pure-u-lg-1-3 pure-u-1 center">
           {if="$nextday"}
-            <a href="?do=daily&amp;day={$nextday}">
-              {'Next day'|t}
+            <a href="{$base_path}/daily?{$type}={$nextday}">
+              {function="t('Next :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
               <i class="fa fa-arrow-right"></i>
             </a>
           {/if}
       </div>
       <div>
         <h3 class="window-subtitle">
-          {if="!empty($dayDesc)"}
-            {$dayDesc} -
-          {/if}
-          {function="format_date($dayDate, false)"}
+          {$dayDesc}
         </h3>
 
         <div id="plugin_zone_about_daily" class="plugin_zone">
                 {$link=$value}
                 <div class="daily-entry">
                   <div class="daily-entry-title center">
-                    <a href="?{$link.shorturl}" title="{'Permalink'|t}">
+                    <a href="{$base_path}/?{$link.shorturl}" title="{'Permalink'|t}">
                       <i class="fa fa-link"></i>
                     </a>
                     <a href="{$link.real_url}">{$link.title}</a>
                   </div>
                   {if="$thumbnails_enabled && !empty($link.thumbnail)"}
                     <div class="daily-entry-thumbnail">
-                      <img data-src="{$link.thumbnail}#" class="b-lazy"
+                      <img data-src="{$root_path}/{$link.thumbnail}#" class="b-lazy"
                            src=""
                            alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
                     </div>
@@ -85,7 +95,7 @@
                   {if="$link.tags"}
                     <div class="daily-entry-tags center">
                       {loop="link.taglist"}
-                        <span class="label label-tag" title="Add tag">
+                        <span class="label label-tag">
                           {$value}
                         </span>
                       {/loop}
   </div>
 </div>
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
 </body>
 </html>
 
index f589b06ead8b0f675c67f347c6b9360437d1bdb9..871a3ba7531abb0c2268ab424ef54ffb5b2abafd 100644 (file)
@@ -1,16 +1,35 @@
-<item>
-    <title>{$title} - {function="strftime('%A %e %B %Y', $daydate)"}</title>
-    <guid>{$absurl}</guid>
-    <link>{$absurl}</link>
-    <pubDate>{$rssdate}</pubDate>
-    <description><![CDATA[
-        {loop="links"}
-               <h3><a href="{$value.url}">{$value.title}</a></h3>
-               <small>{if="!$hide_timestamps"}{function="strftime('%c', $value.timestamp)"} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
-               {$value.url}</small><br>
-               {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
-               {if="$value.description"}{$value.formatedDescription}{/if}
-               <br><br><hr>
-        {/loop}
-    ]]></description>
-</item>
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>{$localizedType} - {$title}</title>
+    <link>{$index_url}</link>
+    <description>{function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}</description>
+    <language>{$language}</language>
+    <copyright>{$index_url}</copyright>
+    <generator>Shaarli</generator>
+
+    {loop="$days"}
+      <item>
+        <title>{$value.date_human} - {$title}</title>
+        <guid>{$value.absolute_url}</guid>
+        <link>{$value.absolute_url}</link>
+        <pubDate>{$value.date_rss}</pubDate>
+        <description><![CDATA[
+          {loop="$value.links"}
+            <h3><a href="{$value.url}">{$value.title}</a></h3>
+            <small>
+              {if="!$hide_timestamps"}{$value.created|format_date} &#8212; {/if}
+              <a href="{$index_url}shaare/{$value.shorturl}">{'Permalink'|t}</a>
+              {if="$value.tags"} &#8212; {$value.tags}{/if}
+              <br>
+              {$value.url}
+            </small><br>
+            {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
+            {if="$value.description"}{$value.description}{/if}
+            <br><hr>
+          {/loop}
+        ]]></description>
+      </item>
+    {/loop}
+  </channel>
+</rss><!-- Cached version of {$page_url} -->
diff --git a/tpl/default/editlink.batch.html b/tpl/default/editlink.batch.html
new file mode 100644 (file)
index 0000000..b1f8e5b
--- /dev/null
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+  {include="includes"}
+</head>
+<body>
+<div class="dark-layer">
+  <div class="screen-center">
+    <div><span class="progressbar-current"></span> / <span class="progressbar-max"></span></div>
+    <div class="progressbar">
+      <div></div>
+    </div>
+  </div>
+</div>
+
+{include="page.header"}
+
+<div class="center">
+  <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
+</div>
+
+{loop="$links"}
+  {include="editlink"}
+{/loop}
+
+<div class="center">
+  <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
+</div>
+
+{include="page.footer"}
+{if="$async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
+<script src="{$asset_path}/js/shaare_batch.min.js?v={$version_hash}#"></script>
index d16059a39b2dcfa204ddbc0ddff742eb812c7710..83e541fdf6b32aaf45a985a20645ab19c53bbc37 100644 (file)
@@ -1,3 +1,4 @@
+{if="empty($batch_mode)"}
 <!DOCTYPE html>
 <html{if="$language !== 'auto'"} lang="{$language}"{/if}>
 <head>
@@ -5,9 +6,19 @@
 </head>
 <body>
   {include="page.header"}
+{else}
+  {ignore}Lil hack: when included in a loop in batch mode, `$value` is assigned by RainTPL with template vars.{/ignore}
+  {function="extract($value) ? '' : ''"}
+{/if}
   <div id="editlinkform" class="edit-link-container" class="pure-g">
     <div class="pure-u-lg-1-5 pure-u-1-24"></div>
-    <form method="post" name="linkform" class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light">
+    <form method="post"
+          name="linkform"
+          action="{$base_path}/admin/shaare"
+          class="page-form pure-u-lg-3-5 pure-u-22-24 page-form page-form-light"
+    >
+      {$asyncLoadClass=$link_is_new && $async_metadata && empty($link.title) ? 'loading-input' : ''}
+
       <h2 class="window-title">
         {if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}
       </h2>
       <div>
       <label for="lf_title">{'Title'|t}</label>
       </div>
-      <div>
-        <input type="text" name="lf_title" id="lf_title" value="{$link.title}" class="lf_input autofocus">
+      <div class="{$asyncLoadClass}">
+        <input type="text" name="lf_title" id="lf_title" value="{$link.title}"
+         class="lf_input {if="!$async_metadata"}autofocus{/if}"
+        >
+        <div class="icon-container">
+          <i class="loader"></i>
+        </div>
       </div>
       <div>
         <label for="lf_description">{'Description'|t}</label>
       </div>
-      <div>
+      <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
         <textarea name="lf_description" id="lf_description" class="autofocus">{$link.description}</textarea>
+        <div class="icon-container">
+          <i class="loader"></i>
+        </div>
       </div>
       <div>
         <label for="lf_tags">{'Tags'|t}</label>
       </div>
-      <div>
+      <div class="{if="$retrieve_description"}{$asyncLoadClass}{/if}">
         <input type="text" name="lf_tags" id="lf_tags" value="{$link.tags}" class="lf_input autofocus"
           data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off" >
+        <div class="icon-container">
+          <i class="loader"></i>
+        </div>
       </div>
 
       <div>
         <input type="checkbox"  name="lf_private" id="lf_private"
-        {if="($link_is_new && $default_private_links || $link.private == true)"}
+        {if="$link.private === true"}
           checked="checked"
         {/if}>
         &nbsp;<label for="lf_private">{'Private'|t}</label>
 
 
       <div class="submit-buttons center">
+        {if="!empty($batch_mode)"}
+          <a href="#" class="button button-grey" name="cancel-batch-link"
+            title="{'Remove this bookmark from batch creation/modification.'}"
+          >
+            {'Cancel'|t}
+          </a>
+        {/if}
         <input type="submit" name="save_edit" class="" id="button-save-edit"
                value="{if="$link_is_new"}{'Save'|t}{else}{'Apply Changes'|t}{/if}">
         {if="!$link_is_new"}
-        <a href="?delete_link&amp;lf_linkdate={$link.id}&amp;token={$token}"
+        <a href="{$base_path}/admin/shaare/delete?id={$link.id}&amp;token={$token}"
            title="" name="delete_link" class="button button-red confirm-delete">
           {'Delete'|t}
         </a>
       </div>
 
       <input type="hidden" name="token" value="{$token}">
+      <input type="hidden" name="source" value="{$source}">
       {if="$http_referer"}
         <input type="hidden" name="returnurl" value="{$http_referer}">
       {/if}
     </form>
   </div>
+
+{if="empty($batch_mode)"}
   {include="page.footer"}
+  {if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
 </body>
 </html>
+{/if}
index ef1dfd73e1759ea7f60f3dc3ec503133bdd415a4..c3e0c3c1db3f914475fbf0d59fa6a3e349f684c1 100644 (file)
@@ -15,7 +15,7 @@
       </pre>
   {/if}
 
-  <img src="img/sad_star.png" alt="">
+  <img src="{$asset_path}/img/sad_star.png#" alt="">
 </div>
 {include="page.footer"}
 </body>
index 99c01b11523a39fa72bc7da02e3b4582d01faa7f..c9c92943e8f804910da93ed5154ce34bea5c8032 100644 (file)
@@ -6,14 +6,13 @@
 <body>
 {include="page.header"}
 
-<form method="GET" action="#" name="exportform" id="exportform">
+<form method="POST" action="{$base_path}/admin/export" name="exportform" id="exportform">
   <div class="pure-g">
     <div class="pure-u-lg-1-4 pure-u-1-24"></div>
     <div class="pure-u-lg-1-2 pure-u-22-24 page-form page-form-complete">
       <div>
         <h2 class="window-title">{"Export Database"|t}</h2>
       </div>
-      <input type="hidden" name="do" value="export">
       <input type="hidden" name="token" value="{$token}">
 
       <div class="pure-g">
index bcfa7012dbe2ca93924e03bcaeab8ba070a0d149..dd58bd1e5196e5e09514a2787cb78647dd6ef699 100644 (file)
@@ -6,6 +6,8 @@
     <updated>{$last_update}</updated>
   {/if}
   <link rel="self" href="{$self_link}#" />
+  <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
+        title="Shaarli search - {$shaarlititle}" />
   {loop="$plugins_feed_header"}
     {$value}
   {/loop}
index 66d9a8697b1ca9647305559c2da34f4a64d5ca14..85cec7f382ecf24e7a6ea23834ed448cff250605 100644 (file)
@@ -7,7 +7,9 @@
     <language>{$language}</language>
     <copyright>{$index_url}</copyright>
     <generator>Shaarli</generator>
-    <atom:link rel="self" href="{$self_link}"  />
+    <atom:link rel="self" href="{$self_link}" />
+    <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
+               title="Shaarli search - {$shaarlititle}" />
     {loop="$plugins_feed_header"}
       {$value}
     {/loop}
index c41afcdbda76b69c43d7900ed08b092dc8649226..156de71fc34d790c8400902a3c7944464a639a21 100644 (file)
@@ -6,7 +6,7 @@
 <body>
 {include="page.header"}
 
-<form method="POST" action="?do=import" enctype="multipart/form-data" name="uploadform" id="uploadform">
+<form method="POST" action="{$base_path}/admin/import" enctype="multipart/form-data" name="uploadform" id="uploadform">
   <div class="pure-g">
     <div class="pure-u-lg-1-4 pure-u-1-24"></div>
     <div class="pure-u-lg-1-2 pure-u-22-24 page-form page-form-complete">
index 3820a4f7ea14c6799820ede1c878b1be6b8810b5..3e3fb6640a52b366af7c3ad96b23484c5000f9b4 100644 (file)
@@ -3,21 +3,22 @@
 <meta name="format-detection" content="telephone=no" />
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <meta name="referrer" content="same-origin">
-<link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" />
-<link rel="alternate" type="application/rss+xml" href="{$feedurl}?do=rss{$searchcrits}#" title="RSS Feed" />
-<link href="img/favicon.png" rel="shortcut icon" type="image/png" />
-<link href="img/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180" />
-<link type="text/css" rel="stylesheet" href="css/shaarli.min.css?v={$version_hash}" />
-{if="$formatter==='markdown'"}
-  <link type="text/css" rel="stylesheet" href="css/markdown.min.css?v={$version_hash}" />
+<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" />
+<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" />
+<link href="{$asset_path}/img/favicon.png#" rel="shortcut icon" type="image/png" />
+<link href="{$asset_path}/img/apple-touch-icon.png#" rel="apple-touch-icon" sizes="180x180" />
+<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css?v={$version_hash}#" />
+{if="strpos($formatter, 'markdown') !== false"}
+  <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
 {/if}
 {loop="$plugins_includes.css_files"}
-  <link type="text/css" rel="stylesheet" href="{$value}?v={$version_hash}#"/>
+  <link type="text/css" rel="stylesheet" href="{$root_path}/{$value}?v={$version_hash}#"/>
 {/loop}
 {if="is_file('data/user.css')"}
-  <link type="text/css" rel="stylesheet" href="data/user.css#" />
+  <link type="text/css" rel="stylesheet" href="{$root_path}/data/user.css#" />
 {/if}
-<link rel="search" type="application/opensearchdescription+xml" href="?do=opensearch#" title="Shaarli search - {$shaarlititle}"/>
+<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
+      title="Shaarli search - {$shaarlititle}" />
 {if="! empty($links) && count($links) === 1"}
   {$link=reset($links)}
   <meta property="og:title" content="{$link.title}" />
index c6f501f0aa25c30c1dae2df8bab256027d89b838..4f98d49dff9f066d7ff50b8a73633b31ff2613a7 100644 (file)
@@ -10,7 +10,7 @@
 {$ratioLabelMobile='7-8'}
 {$ratioInputMobile='1-8'}
 
-<form method="POST" action="#" name="installform" id="installform">
+<form method="POST" action="{$base_path}/install" name="installform" id="installform">
 <div class="pure-g">
   <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-form-complete">
   </div>
 </div>
 </form>
+
+<div class="pure-g">
+  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+  <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-form-complete">
+    <h2 class="window-title">{'Server requirements'|t}</h2>
+
+    {include="server.requirements"}
+  </div>
+</div>
+
 {include="page.footer"}
 </body>
 </html>
index ffc236c71554035bc61b4dc00a812d905ec57d56..e1115d49b61469a74f7179b9dda3feccfb37baf9 100644 (file)
@@ -94,7 +94,9 @@
           {'tagged'|t}
           {loop="$exploded_tags"}
               <span class="label label-tag" title="{'Remove tag'|t}">
-                <a href="?removetag={function="urlencode($value)"}" aria-label="{'Remove tag'|t}">{$value}<span class="remove"><i class="fa fa-times" aria-hidden="true"></i></span></a>
+                <a href="{$base_path}/remove-tag/{function="$search_tags_url.$key1"}" aria-label="{'Remove tag'|t}">
+                  {$value}<span class="remove"><i class="fa fa-times" aria-hidden="true"></i></span>
+                </a>
               </span>
           {/loop}
         {/if}
       {$strAddTag=t('Add tag')}
       {$strToggleSticky=t('Toggle sticky')}
       {$strSticky=t('Sticky')}
+      {$strShaarePrivate=t('Share a private link')}
       {ignore}End of translations{/ignore}
       {loop="links"}
         <div class="anchor" id="{$value.shorturl}"></div>
 
         <div class="linklist-item linklist-item{if="$value.class"} {$value.class}{/if}" data-id="{$value.id}">
           <div class="linklist-item-title">
-            {if="$thumbnails_enabled && !empty($value.thumbnail)"}
-              <div class="linklist-item-thumbnail" style="width:{$thumbnails_width}px;height:{$thumbnails_height}px;">
+            {if="$thumbnails_enabled && $value.thumbnail !== false"}
+              <div
+                class="linklist-item-thumbnail {if="$value.thumbnail === null"}hidden{/if}"
+                style="width:{$thumbnails_width}px;height:{$thumbnails_height}px;"
+                {if="$value.thumbnail === null"}data-async-thumbnail="1"{/if}
+              >
                 <div class="thumbnail">
                   {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
                   <a href="{$value.real_url}" aria-hidden="true" tabindex="-1">
-                  <img data-src="{$value.thumbnail}#" class="b-lazy"
+                  <img data-src="{$root_path}/{$value.thumbnail}#" class="b-lazy"
                     src=""
                     alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
                   </a>
             </div>
 
             <h2>
-              <a href="{$value.real_url}">
+              <a href="{$value.real_url}" class="linklist-real-url">
                 {if="strpos($value.url, $value.shorturl) === false"}
                   <i class="fa fa-external-link" aria-hidden="true"></i>
                 {else}
                   <i class="fa fa-sticky-note" aria-hidden="true"></i>
                 {/if}
 
-                <span class="linklist-link">{$value.title}</span>
+                <span class="linklist-link">{$value.title_html}</span>
               </a>
             </h2>
           </div>
                 {$tag_counter=count($value.taglist)}
                 {loop="value.taglist"}
                   <span class="label label-tag" title="{$strAddTag}">
-                    <a href="?addtag={$value|urlencode}">{$value}</a>
+                    <a href="{$base_path}/add-tag/{$value1.taglist_urlencoded.$key2}">{$value1.taglist_html.$key2}</a>
                   </span>
                   {if="$tag_counter - 1 != $counter"}&middot;{/if}
                 {/loop}
                       <input type="checkbox" class="link-checkbox" value="{$value.id}">
                     </span>
                     <span class="linklist-item-infos-controls-item ctrl-edit">
-                      <a href="?edit_link={$value.id}" aria-label="{$strEdit}" title="{$strEdit}"><i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i></a>
+                      <a href="{$base_path}/admin/shaare/{$value.id}" aria-label="{$strEdit}" title="{$strEdit}"><i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i></a>
                     </span>
                     <span class="linklist-item-infos-controls-item ctrl-delete">
-                      <a href="?delete_link&amp;lf_linkdate={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
+                      <a href="{$base_path}/admin/shaare/delete?id={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
                          title="{$strDelete}" class="delete-link pure-u-0 pure-u-lg-visible confirm-delete">
                         <i class="fa fa-trash" aria-hidden="true"></i>
                       </a>
                     </span>
                     <span class="linklist-item-infos-controls-item ctrl-pin">
-                      <a href="?do=pin&amp;id={$value.id}&amp;token={$token}"
+                      <a href="{$base_path}/admin/shaare/{$value.id}/pin?token={$token}"
                          title="{$strToggleSticky}" aria-label="{$strToggleSticky}" class="pin-link {if="$value.sticky"}pinned-link{/if} pure-u-0 pure-u-lg-visible">
                         <i class="fa fa-thumb-tack" aria-hidden="true"></i>
                       </a>
                     </div>
                   {/if}
                 {/if}
-                <a href="?{$value.shorturl}" title="{$strPermalink}">
+                <a href="{$base_path}/shaare/{$value.shorturl}" title="{$strPermalink}">
                   {if="!$hide_timestamps || $is_logged_in"}
                     {$updated=$value.updated_timestamp ? $strEdited. format_date($value.updated) : $strPermalink}
                     <span class="linkdate" title="{$updated}">
                   {$strPermalinkLc}
                 </a>
 
+                {if="$is_logged_in && $value.private"}
+                  <a href="{$base_path}/admin/shaare/private/{$value.shorturl}?token={$token}" title="{$strShaarePrivate}">
+                    <i class="fa fa-share-alt"></i>
+                  </a>
+                {/if}
+
                 <div class="pure-u-0 pure-u-lg-visible">
                   {if="isset($value.link_plugin)"}
                     &middot;
                 {ignore}do not add space or line break between these div - Firefox issue{/ignore}
                 class="linklist-item-infos-url pure-u-lg-5-12 pure-u-1">
                 <a href="{$value.real_url}" aria-label="{$value.title}" title="{$value.title}">
-                  <i class="fa fa-link" aria-hidden="true"></i> {$value.url}
+                  <i class="fa fa-link" aria-hidden="true"></i> {$value.url_html}
                 </a>
                 <div class="linklist-item-buttons pure-u-0 pure-u-lg-visible">
                   <a href="#" aria-label="{$strFold}" title="{$strFold}" class="fold-button"><i class="fa fa-chevron-up" aria-hidden="true"></i></a>
                 {/if}
                 {if="$is_logged_in"}
                   &middot;
-                  <a href="?delete_link&amp;lf_linkdate={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
+                  <a href="{$base_path}/admin/shaare/delete?id={$value.id}&amp;token={$token}" aria-label="{$strDelete}"
                      title="{$strDelete}" class="delete-link confirm-delete">
                     <i class="fa fa-trash" aria-hidden="true"></i>
                   </a>
                   &middot;
-                  <a href="?edit_link={$value.id}" aria-label="{$strEdit}" title="{$strEdit}"><i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i></a>
+                  <a href="{$base_path}/admin/shaare/{$value.id}" aria-label="{$strEdit}" title="{$strEdit}">
+                    <i class="fa fa-pencil-square-o edit-link" aria-hidden="true"></i>
+                  </a>
+                  &middot;
+                  <a href="{$base_path}/admin/shaare/{$value.id}/pin?token={$token}"
+                     aria-label="{$strToggleSticky}"
+                     title="{$strToggleSticky}"
+                     class="pin-link {if="$value.sticky"}pinned-link{/if}"
+                  >
+                    <i class="fa fa-thumb-tack" aria-hidden="true"></i>
+                  </a>
                 {/if}
               </div>
             </div>
 </div>
 
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
+{if="$is_logged_in && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
 </body>
 </html>
index 68947f923a37afaa47e149bb2bd7cdbfd47a6901..aa637868eb94e2a91cb9e8a5af22011cb6bbc77f 100644 (file)
@@ -1,27 +1,29 @@
 <div class="linklist-paging">
   <div class="paging pure-g">
     <div class="linklist-filters pure-u-1-3">
-      {if="$is_logged_in or !empty($action_plugin)"}
-        <span class="linklist-filters-text pure-u-0 pure-u-lg-visible">
-          {'Filters'|t}
-        </span>
-        {if="$is_logged_in"}
-        <a href="?visibility=private" aria-label="{'Only display private links'|t}" title="{'Only display private links'|t}"
-           class="{if="$visibility==='private'"}filter-on{else}filter-off{/if}"
-        ><i class="fa fa-user-secret" aria-hidden="true"></i></a>
-        <a href="?visibility=public" aria-label="{'Only display public links'|t}" title="{'Only display public links'|t}"
-           class="{if="$visibility==='public'"}filter-on{else}filter-off{/if}"
-        ><i class="fa fa-globe" aria-hidden="true"></i></a>
-        {/if}
-        <a href="?untaggedonly" aria-label="{'Filter untagged links'|t}" title="{'Filter untagged links'|t}"
-           class={if="$untaggedonly"}"filter-on"{else}"filter-off"{/if}
-        ><i class="fa fa-tag" aria-hidden="true"></i></a>
-        <a href="#" aria-label="{'Select all'|t}" title="{'Select all'|t}"
-           class="filter-off select-all-button pure-u-0 pure-u-lg-visible"
-        ><i class="fa fa-check-square-o" aria-hidden="true"></i></a>
-        <a href="#" class="filter-off fold-all pure-u-lg-0" aria-label="{'Fold all'|t}" title="{'Fold all'|t}">
-          <i class="fa fa-chevron-up" aria-hidden="true"></i>
-        </a>
+      <span class="linklist-filters-text pure-u-0 pure-u-lg-visible">
+        {'Filters'|t}
+      </span>
+      {if="$is_logged_in"}
+      <a href="{$base_path}/admin/visibility/private" aria-label="{'Only display private links'|t}" title="{'Only display private links'|t}"
+         class="{if="$visibility==='private'"}filter-on{else}filter-off{/if}"
+      ><i class="fa fa-user-secret" aria-hidden="true"></i></a>
+      <a href="{$base_path}/admin/visibility/public" aria-label="{'Only display public links'|t}" title="{'Only display public links'|t}"
+         class="{if="$visibility==='public'"}filter-on{else}filter-off{/if}"
+      ><i class="fa fa-globe" aria-hidden="true"></i></a>
+      {/if}
+      <a href="{$base_path}/untagged-only" aria-label="{'Filter untagged links'|t}" title="{'Filter untagged links'|t}"
+         class={if="$untaggedonly"}"filter-on"{else}"filter-off"{/if}
+      ><i class="fa fa-tag" aria-hidden="true"></i></a>
+      {if="$is_logged_in"}
+      <a href="#" aria-label="{'Select all'|t}" title="{'Select all'|t}"
+         class="filter-off select-all-button pure-u-0 pure-u-lg-visible"
+      ><i class="fa fa-check-square-o" aria-hidden="true"></i></a>
+      {/if}
+      <a href="#" class="filter-off fold-all pure-u-lg-0" aria-label="{'Fold all'|t}" title="{'Fold all'|t}">
+        <i class="fa fa-chevron-up" aria-hidden="true"></i>
+      </a>
+      {if="!empty($action_plugin)"}
         {loop="$action_plugin"}
           {$value.attr.class=isset($value.attr.class) ? $value.attr.class : ''}
           {$value.attr.class=!empty($value.on) ? $value.attr.class .' filter-on' : $value.attr.class .' filter-off'}
 
     <div class="linksperpage pure-u-1-3">
       <div class="pure-u-0 pure-u-lg-visible">{'Links per page'|t}</div>
-      <a href="?linksperpage=20">20</a>
-      <a href="?linksperpage=50">50</a>
-      <a href="?linksperpage=100">100</a>
-      <form method="GET" class="pure-u-0 pure-u-lg-visible">
-        <input type="text" name="linksperpage" placeholder="133">
+      <a href="{$base_path}/links-per-page?nb=20"
+                       {if="$links_per_page == 20"}class="selected"{/if}>20</a>
+      <a href="{$base_path}/links-per-page?nb=50"
+                       {if="$links_per_page == 50"}class="selected"{/if}>50</a>
+      <a href="{$base_path}/links-per-page?nb=100"
+                       {if="$links_per_page == 100"}class="selected"{/if}>100</a>
+      <form method="GET" class="pure-u-0 pure-u-lg-visible" action="{$base_path}/links-per-page">
+        <input type="text" name="nb" placeholder="133"
+                       {if="$links_per_page != 20 && $links_per_page != 50 && $links_per_page != 100"}
+                               value="{$links_per_page}"{/if}>
       </form>
       <a href="#" class="filter-off fold-all pure-u-0 pure-u-lg-visible" aria-label="{'Fold all'|t}" title="{'Fold all'|t}">
         <i class="fa fa-chevron-up" aria-hidden="true"></i>
index 3fcc30b74fdc2bc150687dcabc12f89b62222c54..1c7f279b0d061ff74b310cf79986ebfa57ca0464 100644 (file)
@@ -3,8 +3,8 @@
     <ShortName>Shaarli search - {$pagetitle}</ShortName>
     <Description>Shaarli search - {$pagetitle}</Description>
     <Url type="text/html" template="{$serverurl}?searchterm={searchTerms}" />
-    <Url type="application/atom+xml" template="{$serverurl}?do=atom&amp;searchterm={searchTerms}"/>
-    <Url type="application/rss+xml" template="{$serverurl}?do=rss&amp;searchterm={searchTerms}"/>
+    <Url type="application/atom+xml" template="{$serverurl}feed/atom?searchterm={searchTerms}"/>
+    <Url type="application/rss+xml" template="{$serverurl}feed/rss?searchterm={searchTerms}"/>
     <InputEncoding>UTF-8</InputEncoding>
     <Developer>Shaarli Community - https://github.com/shaarli/Shaarli/</Developer>
     <Image width="16" height="16">data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAHRklE
index 0899826b66f7b2f73d1437f2e4c49a27e47f8cbd..c153def0450419477dccc8c56cf19c3f146f35e8 100644 (file)
@@ -10,7 +10,7 @@
     {/if}
     &middot;
     {'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t} &middot;
-    <a href="doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
+    <a href="{$root_path}/doc/html/index.html" rel="nofollow">{'Documentation'|t}</a>
       {loop="$plugins_footer.text"}
           {$value}
       {/loop}
@@ -25,7 +25,7 @@
 {/loop}
 
 {loop="$plugins_footer.js_files"}
-       <script src="{$value}#"></script>
+       <script src="{$root_path}/{$value}#"></script>
 {/loop}
 
 <div id="js-translations" class="hidden">
   <span id="translation-fold-all">{'Fold all'|t}</span>
   <span id="translation-expand">{'Expand'|t}</span>
   <span id="translation-expand-all">{'Expand all'|t}</span>
-  <span id="translation-delete-link">{'Are you sure you want to delete this link?'|t}</span>
+  <span id="translation-delete-link">{'Are you sure you want to delete this tag?'|t}</span>
   <span id="translation-shaarli-desc">
     {'The personal, minimalist, super-fast, database free, bookmarking service'|t} {'by the Shaarli community'|t}
   </span>
 </div>
 
-<script src="js/shaarli.min.js?v={$version_hash}"></script>
+<input type="hidden" name="js_base_path" value="{$base_path}" />
+<script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script>
index 82f8ebf1fa164f947221ae36bdcd994c8443adc8..a71464c71c3faf000c5f46d0807d9a0106ce3bca 100644 (file)
         </li>
         {if="$is_logged_in || $openshaarli"}
           <li class="pure-menu-item">
-            <a href="?do=addlink" class="pure-menu-link" id="shaarli-menu-shaare">
+            <a href="{$base_path}/admin/add-shaare" class="pure-menu-link" id="shaarli-menu-shaare">
               <i class="fa fa-plus" aria-hidden="true"></i> {'Shaare'|t}
             </a>
           </li>
           <li class="pure-menu-item" id="shaarli-menu-tools">
-            <a href="?do=tools" class="pure-menu-link">{'Tools'|t}</a>
+            <a href="{$base_path}/admin/tools" class="pure-menu-link">{'Tools'|t}</a>
           </li>
         {/if}
         <li class="pure-menu-item" id="shaarli-menu-tags">
-          <a href="?do=tagcloud" class="pure-menu-link">{'Tag cloud'|t}</a>
+          <a href="{$base_path}/tags/cloud" class="pure-menu-link">{'Tag cloud'|t}</a>
         </li>
         {if="$thumbnails_enabled"}
           <li class="pure-menu-item" id="shaarli-menu-picwall">
-            <a href="?do=picwall{$searchcrits}" class="pure-menu-link">{'Picture wall'|t}</a>
+            <a href="{$base_path}/picture-wall?{function="ltrim($searchcrits, '&')"}" class="pure-menu-link">{'Picture wall'|t}</a>
           </li>
         {/if}
         <li class="pure-menu-item" id="shaarli-menu-daily">
-          <a href="?do=daily" class="pure-menu-link">{'Daily'|t}</a>
+          <a href="{$base_path}/daily" class="pure-menu-link">{'Daily'|t}</a>
         </li>
         {loop="$plugins_header.buttons_toolbar"}
           <li class="pure-menu-item shaarli-menu-plugin">
           </li>
         {/loop}
         <li class="pure-menu-item pure-u-lg-0 shaarli-menu-mobile" id="shaarli-menu-mobile-rss">
-            <a href="?do={$feed_type}{$searchcrits}" class="pure-menu-link">{'RSS Feed'|t}</a>
+            <a href="{$base_path}/feed/{$feed_type}?{$searchcrits}" class="pure-menu-link">{'RSS Feed'|t}</a>
         </li>
         {if="$is_logged_in"}
           <li class="pure-menu-item pure-u-lg-0 shaarli-menu-mobile" id="shaarli-menu-mobile-logout">
-            <a href="?do=logout" class="pure-menu-link">{'Logout'|t}</a>
+            <a href="{$base_path}/admin/logout" class="pure-menu-link">{'Logout'|t}</a>
           </li>
         {else}
           <li class="pure-menu-item pure-u-lg-0 shaarli-menu-mobile" id="shaarli-menu-mobile-login">
-            <a href="/login" class="pure-menu-link">{'Login'|t}</a>
+            <a href="{$base_path}/login" class="pure-menu-link">{'Login'|t}</a>
           </li>
         {/if}
       </ul>
             </a>
           </li>
           <li class="pure-menu-item" id="shaarli-menu-desktop-rss">
-            <a href="?do={$feed_type}{$searchcrits}" class="pure-menu-link" title="{'RSS Feed'|t}" aria-label="{'RSS Feed'|t}">
+            <a href="{$base_path}/feed/{$feed_type}?{$searchcrits}" class="pure-menu-link" title="{'RSS Feed'|t}" aria-label="{'RSS Feed'|t}">
               <i class="fa fa-rss" aria-hidden="true"></i>
             </a>
           </li>
           {if="!$is_logged_in"}
             <li class="pure-menu-item" id="shaarli-menu-desktop-login">
-              <a href="/login" class="pure-menu-link"
+              <a href="{$base_path}/login" class="pure-menu-link"
                  data-open-id="header-login-form"
                  id="login-button" aria-label="{'Login'|t}" title="{'Login'|t}">
                 <i class="fa fa-user" aria-hidden="true"></i>
@@ -88,7 +88,7 @@
             </li>
           {else}
             <li class="pure-menu-item" id="shaarli-menu-desktop-logout">
-              <a href="?do=logout" class="pure-menu-link" aria-label="{'Logout'|t}" title="{'Logout'|t}">
+              <a href="{$base_path}/admin/logout" class="pure-menu-link" aria-label="{'Logout'|t}" title="{'Logout'|t}">
                 <i class="fa fa-sign-out" aria-hidden="true"></i>
               </a>
             </li>
 
 <main id="content" class="container" role="main">
   <div id="search" class="subheader-form searchform-block header-search">
-    <form method="GET" class="pure-form searchform" name="searchform">
+    <form method="GET" class="pure-form searchform" name="searchform" action="{$base_path}/">
       <input type="text" id="searchform_value" name="searchterm" aria-label="{'Search text'|t}" placeholder="{'Search text'|t}"
              {if="!empty($search_term)"}
              value="{$search_term}"
   </div>
 {/if}
 
-{if="!empty($global_warnings) && $is_logged_in"}
-  <div class="pure-g pure-alert pure-alert-warning pure-alert-closable" id="shaarli-warnings-alert">
+{if="!empty($global_errors)"}
+  <div class="pure-g header-alert-message pure-alert pure-alert-error pure-alert-closable" id="shaarli-errors-alert">
+  <div class="pure-u-2-24"></div>
+    <div class="pure-u-20-24">
+      {loop="$global_errors"}
+        <p>{$value}</p>
+      {/loop}
+    </div>
+    <div class="pure-u-2-24">
+      <i class="fa fa-times pure-alert-close"></i>
+    </div>
+  </div>
+{/if}
+
+{if="!empty($global_warnings)"}
+  <div class="pure-g header-alert-message pure-alert pure-alert-warning pure-alert-closable" id="shaarli-warnings-alert">
     <div class="pure-u-2-24"></div>
     <div class="pure-u-20-24">
       {loop="global_warnings"}
   </div>
 {/if}
 
+{if="!empty($global_successes)"}
+  <div class="pure-g header-alert-message new-version-message pure-alert pure-alert-success pure-alert-closable" id="shaarli-success-alert">
+    <div class="pure-u-2-24"></div>
+    <div class="pure-u-20-24">
+      {loop="$global_successes"}
+      <p>{$value}</p>
+      {/loop}
+    </div>
+    <div class="pure-u-2-24">
+      <i class="fa fa-times pure-alert-close"></i>
+    </div>
+  </div>
+{/if}
+
   <div class="clear"></div>
index 73359949ce433cb96dc0e6510c56e6e22933602c..ac613b35d56080bf0c4a0f303324871082d2e9d4 100644 (file)
@@ -5,61 +5,55 @@
 </head>
 <body>
 {include="page.header"}
-{if="!$thumbnails_enabled"}
-<div class="pure-g pure-alert pure-alert-warning page-single-alert">
-  <div class="pure-u-1 center">
-    {'Picture wall unavailable (thumbnails are disabled).'|t}
-  </div>
-</div>
-{else}
-  {if="count($linksToDisplay)===0 && $is_logged_in"}
-    <div class="pure-g pure-alert pure-alert-warning page-single-alert">
-      <div class="pure-u-1 center">
-        {'There is no cached thumbnail. Try to <a href="?do=thumbs_update">synchronize them</a>.'|t}
-      </div>
+
+{if="count($linksToDisplay)===0 && $is_logged_in"}
+  <div class="pure-g pure-alert pure-alert-warning page-single-alert">
+    <div class="pure-u-1 center">
+      {'There is no cached thumbnail.'|t}
+      <a href="{$base_path}/admin/thumbnails">{'Try to synchronize them.'|t}</a>
     </div>
-  {/if}
+  </div>
+{/if}
 
-  <div class="pure-g">
-    <div class="pure-u-lg-1-6 pure-u-1-24"></div>
-    <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
-      {$countPics=count($linksToDisplay)}
-      <h2 class="window-title">{'Picture Wall'|t} - {$countPics} {'pics'|t}</h2>
+<div class="pure-g">
+  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+  <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
+    {$countPics=count($linksToDisplay)}
+    <h2 class="window-title">{'Picture Wall'|t} - {$countPics} {'pics'|t}</h2>
 
-      <div id="plugin_zone_start_picwall" class="plugin_zone">
-        {loop="$plugin_start_zone"}
-          {$value}
-        {/loop}
-      </div>
+    <div id="plugin_zone_start_picwall" class="plugin_zone">
+      {loop="$plugin_start_zone"}
+        {$value}
+      {/loop}
+    </div>
 
-      <div id="picwall-container" class="picwall-container" role="list">
-        {loop="$linksToDisplay"}
-          <div class="picwall-pictureframe" role="listitem">
-            {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
-            <img data-src="{$value.thumbnail}#" class="b-lazy"
-                 src=""
-                 alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
-            <a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
-            {loop="$value.picwall_plugin"}
-              {$value}
-            {/loop}
-          </div>
-        {/loop}
-        <div class="clear"></div>
-      </div>
+    <div id="picwall-container" class="picwall-container" role="list">
+      {loop="$linksToDisplay"}
+        <div class="picwall-pictureframe" role="listitem">
+          {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
+          <img data-src="{$root_path}/{$value.thumbnail}#" class="b-lazy"
+               src=""
+               alt="" width="{$thumbnails_width}" height="{$thumbnails_height}" />
+          <a href="{$value.real_url}"><span class="info">{$value.title}</span></a>
+          {loop="$value.picwall_plugin"}
+            {$value}
+          {/loop}
+        </div>
+      {/loop}
+      <div class="clear"></div>
+    </div>
 
-      <div id="plugin_zone_end_picwall" class="plugin_zone">
-        {loop="$plugin_end_zone"}
-          {$value}
-        {/loop}
-      </div>
+    <div id="plugin_zone_end_picwall" class="plugin_zone">
+      {loop="$plugin_end_zone"}
+        {$value}
+      {/loop}
     </div>
-    <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   </div>
-{/if}
+  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+</div>
 
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
 </body>
 </html>
 
index 4bfaa934381893d09dd46388b34cd4ac3d4448e3..5c073da645d4dcf0934dfce24773d4990f2dac19 100644 (file)
@@ -16,7 +16,7 @@
   <div class="clear"></div>
 </noscript>
 
-<form method="POST" action="?do=save_pluginadmin" name="pluginform" id="pluginform" class="pluginform-container">
+<form method="POST" action="{$base_path}/admin/plugins" name="pluginform" id="pluginform" class="pluginform-container">
   <div class="pure-g">
     <div class="pure-u-lg-1-8 pure-u-1-24"></div>
     <div class="pure-u-lg-3-4 pure-u-22-24 page-form page-form-complete">
 
       <div class="center more">
         {"More plugins available"|t}
-        <a href="doc/html/Community-&-Related-software/#third-party-plugins">{"in the documentation"|t}</a>.
+        <a href="{$root_path}/doc/html/Community-&-Related-software/#third-party-plugins">{"in the documentation"|t}</a>.
       </div>
       <div class="center">
         <input type="submit" value="{'Save'|t}" name="save">
   <input type="hidden" name="token" value="{$token}">
 </form>
 
-<form action="?do=save_pluginadmin" method="POST">
+<form action="{$base_path}/admin/plugins" method="POST">
   <div class="pure-g">
     <div class="pure-u-lg-1-8 pure-u-1-24"></div>
     <div class="pure-u-lg-3-4 pure-u-22-24 page-form page-form-light">
       </section>
     </div>
   </div>
+  <input type="hidden" name="token" value="{$token}">
 </form>
 
 {include="page.footer"}
-<script src="js/pluginsadmin.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/pluginsadmin.min.js?v={$version_hash}#"></script>
 
 </body>
 </html>
diff --git a/tpl/default/server.html b/tpl/default/server.html
new file mode 100644 (file)
index 0000000..de1c8b5
--- /dev/null
@@ -0,0 +1,129 @@
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+  {include="includes"}
+</head>
+<body>
+{include="page.header"}
+
+<div class="pure-g">
+  <div class="pure-u-lg-1-4 pure-u-1-24"></div>
+  <div class="pure-u-lg-1-2 pure-u-22-24 page-form server-tables-page">
+    <h2 class="window-title">{'Server administration'|t}</h2>
+
+    <h3 class="window-subtitle">{'General'|t}</h3>
+
+    <div class="pure-g server-row">
+      <div class="pure-u-lg-1-2 pure-u-1 server-label">
+        <p>{'Index URL'|t}</p>
+      </div>
+      <div class="pure-u-lg-1-2 pure-u-1">
+        <p><a href="{$index_url}" title="{$pagetitle}">{$index_url}</a></p>
+      </div>
+    </div>
+    <div class="pure-g server-row">
+      <div class="pure-u-lg-1-2 pure-u-1 server-label">
+        <p>{'Base path'|t}</p>
+      </div>
+      <div class="pure-u-lg-1-2 pure-u-1">
+        <p>{$base_path}</p>
+      </div>
+    </div>
+    <div class="pure-g server-row">
+      <div class="pure-u-lg-1-2 pure-u-1 server-label">
+        <p>{'Client IP'|t}</p>
+      </div>
+      <div class="pure-u-lg-1-2 pure-u-1">
+        <p>{$client_ip}</p>
+      </div>
+    </div>
+    <div class="pure-g server-row">
+      <div class="pure-u-lg-1-2 pure-u-1 server-label">
+        <p>{'Trusted reverse proxies'|t}</p>
+      </div>
+      <div class="pure-u-lg-1-2 pure-u-1">
+        {if="count($trusted_proxies) > 0"}
+        <p>
+          {loop="$trusted_proxies"}
+          {$value}<br>
+          {/loop}
+        </p>
+        {else}
+        <p>{'N/A'|t}</p>
+        {/if}
+      </div>
+    </div>
+
+    {include="server.requirements"}
+
+    <h3 class="window-subtitle">Version</h3>
+
+    <div class="pure-g server-row">
+      <div class="pure-u-lg-1-2 pure-u-1 server-label">
+        <p>Current version</p>
+      </div>
+      <div class="pure-u-lg-1-2 pure-u-1">
+        <p>{$current_version}</p>
+      </div>
+    </div>
+
+    <div class="pure-g server-row">
+      <div class="pure-u-lg-1-2 pure-u-1 server-label">
+        <p>Latest release</p>
+      </div>
+      <div class="pure-u-lg-1-2 pure-u-1">
+        <p>
+          <a href="{$release_url}" title="{'Visit releases page on Github'|t}">
+            {$latest_version}
+          </a>
+        </p>
+      </div>
+    </div>
+
+    <h3 class="window-subtitle">Thumbnails</h3>
+
+    <div class="pure-g server-row">
+      <div class="pure-u-lg-1-2 pure-u-1 server-label">
+        <p>Thumbnails status</p>
+      </div>
+      <div class="pure-u-lg-1-2 pure-u-1">
+        <p>
+          {if="$thumbnails_mode==='all'"}
+            {'All'|t}
+          {elseif="$thumbnails_mode==='common'"}
+            {'Only common media hosts'|t}
+          {else}
+            {'None'|t}
+          {/if}
+        </p>
+      </div>
+    </div>
+
+    {if="$thumbnails_mode!=='none'"}
+    <div class="center tools-item">
+      <a href="{$base_path}/admin/thumbnails" title="{'Synchronize all link thumbnails'|t}">
+        <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
+      </a>
+    </div>
+    {/if}
+
+    <h3 class="window-subtitle">Cache</h3>
+
+    <div class="center tools-item">
+      <a href="{$base_path}/admin/clear-cache?type=main">
+        <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Clear main cache</span>
+      </a>
+    </div>
+
+    <div class="center tools-item">
+      <a href="{$base_path}/admin/clear-cache?type=thumbnails">
+        <span class="pure-button pure-u-lg-2-3 pure-u-3-4">Clear thumbnails cache</span>
+      </a>
+    </div>
+  </div>
+</div>
+
+{include="page.footer"}
+
+</body>
+</html>
diff --git a/tpl/default/server.requirements.html b/tpl/default/server.requirements.html
new file mode 100644 (file)
index 0000000..85def9b
--- /dev/null
@@ -0,0 +1,68 @@
+<div class="server-tables">
+  <h3 class="window-subtitle">{'Permissions'|t}</h3>
+
+  {if="count($permissions) > 0"}
+    <p class="center">
+      <i class="fa fa-close fa-color-red" aria-hidden="true"></i>
+      {'There are permissions that need to be fixed.'|t}
+    </p>
+
+    <p>
+      {loop="$permissions"}
+        <div class="center">{$value}</div>
+      {/loop}
+    </p>
+  {else}
+    <p class="center">
+      <i class="fa fa-check fa-color-green" aria-hidden="true"></i>
+      {'All read/write permissions are properly set.'|t}
+    </p>
+  {/if}
+
+  <h3 class="window-subtitle">PHP</h3>
+
+  <p class="center">
+    <strong>{'Running PHP'|t} {$php_version}</strong>
+    {if="$php_has_reached_eol"}
+    <i class="fa fa-circle fa-color-orange" aria-label="hidden"></i><br>
+    {'End of life: '|t} {$php_eol}
+    {else}
+    <i class="fa fa-circle fa-color-green" aria-label="hidden"></i><br>
+    {/if}
+  </p>
+
+  <table class="center">
+    <thead>
+      <tr>
+        <th>{'Extension'|t}</th>
+        <th>{'Usage'|t}</th>
+        <th>{'Status'|t}</th>
+        <th>{'Loaded'|t}</th>
+      </tr>
+    </thead>
+    <tbody>
+      {loop="$php_extensions"}
+        <tr>
+          <td>{$value.name}</td>
+          <td>{$value.desc}</td>
+          <td>{$value.required ? t('Required') : t('Optional')}</td>
+          <td>
+            {if="$value.loaded"}
+              {$classLoaded="fa-color-green"}
+              {$strLoaded=t('Loaded')}
+            {else}
+              {$strLoaded=t('Not loaded')}
+              {if="$value.required"}
+                {$classLoaded="fa-color-red"}
+              {else}
+                {$classLoaded="fa-color-orange"}
+              {/if}
+            {/if}
+
+            <i class="fa fa-circle {$classLoaded}" aria-label="{$strLoaded}" title="{$strLoaded}"></i>
+          </td>
+        </tr>
+      {/loop}
+    </tbody>
+  </table>
+</div>
index 7839fcca78e4b1b5f6c408ae190a576a083ac4da..c067e1d459ed76dc232cc8e5f706e1f9a897306f 100644 (file)
@@ -15,7 +15,7 @@
     <h2 class="window-title">{'Tag cloud'|t} - {$countTags} {'tags'|t}</h2>
     {if="!empty($search_tags)"}
     <p class="center">
-      <a href="?searchtags={$search_tags|urlencode}" class="pure-button pure-button-shaarli">
+      <a href="{$base_path}/?searchtags={$search_tags_url}" class="pure-button pure-button-shaarli">
         {'List all links with those tags'|t}
       </a>
     </p>
@@ -48,8 +48,8 @@
 
     <div id="cloudtag" class="cloudtag-container">
       {loop="tags"}
-        <a href="?searchtags={$key|urlencode} {$search_tags|urlencode}" style="font-size:{$value.size}em;">{$key}</a
-        ><a href="?addtag={$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
+        <a href="{$base_path}/?searchtags={$tags_url.$key1} {$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a
+        ><a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
         {loop="$value.tag_plugin"}
           {$value}
         {/loop}
index d5777465ce3177e62fc59f52c0cd70506e32306b..96e7fbe0da8c752779e35fb7af29a6b6f45b0d58 100644 (file)
@@ -15,7 +15,7 @@
     <h2 class="window-title">{'Tag list'|t} - {$countTags} {'tags'|t}</h2>
     {if="!empty($search_tags)"}
       <p class="center">
-        <a href="?searchtags={$search_tags|urlencode}" class="pure-button pure-button-shaarli">
+        <a href="{$base_path}/?searchtags={$search_tags_url}" class="pure-button pure-button-shaarli">
           {'List all links with those tags'|t}
         </a>
       </p>
 
     <div id="taglist" class="taglist-container">
       {loop="tags"}
-        <div class="tag-list-item pure-g" data-tag="{$key}">
+        <div class="tag-list-item pure-g" data-tag="{$key}" data-tag-url="{$tags_url.$key1}">
           <div class="pure-u-1">
             {if="$is_logged_in===true"}
               <a href="#" class="delete-tag" aria-label="{'Delete'|t}"><i class="fa fa-trash" aria-hidden="true"></i></a>&nbsp;&nbsp;
-              <a href="?do=changetag&fromtag={$key|urlencode}" class="rename-tag" aria-label="{'Rename tag'|t}">
+              <a href="{$base_path}/admin/tags?fromtag={$tags_url.$key1}" class="rename-tag" aria-label="{'Rename tag'|t}">
                 <i class="fa fa-pencil-square-o {$key}" aria-hidden="true"></i>
               </a>
             {/if}
 
-            <a href="?addtag={$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value}</a>
-            <a href="?searchtags={$key|urlencode} {$search_tags|urlencode}" class="tag-link">{$key}</a>
+            <a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value}</a>
+            <a href="{$base_path}/?searchtags={$tags_url.$key1} {$search_tags_url}" class="tag-link">{$key}</a>
 
             {loop="$value.tag_plugin"}
               {$value}
index d24c9f645ae3f9feecc541bb8ff22eeb2c258cbe..b3764e290082ae26e5b9b8c0ccc16f6e7759f414 100644 (file)
@@ -1,8 +1,8 @@
 <div class="pure-g">
   <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
     {'Sort by:'|t}
-    <a href="?do=tagcloud">{'Cloud'|t}</a> &middot;
-    <a href="?do=taglist&sort=usage">{'Most used'|t}</a> &middot;
-    <a href="?do=taglist&sort=alpha">{'Alphabetical'|t}</a>
+    <a href="{$base_path}/tags/cloud">{'Cloud'|t}</a>
+    <a href="{$base_path}/tags/list?sort=usage">{'Most used'|t}</a>
+    <a href="{$base_path}/tags/list?sort=alpha">{'Alphabetical'|t}</a>
   </div>
-</div>
\ No newline at end of file
+</div>
index 5f9bef08e48b91b806c03894bcb19dc7d7bf1253..504644ca84fa00d60ccca5fe2f5be98bc3b3ebcf 100644 (file)
@@ -43,6 +43,6 @@
 </div>
 
 {include="page.footer"}
-<script src="js/thumbnails_update.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails_update.min.js?v={$version_hash}#"></script>
 </body>
 </html>
index 20d0c893cb8d80d7dce271d8937189daa7775ac1..2df73598173ae522306ae1007ba5188824dcfbd8 100644 (file)
   <div class="pure-u-lg-1-3 pure-u-22-24 page-form page-form-light">
     <h2 class="window-title">{'Settings'|t}</h2>
     <div class="tools-item">
-      <a href="?do=configure" title="{'Change Shaarli settings: title, timezone, etc.'|t}">
+      <a href="{$base_path}/admin/configure" title="{'Change Shaarli settings: title, timezone, etc.'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Configure your Shaarli'|t}</span>
       </a>
     </div>
     <div class="tools-item">
-      <a href="?do=pluginadmin" title="{'Enable, disable and configure plugins'|t}">
+      <a href="{$base_path}/admin/plugins" title="{'Enable, disable and configure plugins'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Plugin administration'|t}</span>
       </a>
     </div>
+    <div class="tools-item">
+      <a href="{$base_path}/admin/server"
+         title="{'Check instance\'s server configuration'|t}">
+        <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Server administration'|t}</span>
+      </a>
+    </div>
     {if="!$openshaarli"}
       <div class="tools-item">
-        <a href="?do=changepasswd" title="{'Change your password'|t}">
+        <a href="{$base_path}/admin/password" title="{'Change your password'|t}">
           <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Change password'|t}</span>
         </a>
       </div>
     {/if}
     <div class="tools-item">
-      <a href="?do=changetag" title="{'Rename or delete a tag in all links'|t}">
+      <a href="{$base_path}/admin/tags" title="{'Rename or delete a tag in all links'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Manage tags'|t}</span>
       </a>
     </div>
     <div class="tools-item">
-      <a href="?do=import"
+      <a href="{$base_path}/admin/import"
          title="{'Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, delicious...)'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Import links'|t}</span>
       </a>
     </div>
     <div class="tools-item">
-      <a href="?do=export"
+      <a href="{$base_path}/admin/export"
          title="{'Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)'|t}">
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Export database'|t}</span>
       </a>
     </div>
 
-    {if="$thumbnails_enabled"}
-      <div class="tools-item">
-        <a href="?do=thumbs_update" title="{'Synchronize all link thumbnails'|t}">
-          <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
-        </a>
-      </div>
-    {/if}
-
     {loop="$tools_plugin"}
       <div class="tools-item">
         {$value}
@@ -86,7 +84,7 @@
               alert('{function="str_replace(' ', '%20', t('The selected text is too long, it will be truncated.'))"}');
             }
             window.open(
-              '{$pageabsaddr}?post='%20+%20encodeURIComponent(url)+
+              '{$pageabsaddr}admin/shaare?post='%20+%20encodeURIComponent(url)+
               '&amp;title='%20+%20encodeURIComponent(title)+
               '&amp;description='%20+%20encodeURIComponent(desc)+
               '&amp;source=bookmarklet','_blank','menubar=no,height=800,width=600,toolbar=no,scrollbars=yes,status=no,dialog=1'
index 53e98e2e0f36ffb61135122fea42f9461930b9b0..0fef0f08be9a3b3e1ca7035a5b564a8247647c51 100644 (file)
@@ -10,7 +10,7 @@
 <div class="error-container">
     <h1>404 Not found <small>Oh crap!</small></h1>
     <p>{$error_message}</p>
-    <p>Would you mind <a href="?">clicking here</a>?</p>
+    <p>Would you mind <a href="{$base_path}/">clicking here</a>?</p>
 </div>
 {include="page.footer"}
 </body>
index da50f45e208029a704e24dd0bf44a31c4a73ddd1..ade08c7c69cfc108e1944b96d036dfc2e451edc3 100644 (file)
@@ -5,7 +5,7 @@
 <div id="pageheader">
        {include="page.header"}
        <div id="headerform">
-               <form method="GET" action="" name="addform" class="addform">
+               <form method="GET" action="{$base_path}/admin/shaare" name="addform" class="addform">
                        <input type="text" name="post" class="linkurl">
                        <input type="submit" value="Add link" class="bigbutton">
                </form>
index c40daf9d385b781fe3c2c1bbfa713b00a2164ba9..7e37b9a3459b7e4f8b6e862cfbe1491be88dcb36 100644 (file)
@@ -4,7 +4,7 @@
 <body onload="document.changepasswordform.oldpassword.focus();">
 <div id="pageheader">
        {include="page.header"}
-       <form method="POST" action="#" name="changepasswordform" id="changepasswordform">
+       <form method="POST" action="{$base_path}/admin/password" name="changepasswordform" id="changepasswordform">
        Old password: <input type="password" name="oldpassword">&nbsp; &nbsp;
        New password: <input type="password" name="setpassword">
        <input type="hidden" name="token" value="{$token}">
@@ -12,4 +12,4 @@
 </div>
 {include="page.footer"}
 </body>
-</html>
\ No newline at end of file
+</html>
index 670a8dd7309c2d73b92927aae72569b434dec351..6ef60252d32394b1b5a5760c9abbacb829e90ec0 100644 (file)
@@ -5,7 +5,7 @@
 <body onload="document.changetag.fromtag.focus();">
 <div id="pageheader">
        {include="page.header"}
-       <form method="POST" action="" name="changetag" id="changetag">
+       <form method="POST" action="{$base_path}/admin/tags" name="changetag" id="changetag">
         <input type="hidden" name="token" value="{$token}">
         <div>
             <label for="fromtag">Tag:</label>
index 53b0cad20ef6f3b74b424f271cf854a151280a47..ba4f3f71c02fd38e359498a95f09a7d9ae2e3198 100644 (file)
@@ -4,7 +4,7 @@
 <body onload="document.configform.title.focus();">
 <div id="pageheader">
   {include="page.header"}
-  <form method="POST" action="#" name="configform" id="configform">
+  <form method="POST" action="{$base_path}/admin/configure" name="configform" id="configform">
     <input type="hidden" name="token" value="{$token}">
     <table id="configuration_table">
 
@@ -16,7 +16,7 @@
       <tr>
         <td><b>Home link:</b></td>
         <td><input type="text" name="titleLink" id="titleLink" size="50" value="{$titleLink}"><br/><label
-            for="titleLink">(default value is: ?)</label></td>
+            for="titleLink">(default value is: {$base_path}/)</label></td>
       </tr>
 
       <tr>
             {if="! $gd_enabled"}
               {'You need to enable the extension <code>php-gd</code> to use thumbnails.'|t}
             {elseif="$thumbnails_enabled"}
-              <a href="?do=thumbs_update">{'Synchonize thumbnails'|t}</a>
+              <a href="{$base_path}/admin/thumbnails">{'Synchonize thumbnails'|t}</a>
             {/if}
           </label>
         </td>
index 00f18e26c97951330d9439a1cbb318ab67ed6a3c..74f6cdc74417194f9931a1ceabdd51b701575b4f 100644 (file)
@@ -14,9 +14,9 @@
 
     <div class="dailyAbout">
         All links of one day<br>in a single page.<br>
-        {if="$previousday"} <a href="?do=daily&amp;day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if}
+        {if="$previousday"} <a href="{$base_path}/daily&amp;day={$previousday}"><b>&lt;</b>Previous day</a>{else}<b>&lt;</b>Previous day{/if}
         -
-        {if="$nextday"}<a href="?do=daily&amp;day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</b>{/if}
+        {if="$nextday"}<a href="{$base_path}/daily&amp;day={$nextday}">Next day<b>&gt;</b></a>{else}Next day<b>&gt;</b>{/if}
         <br>
 
         {loop="$daily_about_plugin"}
         {/loop}
 
         <br>
-        <a href="?do=dailyrss" title="1 RSS entry per day"><img src="img/feed-icon-14x14.png" alt="rss_feed">Daily RSS Feed</a>
+        <a href="{$base_path}/daily-rss" title="1 RSS entry per day"><img src="{$asset_path}/img/feed-icon-14x14.png#" alt="rss_feed">Daily RSS Feed</a>
     </div>
 
     <div class="dailyTitle">
-        <img src="img/floral_left.png" width="51" height="50" class="nomobile" alt="floral_left">
+        <img src="{$asset_path}/img/floral_left.png#" width="51" height="50" class="nomobile" alt="floral_left">
         The Daily Shaarli
-        <img src="img/floral_right.png" width="51" height="50" class="nomobile" alt="floral_right">
+        <img src="{$asset_path}/img/floral_right.png#" width="51" height="50" class="nomobile" alt="floral_right">
     </div>
 
     <div class="dailyDate">
                     {$link=$value}
                     <div class="dailyEntry">
                         <div class="dailyEntryPermalink">
-                            <a href="?{$value.shorturl}">
-                                <img src="img/squiggle.png" width="25" height="26" title="permalink" alt="permalink">
+                            <a href="{$base_path}/?{$value.shorturl}">
+                                <img src="{$asset_path}/img/squiggle.png#" width="25" height="26" title="permalink" alt="permalink">
                             </a>
                         </div>
                         {if="!$hide_timestamps || $is_logged_in"}
                             <div class="dailyEntryLinkdate">
-                                <a href="?{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a>
+                                <a href="{$base_path}/?{$value.shorturl}">{function="strftime('%c', $link.timestamp)"}</a>
                             </div>
                         {/if}
                         {if="$link.tags"}
             {$value}
         {/loop}
     </div>
-    <div id="closing"><img src="img/squiggle_closing.png" width="66" height="61" alt="-"></div>
+    <div id="closing"><img src="{$asset_path}/img/squiggle_closing.png#" width="66" height="61" alt="-"></div>
 </div>
 {include="page.footer"}
-<script src="js/thumbnails.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails.min.js?v={$version_hash}#"></script>
 </body>
 </html>
index f589b06ead8b0f675c67f347c6b9360437d1bdb9..ff19bbfbae7f8c1ed9903705e849e53f455fe5cd 100644 (file)
@@ -1,16 +1,32 @@
-<item>
-    <title>{$title} - {function="strftime('%A %e %B %Y', $daydate)"}</title>
-    <guid>{$absurl}</guid>
-    <link>{$absurl}</link>
-    <pubDate>{$rssdate}</pubDate>
-    <description><![CDATA[
-        {loop="links"}
-               <h3><a href="{$value.url}">{$value.title}</a></h3>
-               <small>{if="!$hide_timestamps"}{function="strftime('%c', $value.timestamp)"} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
-               {$value.url}</small><br>
-               {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
-               {if="$value.description"}{$value.formatedDescription}{/if}
-               <br><br><hr>
+<?xml version="1.0" encoding="UTF-8"?>
+<rss version="2.0">
+  <channel>
+    <title>Daily - {$title}</title>
+    <link>{$index_url}</link>
+    <description>Daily shaared bookmarks</description>
+    <language>{$language}</language>
+    <copyright>{$index_url}</copyright>
+    <generator>Shaarli</generator>
+
+    {loop="$days"}
+    <item>
+      <title>{$value.date_human} - {$title}</title>
+      <guid>{$value.absolute_url}</guid>
+      <link>{$value.absolute_url}</link>
+      <pubDate>{$value.date_rss}</pubDate>
+      <description><![CDATA[
+        {loop="$value.links"}
+        <h3><a href="{$value.url}">{$value.title}</a></h3>
+        <small>
+          {if="!$hide_timestamps"}{$value.created|format_date} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
+          {$value.url}
+        </small><br>
+        {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
+        {if="$value.description"}{$value.description}{/if}
+        <br><br><hr>
         {/loop}
-    ]]></description>
-</item>
+        ]]></description>
+    </item>
+    {/loop}
+  </channel>
+</rss><!-- Cached version of {$page_url} -->
index 6f7a330f4f979ab1f044f482f3a56dc26f24adae..eb8807b5a2d0fc7bdbc594f0a020815f3e331834 100644 (file)
@@ -1,21 +1,16 @@
 <!DOCTYPE html>
 <html>
 <head>{include="includes"}
-    <link type="text/css" rel="stylesheet" href="inc/awesomplete.css#" />
 </head>
 <body
 {if="$link.title==''"}onload="document.linkform.lf_title.focus();"
 {elseif="$link.description==''"}onload="document.linkform.lf_description.focus();"
 {else}onload="document.linkform.lf_tags.focus();"{/if} >
 <div id="pageheader">
-    {if="$source !== 'firefoxsocialapi'"}
     {include="page.header"}
-    {else}
     <div id="shaarli_title"><a href="{$titleLink}">{$shaarlititle}</a></div>
-    {/if}
     <div id="editlinkform">
-        <form method="post" name="linkform">
-            <input type="hidden" name="lf_linkdate" value="{$link.linkdate}">
+        <form method="post" name="linkform" action="{$base_path}/admin/shaare">
           {if="isset($link.id)"}
                  <input type="hidden" name="lf_id" value="{$link.id}">
           {/if}
             {/if}
             <input type="submit" value="Save" name="save_edit" class="bigbutton">
             {if="!$link_is_new && isset($link.id)"}
-              <a href="?delete_link&amp;lf_linkdate={$link.id}&amp;token={$token}"
+              <a href="{$base_path}/admin/shaare/delete?id={$link.id}&amp;token={$token}"
                  name="delete_link" class="bigbutton"
                  onClick="return confirmDeleteLink();">
                 {'Delete'|t}
               </a>
             {/if}
             <input type="hidden" name="token" value="{$token}">
+            <input type="hidden" name="source" value="{$source}">
             {if="$http_referer"}<input type="hidden" name="returnurl" value="{$http_referer}">{/if}
         </form>
     </div>
 </div>
-{if="$source !== 'firefoxsocialapi'"}
 {include="page.footer"}
-{/if}
 </body>
 </html>
index b6e62be09e9ff6f404c388c82c8c15e08413b496..64f54cd287be2d44f956eaa234d19bda0c13a419 100644 (file)
@@ -18,7 +18,7 @@
         </pre>
     {/if}
 
-    <p>Would you mind <a href="?">clicking here</a>?</p>
+    <p>Would you mind <a href="{$base_path}/">clicking here</a>?</p>
 </div>
 {include="page.footer"}
 </body>
index 67c3d05fb8a4926260d32f4a5ca09b8f2e9bca4c..c30e3b0ad41f3a3c3dc6944c85fa55a2c8fd64b1 100644 (file)
@@ -5,12 +5,13 @@
   <div id="pageheader">
     {include="page.header"}
     <div id="toolsdiv">
-      <form method="GET">
-        <input type="hidden" name="do" value="export">
+      <form method="POST" action="{$base_path}/admin/export">
         Selection:<br>
         <input type="radio" name="selection" value="all" checked="true"> All<br>
         <input type="radio" name="selection" value="private"> Private<br>
         <input type="radio" name="selection" value="public"> Public<br>
+        <input type="hidden" name="token" value="{$token}">
+
         <br>
         <input type="checkbox" name="prepend_note_url" id="prepend_note_url">
         <label for="prepend_note_url">
index 0621cb9e456d105e8769275c7fd5fb7a10454259..5919bb4956210ddf152b2df4d8bc0dad067db26e 100644 (file)
@@ -6,8 +6,8 @@
     <updated>{$last_update}</updated>
   {/if}
   <link rel="self" href="{$self_link}#" />
-  <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
-             title="Shaarli search - {$shaarlititle}" />
+  <link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
+        title="Shaarli search - {$shaarlititle}" />
   {loop="$feed_plugins_header"}
     {$value}
   {/loop}
index ee3fef880de3b7e931d4f04968d4cb3ca0403706..4be8202f57ced7cb19fbbd470b38fa70f857f843 100644 (file)
@@ -8,7 +8,7 @@
     <copyright>{$index_url}</copyright>
     <generator>Shaarli</generator>
     <atom:link rel="self" href="{$self_link}"  />
-    <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}?do=opensearch#"
+    <atom:link rel="search" type="application/opensearchdescription+xml" href="{$index_url}open-search#"
                title="Shaarli search - {$shaarlititle}" />
     {loop="$feed_plugins_header"}
       {$value}
index bb9e4a562040b410b00e90bbc6a6a63b5520d9ab..7d6eac766d47677044608434e82d06fdcb9b456c 100644 (file)
@@ -6,7 +6,7 @@
   {include="page.header"}
   <div id="uploaddiv">
     Import Netscape HTML bookmarks (as exported from Firefox/Chrome/Opera/Delicious/Diigo...) (Max: {$maxfilesize}).
-    <form method="POST" action="?do=import" enctype="multipart/form-data"
+    <form method="POST" action="{$base_path}/admin/import" enctype="multipart/form-data"
           name="uploadform" id="uploadform">
       <input type="hidden" name="token" value="{$token}">
       <input type="hidden" name="MAX_FILE_SIZE" value="{$maxfilesize}">
index 8d273c441c95bb7f5123bfeae86114d1a75f25b7..eac05701c70abf1ed49479faa6dbbd2bf0cfcdcf 100644 (file)
@@ -3,18 +3,19 @@
 <meta name="format-detection" content="telephone=no" />
 <meta name="viewport" content="width=device-width,initial-scale=1.0" />
 <meta name="referrer" content="same-origin">
-<link rel="alternate" type="application/rss+xml" href="{$feedurl}?do=rss{$searchcrits}#" title="RSS Feed" />
-<link rel="alternate" type="application/atom+xml" href="{$feedurl}?do=atom{$searchcrits}#" title="ATOM Feed" />
+<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" />
+<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" />
 <link href="img/favicon.ico" rel="shortcut icon" type="image/x-icon" />
-<link type="text/css" rel="stylesheet" href="css/shaarli.min.css" />
+<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css#" />
 {if="$formatter==='markdown'"}
-  <link type="text/css" rel="stylesheet" href="css/markdown.min.css?v={$version_hash}" />
+  <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
 {/if}
 {loop="$plugins_includes.css_files"}
-<link type="text/css" rel="stylesheet" href="{$value}#"/>
+<link type="text/css" rel="stylesheet" href="{$base_path}/{$value}#"/>
 {/loop}
-{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="data/user.css#" />{/if}
-<link rel="search" type="application/opensearchdescription+xml" href="?do=opensearch#" title="Shaarli search - {$shaarlititle|htmlspecialchars}"/>
+{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />{/if}
+<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
+      title="Shaarli search - {$shaarlititle|htmlspecialchars}" />
 {if="! empty($links) && count($links) === 1"}
   {$link=reset($links)}
   <meta property="og:title" content="{$link.title}" />
index aca890d6c3a549fca924fa02a58a5fa384a7dae6..8c10b2cba7df49f27d53e0cc1d1df353365ee64e 100644 (file)
@@ -5,7 +5,7 @@
 <div id="install">
     <h1>Shaarli</h1>
     It looks like it's the first time you run Shaarli. Please configure it:<br>
-    <form method="POST" action="#" name="installform" id="installform">
+    <form method="POST" action="{$base_path}/install" name="installform" id="installform">
         <table>
             <tr><td><b>Login:</b></td><td><input type="text" name="setlogin" size="30"></td></tr>
             <tr><td><b>Password:</b></td><td><input type="password" name="setpassword" size="30"></td></tr>
index dcb14e908042f6a6088ca118f820f19551cac47a..90f5cf8f5b81af10d936162cf2e932893f697f90 100644 (file)
@@ -1,7 +1,6 @@
 <!DOCTYPE html>
 <html>
 <head>
-    <link type="text/css" rel="stylesheet" href="inc/awesomplete.css#" />
     {include="includes"}
 </head>
 <body>
                 tagged
                 {loop="$exploded_tags"}
                     <span class="linktag" title="Remove tag">
-                        <a href="?removetag={function="urlencode($value)"}">{$value} <span class="remove">x</span></a>
+                        <a href="{$base_path}/remove-tag/{function="urlencode($value)"}">{$value} <span class="remove">x</span></a>
                     </span>
                 {/loop}
             {elseif="$search_tags === false"}
                 <span class="linktag" title="Remove tag">
-                    <a href="?">untagged <span class="remove">x</span></a>
+                    <a href="{$base_path}/">untagged <span class="remove">x</span></a>
                 </span>
             {/if}
         </div>
     {/if}
     <ul>
         {loop="$links"}
-        <li{if="$value.class"} class="{$value.class}"{/if}>
+        <li{if="$value.class"} class="{$value.class}"{/if} data-id="{$value.id}">
             <a id="{$value.shorturl}"></a>
-            {if="$thumbnails_enabled && !empty($value.thumbnail)"}
-                <div class="thumbnail">
+            {if="$thumbnails_enabled && $value.thumbnail !== false"}
+                <div class="thumbnail" {if="$value.thumbnail === null"}data-async-thumbnail="1"{/if}>
                     <a href="{$value.real_url}">
                         {ignore}RainTPL hack: put the 2 src on two different line to avoid path replace bug{/ignore}
-                        <img data-src="{$value.thumbnail}#" class="b-lazy"
+                        <img data-src="{$base_path}/{$value.thumbnail}#" class="b-lazy"
                              src=""
                              alt="thumbnail" width="{$thumbnails_width}" height="{$thumbnails_height}" />
                     </a>
             <div class="linkcontainer">
                 {if="$is_logged_in"}
                     <div class="linkeditbuttons">
-                        <form method="GET" class="buttoneditform">
-                            <input type="hidden" name="edit_link" value="{$value.id}">
-                            <input type="image" alt="Edit" src="img/edit_icon.png" title="Edit" class="button_edit">
-                        </form><br>
-                        <form method="GET" class="buttoneditform">
-                            <input type="hidden" name="lf_linkdate" value="{$value.id}">
-                            <input type="hidden" name="token" value="{$token}">
-                            <input type="hidden" name="delete_link">
-                            <input type="image" alt="Delete" src="img/delete_icon.png" title="Delete"
-                                   class="button_delete" onClick="return confirmDeleteLink();">
-                        </form>
+                        <a href="{$base_path}/admin/shaare/{$value.id}" title="Edit" class="button_edit">
+                            <img src="{$asset_path}/img/edit_icon.png#">
+                        </a>
+                        <br>
+                        <a href="{$base_path}/admin/shaare/delete?id={$value.id}&amp;token={$token}" label="Delete"
+                           onClick="return confirmDeleteLink();"
+                           class="button_delete"
+                        >
+                            <img src="{$asset_path}/img/delete_icon.png#">
+                        </a>
                     </div>
                 {/if}
                 <span class="linktitle">
                 {if="!$hide_timestamps || $is_logged_in"}
                     {$updated=$value.updated_timestamp ? 'Edited: '. format_date($value.updated) : 'Permalink'}
                     <span class="linkdate" title="Permalink">
-                        <a href="?{$value.shorturl}">
+                        <a href="{$base_path}/shaare/{$value.shorturl}">
                             <span title="{$updated}">
                                 {$value.created|format_date}
                                 {if="$value.updated_timestamp"}*{/if}
                         </a> -
                     </span>
                 {else}
-                    <span class="linkdate" title="Short link here"><a href="?{$value.shorturl}">permalink</a> - </span>
+                    <span class="linkdate" title="Short link here"><a href="{$base_path}/shaare/{$value.shorturl}">permalink</a> - </span>
                 {/if}
 
                 {loop="$value.link_plugin"}
                 <a href="{$value.real_url}"><span class="linkurl" title="Short link">{$value.url}</span></a><br>
                 {if="$value.tags"}
                     <div class="linktaglist">
-                    {loop="$value.taglist"}<span class="linktag" title="Add tag"><a href="?addtag={$value|urlencode}">{$value}</a></span> {/loop}
+                    {loop="$value.taglist"}<span class="linktag" title="Add tag"><a href="{$base_path}/add-tag/{$value|urlencode}">{$value}</a></span> {/loop}
                     </div>
                 {/if}
 
 </div>
 
     {include="page.footer"}
-<script src="js/thumbnails.min.js"></script>
+<script src="{$asset_path}/js/thumbnails.min.js#"></script>
+{if="$is_logged_in && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
 
 </body>
 </html>
index 35149a6bfddda4c5b3633da3ca5ada947f855b9e..79daf16c26c5273090c517b68b2b5504fa628f5d 100644 (file)
@@ -1,11 +1,11 @@
 <div class="paging">
 {if="$is_logged_in"}
     <div class="paging_privatelinks">
-      <a href="?visibility=private">
+      <a href="{$base_path}/admin/isibility/private">
                {if="$visibility=='private'"}
-      <img src="img/private_16x16_active.png" width="16" height="16" title="Click to see all links" alt="Click to see all links">
+      <img src="{$asset_path}/img/private_16x16_active.png#" width="16" height="16" title="Click to see all links" alt="Click to see all links">
     {else}
-      <img src="img/private_16x16.png" width="16" height="16" title="Click to see only private links" alt="Click to see only private links">
+      <img src="{$asset_path}/img/private_16x16.png#" width="16" height="16" title="Click to see only private links" alt="Click to see only private links">
                {/if}
                </a>
 
       </div>
     {/loop}
     <div class="paging_linksperpage">
-        Links per page: <a href="?linksperpage=20">20</a> <a href="?linksperpage=50">50</a> <a href="?linksperpage=100">100</a>
-        <form method="GET" class="linksperpage"><input type="text" name="linksperpage" size="2"></form>
+        Links per page:
+        <a href="{$base_path}/links-per-page?nb=20">20</a>
+        <a href="{$base_path}/links-per-page?nb=50">50</a>
+        <a href="{$base_path}/links-per-page?nb=100">100</a>
+        <form method="GET" class="linksperpage" action="{$base_path}/links-per-page">
+          <input type="text" name="nb" size="2">
+        </form>
     </div>
     {if="$previous_page_url"} <a href="{$previous_page_url}" class="paging_older">&#x25C4;Older</a> {/if}
-    <div class="paging_current">page {$page_current} / {$page_max} </div>
+    {if="$page_max>1"}<div class="paging_current">page {$page_current} / {$page_max} </div>{/if}
     {if="$next_page_url"} <a href="{$next_page_url}" class="paging_newer">Newer&#x25BA;</a> {/if}
 </div>
index a37920667883f5182a083be7b8387bcbf09a7791..6aa20ab193453df7baaa9128c0106bfb0c3f50dd 100644 (file)
@@ -11,7 +11,7 @@
   {include="page.header"}
 
   <div id="headerform">
-    <form method="post" name="loginform">
+    <form method="post" name="loginform" action="{$base_path}/login">
       <label for="login">Login: <input type="text" id="login" name="login" tabindex="1"
          {if="!empty($username)"}value="{$username}"{/if}>
       </label>
index 3fcc30b74fdc2bc150687dcabc12f89b62222c54..1c7f279b0d061ff74b310cf79986ebfa57ca0464 100644 (file)
@@ -3,8 +3,8 @@
     <ShortName>Shaarli search - {$pagetitle}</ShortName>
     <Description>Shaarli search - {$pagetitle}</Description>
     <Url type="text/html" template="{$serverurl}?searchterm={searchTerms}" />
-    <Url type="application/atom+xml" template="{$serverurl}?do=atom&amp;searchterm={searchTerms}"/>
-    <Url type="application/rss+xml" template="{$serverurl}?do=rss&amp;searchterm={searchTerms}"/>
+    <Url type="application/atom+xml" template="{$serverurl}feed/atom?searchterm={searchTerms}"/>
+    <Url type="application/rss+xml" template="{$serverurl}feed/rss?searchterm={searchTerms}"/>
     <InputEncoding>UTF-8</InputEncoding>
     <Developer>Shaarli Community - https://github.com/shaarli/Shaarli/</Developer>
     <Image width="16" height="16">data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAHRklE
index a3380841b5d35e00dccbafa2e7949c99ece0793a..0fe4c7368f10ac071b76565fd2a2cdcc1ddb822c 100644 (file)
 </div>
 {/if}
 
-<script src="js/shaarli.min.js"></script>
+<script src="{$asset_path}/js/shaarli.min.js#"></script>
 
 {if="$is_logged_in"}
 <script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script>
 {/if}
 
 {loop="$plugins_footer.js_files"}
-       <script src="{$value}#"></script>
+       <script src="{$base_path}/{$value}#"></script>
 {/loop}
+
+<input type="hidden" name="js_base_path" value="{$base_path}" />
index a37926d2a183521398b5060b52abf67390af60b4..0a33523b375c3f650242a155a68386ee6f4cb5b5 100644 (file)
 {else}
 <li><a href="{$titleLink}" class="nomobile">Home</a></li>
     {if="$is_logged_in"}
-    <li><a href="?do=logout">Logout</a></li>
-    <li><a href="?do=tools">Tools</a></li>
-    <li><a href="?do=addlink">Add link</a></li>
+    <li><a href="{$base_path}/admin/logout">Logout</a></li>
+    <li><a href="{$base_path}/admin/tools">Tools</a></li>
+    <li><a href="{$base_path}/admin/add-shaare">Add link</a></li>
     {elseif="$openshaarli"}
-    <li><a href="?do=tools">Tools</a></li>
-    <li><a href="?do=addlink">Add link</a></li>
+    <li><a href="{$base_path}/admin/tools">Tools</a></li>
+    <li><a href="{$base_path}/admin/add-shaare">Add link</a></li>
     {else}
-    <li><a href="/login">Login</a></li>
+    <li><a href="{$base_path}/login">Login</a></li>
     {/if}
-    <li><a href="{$feedurl}?do=rss{$searchcrits}" class="nomobile">RSS Feed</a></li>
+    <li><a href="{$feedurl}/feed/rss?{$searchcrits}" class="nomobile">RSS Feed</a></li>
     {if="$showatom"}
-    <li><a href="{$feedurl}?do=atom{$searchcrits}" class="nomobile">ATOM Feed</a></li>
+    <li><a href="{$feedurl}/feed/atom?{$searchcrits}" class="nomobile">ATOM Feed</a></li>
     {/if}
-    <li><a href="?do=tagcloud">Tag cloud</a></li>
-    <li><a href="?do=picwall{$searchcrits}">Picture wall</a></li>
-    <li><a href="?do=daily">Daily</a></li>
+    <li><a href="{$base_path}/tags/cloud">Tag cloud</a></li>
+    <li><a href="{$base_path}/picture-wall{function="ltrim($searchcrits, '&')"}">Picture wall</a></li>
+    <li><a href="{$base_path}/daily">Daily</a></li>
     {loop="$plugins_header.buttons_toolbar"}
         <li><a
             {loop="$value.attr"}
index b3a16791b679a06dc8cadea773d299f3cf0e701f..da3aa36c4096d2b601931c9ad88c96766690a5ac 100644 (file)
@@ -38,6 +38,6 @@
 
 {include="page.footer"}
 
-<script src="js/thumbnails.min.js"></script>
+<script src="{$asset_path}/js/thumbnails.min.js#"></script>
 </body>
 </html>
index 63b45cac5d13b7719fe71a4684ca229192be68a0..d0972cd15befc4b4d0900e75e532c884b4563ccf 100644 (file)
@@ -16,7 +16,7 @@
 </noscript>
 
 <div id="pluginsadmin">
-  <form action="?do=save_pluginadmin" method="POST">
+  <form action="{$base_path}/admin/plugins" method="POST">
     <section id="enabled_plugins">
       <h1>Enabled Plugins</h1>
 
         <input type="submit" value="Save"/>
       </div>
     </section>
+    <input type="hidden" name="token" value="{$token}">
   </form>
 
-  <form action="?do=save_pluginadmin" method="POST">
+  <form action="{$base_path}/admin/plugins" method="POST">
     <section id="plugin_parameters">
       <h1>Enabled Plugin Parameters</h1>
 
         </div>
       </div>
     </section>
+    <input type="hidden" name="token" value="{$token}">
   </form>
 
 </div>
index d93bf4f9db94b8cdbbd4b97f3358025652320a72..5d21f2395549a27de7de4d1ebd28395589b41592 100644 (file)
@@ -12,8 +12,8 @@
 
     <div id="cloudtag">
         {loop="$tags"}
-            <a href="?addtag={$key|urlencode}" class="count">{$value.count}</a><a
-                href="?searchtags={$key|urlencode}" style="font-size:{$value.size}em;">{$key}</a>
+            <a href="{$base_path}/add-tag/{$key|urlencode}" class="count">{$value.count}</a><a
+                href="{$base_path}/?searchtags={$key|urlencode}" style="font-size:{$value.size}em;">{$key}</a>
             {loop="$value.tag_plugin"}
                 {$value}
             {/loop}
index 5cad845b62d91b45dc4dd3109cfda1ee0593bea3..18f296f7ad73fff10d1f5c77c216962dc8ea3dca 100644 (file)
@@ -23,6 +23,6 @@
 <input type="hidden" name="ids" value="{function="implode(',', $ids)"}" />
 
 {include="page.footer"}
-<script src="js/thumbnails_update.min.js?v={$version_hash}"></script>
+<script src="{$asset_path}/js/thumbnails_update.min.js?v={$version_hash}#"></script>
 </body>
 </html>
index 1cef726eef357aedd4a27779dcb85990b2effe55..1125bba92082ba8ef3622c208ffb22df35387998 100644 (file)
@@ -5,17 +5,17 @@
 <div id="pageheader">
        {include="page.header"}
        <div id="toolsdiv">
-               <a href="?do=configure"><b>Configure your Shaarli</b><span>: Change Title, timezone...</span></a>
+               <a href="{$base_path}/admin/configure"><b>Configure your Shaarli</b><span>: Change Title, timezone...</span></a>
                <br><br>
-               <a href="?do=pluginadmin"><b>Plugin administration</b><span>: Enable, disable and configure plugins.</span></a>
+               <a href="{$base_path}/admin/plugins"><b>Plugin administration</b><span>: Enable, disable and configure plugins.</span></a>
     <br><br>
-               {if="!$openshaarli"}<a href="?do=changepasswd"><b>Change password</b><span>: Change your password.</span></a>
+               {if="!$openshaarli"}<a href="{$base_path}/admin/password"><b>Change password</b><span>: Change your password.</span></a>
     <br><br>{/if}
-               <a href="?do=changetag"><b>Rename/delete tags</b><span>: Rename or delete a tag in all links</span></a>
+               <a href="{$base_path}/admin/tags"><b>Rename/delete tags</b><span>: Rename or delete a tag in all links</span></a>
     <br><br>
-               <a href="?do=import"><b>Import</b><span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a>
+               <a href="{$base_path}/admin/import"><b>Import</b><span>: Import Netscape html bookmarks (as exported from Firefox, Chrome, Opera, delicious...)</span></a>
     <br><br>
-               <a href="?do=export"><b>Export</b><span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a>
+               <a href="{$base_path}/admin/export"><b>Export</b><span>: Export Netscape html bookmarks (which can be imported in Firefox, Chrome, Opera, delicious...)</span></a>
     <br><br>
                <a class="smallbutton"
                   onclick="return alertBookmarklet();"
@@ -24,7 +24,7 @@
                                var%20url%20=%20location.href;
                                var%20title%20=%20document.title%20||%20url;
                                window.open(
-                                       '{$pageabsaddr}?post='%20+%20encodeURIComponent(url)+
+                                       '{$pageabsaddr}admin/shaare?post='%20+%20encodeURIComponent(url)+
                                        '&amp;title='%20+%20encodeURIComponent(title)+
                                        '&amp;description='%20+%20encodeURIComponent(document.getSelection())+
                                        '&amp;source=bookmarklet','_blank','menubar=no,height=390,width=600,toolbar=no,scrollbars=no,status=no,dialog=1'
index 602147e51d3d200b8dcf45bbb6df7f951e08c6c3..a4aa633eb6ace028380d7bfbbf27495adafbeb55 100644 (file)
@@ -2,29 +2,26 @@ const path = require('path');
 const glob = require('glob');
 
 // Minify JS
-const MinifyPlugin = require('babel-minify-webpack-plugin');
+const TerserPlugin = require('terser-webpack-plugin');
 
 // This plugin extracts the CSS into its own file instead of tying it with the JS.
 // It prevents:
 //   - not having styles due to a JS error
 //   - the flash page without styles during JS loading
-const ExtractTextPlugin = require("extract-text-webpack-plugin");
+const MiniCssExtractPlugin = require("mini-css-extract-plugin");
 
-const extractCssDefault = new ExtractTextPlugin({
+const extractCss = new MiniCssExtractPlugin({
   filename: "../css/[name].min.css",
-  publicPath: 'tpl/default/css/',
-});
-
-const extractCssVintage = new ExtractTextPlugin({
-  filename: "../css/[name].min.css",
-  publicPath: 'tpl/vintage/css/',
 });
 
 module.exports = [
   {
+    mode: 'production',
     entry: {
+      shaare_batch: './assets/common/js/shaare-batch.js',
       thumbnails: './assets/common/js/thumbnails.js',
       thumbnails_update: './assets/common/js/thumbnails-update.js',
+      metadata: './assets/common/js/metadata.js',
       pluginsadmin: './assets/default/js/plugins-admin.js',
       shaarli: [
         './assets/default/js/base.js',
@@ -45,23 +42,23 @@ module.exports = [
             loader: 'babel-loader',
             options: {
               presets: [
-                'babel-preset-env',
+                '@babel/preset-env',
               ]
             }
           }
         },
         {
           test: /\.s?css/,
-          use: extractCssDefault.extract({
-            use: [{
-              loader: "css-loader",
+          use: [
+            {
+              loader: MiniCssExtractPlugin.loader,
               options: {
-                minimize: true,
-              }
-            }, {
-              loader: "sass-loader"
-            }],
-          })
+                publicPath: 'tpl/default/css/',
+              },
+            },
+            'css-loader',
+            'sass-loader',
+          ],
         },
         {
           test: /\.(gif|png|jpe?g|svg|ico)$/i,
@@ -81,17 +78,21 @@ module.exports = [
           options: {
             name: '../fonts/[name].[ext]',
             // do not add a publicPath here because it's already handled by CSS's publicPath
-            publicPath: '',
+            publicPath: '../default/',
           }
         },
       ],
     },
+    optimization: {
+      minimize: true,
+      minimizer: [new TerserPlugin()],
+    },
     plugins: [
-      new MinifyPlugin(),
-      extractCssDefault,
+      extractCss,
     ],
   },
   {
+    mode: 'production',
     entry: {
       shaarli: [
         './assets/vintage/js/base.js',
@@ -100,6 +101,7 @@ module.exports = [
       ].concat(glob.sync('./assets/vintage/img/*')),
       markdown: './assets/common/css/markdown.css',
       thumbnails: './assets/common/js/thumbnails.js',
+      metadata: './assets/common/js/metadata.js',
       thumbnails_update: './assets/common/js/thumbnails-update.js',
     },
     output: {
@@ -115,21 +117,23 @@ module.exports = [
             loader: 'babel-loader',
             options: {
               presets: [
-                'babel-preset-env',
+                '@babel/preset-env',
               ]
             }
           }
         },
         {
           test: /\.css$/,
-          use: extractCssVintage.extract({
-            use: [{
-              loader: "css-loader",
+          use: [
+            {
+              loader: MiniCssExtractPlugin.loader,
               options: {
-                minimize: true,
-              }
-            }],
-          })
+                publicPath: 'tpl/vintage/css/',
+              },
+            },
+            'css-loader',
+            'sass-loader',
+          ],
         },
         {
           test: /\.(gif|png|jpe?g|svg|ico)$/i,
@@ -145,9 +149,12 @@ module.exports = [
         },
       ],
     },
+    optimization: {
+      minimize: true,
+      minimizer: [new TerserPlugin()],
+    },
     plugins: [
-      new MinifyPlugin(),
-      extractCssVintage,
+      extractCss,
     ],
   },
 ];
index 96f854c19da45502496763dfae89722ac9ee86d7..55bd9827843a20d21bd96e425c69e53af52fa976 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
 # yarn lockfile v1
 
 
-abbrev@1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
-  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.10.4.tgz#168da1a36e90da68ae8d49c0f1b48c7c6249213a"
+  integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==
+  dependencies:
+    "@babel/highlight" "^7.10.4"
+
+"@babel/compat-data@^7.10.4", "@babel/compat-data@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.11.0.tgz#e9f73efe09af1355b723a7f39b11bad637d7c99c"
+  integrity sha512-TPSvJfv73ng0pfnEOh17bYMPQbI95+nGWc71Ss4vZdRBHTDqmM9Z8ZV4rYz8Ks7sfzc95n30k6ODIq5UGnXcYQ==
+  dependencies:
+    browserslist "^4.12.0"
+    invariant "^2.2.4"
+    semver "^5.5.0"
+
+"@babel/core@>=7.9.0", "@babel/core@^7.11.6":
+  version "7.11.6"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.11.6.tgz#3a9455dc7387ff1bac45770650bc13ba04a15651"
+  integrity sha512-Wpcv03AGnmkgm6uS6k8iwhIwTrcP0m17TL1n1sy7qD0qelDu4XNeW0dN0mHfa+Gei211yDaLoEe/VlbXQzM4Bg==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.11.6"
+    "@babel/helper-module-transforms" "^7.11.0"
+    "@babel/helpers" "^7.10.4"
+    "@babel/parser" "^7.11.5"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.11.5"
+    "@babel/types" "^7.11.5"
+    convert-source-map "^1.7.0"
+    debug "^4.1.0"
+    gensync "^1.0.0-beta.1"
+    json5 "^2.1.2"
+    lodash "^4.17.19"
+    resolve "^1.3.2"
+    semver "^5.4.1"
+    source-map "^0.5.0"
+
+"@babel/generator@^7.11.5", "@babel/generator@^7.11.6":
+  version "7.11.6"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.11.6.tgz#b868900f81b163b4d464ea24545c61cbac4dc620"
+  integrity sha512-DWtQ1PV3r+cLbySoHrwn9RWEgKMBLLma4OBQloPRyDYvc5msJM9kvTLo1YnlJd1P/ZuKbdli3ijr5q3FvAF3uA==
+  dependencies:
+    "@babel/types" "^7.11.5"
+    jsesc "^2.5.1"
+    source-map "^0.5.0"
+
+"@babel/helper-annotate-as-pure@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.10.4.tgz#5bf0d495a3f757ac3bda48b5bf3b3ba309c72ba3"
+  integrity sha512-XQlqKQP4vXFB7BN8fEEerrmYvHp3fK/rBkRFz9jaJbzK0B1DSfej9Kc7ZzE8Z/OnId1jpJdNAZ3BFQjWG68rcA==
+  dependencies:
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.10.4.tgz#bb0b75f31bf98cbf9ff143c1ae578b87274ae1a3"
+  integrity sha512-L0zGlFrGWZK4PbT8AszSfLTM5sDU1+Az/En9VrdT8/LmEiJt4zXt+Jve9DCAnQcbqDhCI+29y/L93mrDzddCcg==
+  dependencies:
+    "@babel/helper-explode-assignable-expression" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-compilation-targets@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.10.4.tgz#804ae8e3f04376607cc791b9d47d540276332bd2"
+  integrity sha512-a3rYhlsGV0UHNDvrtOXBg8/OpfV0OKTkxKPzIplS1zpx7CygDcWWxckxZeDd3gzPzC4kUT0A4nVFDK0wGMh4MQ==
+  dependencies:
+    "@babel/compat-data" "^7.10.4"
+    browserslist "^4.12.0"
+    invariant "^2.2.4"
+    levenary "^1.1.1"
+    semver "^5.5.0"
+
+"@babel/helper-create-class-features-plugin@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.10.5.tgz#9f61446ba80e8240b0a5c85c6fdac8459d6f259d"
+  integrity sha512-0nkdeijB7VlZoLT3r/mY3bUkw3T8WG/hNw+FATs/6+pG2039IJWjTYL0VTISqsNHMUTEnwbVnc89WIJX9Qed0A==
+  dependencies:
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-member-expression-to-functions" "^7.10.5"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
+
+"@babel/helper-create-regexp-features-plugin@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.10.4.tgz#fdd60d88524659a0b6959c0579925e425714f3b8"
+  integrity sha512-2/hu58IEPKeoLF45DBwx3XFqsbCXmkdAay4spVr2x0jYgRxrSNp+ePwvSsy9g6YSaNDcKIQVPXk1Ov8S2edk2g==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-regex" "^7.10.4"
+    regexpu-core "^4.7.0"
+
+"@babel/helper-define-map@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.10.5.tgz#b53c10db78a640800152692b13393147acb9bb30"
+  integrity sha512-fMw4kgFB720aQFXSVaXr79pjjcW5puTCM16+rECJ/plGS+zByelE8l9nCpV1GibxTnFVmUuYG9U8wYfQHdzOEQ==
+  dependencies:
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/types" "^7.10.5"
+    lodash "^4.17.19"
+
+"@babel/helper-explode-assignable-expression@^7.10.4":
+  version "7.11.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.11.4.tgz#2d8e3470252cc17aba917ede7803d4a7a276a41b"
+  integrity sha512-ux9hm3zR4WV1Y3xXxXkdG/0gxF9nvI0YVmKVhvK9AfMoaQkemL3sJpXw+Xbz65azo8qJiEz2XVDUpK3KYhH3ZQ==
+  dependencies:
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-function-name@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.10.4.tgz#d2d3b20c59ad8c47112fa7d2a94bc09d5ef82f1a"
+  integrity sha512-YdaSyz1n8gY44EmN7x44zBn9zQ1Ry2Y+3GTA+3vH6Mizke1Vw0aWDM66FOYEPw8//qKkmqOckrGgTYa+6sceqQ==
+  dependencies:
+    "@babel/helper-get-function-arity" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-get-function-arity@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.10.4.tgz#98c1cbea0e2332f33f9a4661b8ce1505b2c19ba2"
+  integrity sha512-EkN3YDB+SRDgiIUnNgcmiD361ti+AVbL3f3Henf6dqqUyr5dMsorno0lJWJuLhDhkI5sYEpgj6y9kB8AOU1I2A==
+  dependencies:
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-hoist-variables@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e"
+  integrity sha512-wljroF5PgCk2juF69kanHVs6vrLwIPNp6DLD+Lrl3hoQ3PpPPikaDRNFA+0t81NOoMt2DL6WW/mdU8k4k6ZzuA==
+  dependencies:
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-member-expression-to-functions@^7.10.4", "@babel/helper-member-expression-to-functions@^7.10.5":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.11.0.tgz#ae69c83d84ee82f4b42f96e2a09410935a8f26df"
+  integrity sha512-JbFlKHFntRV5qKw3YC0CvQnDZ4XMwgzzBbld7Ly4Mj4cbFy3KywcR8NtNctRToMWJOVvLINJv525Gd6wwVEx/Q==
+  dependencies:
+    "@babel/types" "^7.11.0"
+
+"@babel/helper-module-imports@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.10.4.tgz#4c5c54be04bd31670a7382797d75b9fa2e5b5620"
+  integrity sha512-nEQJHqYavI217oD9+s5MUBzk6x1IlvoS9WTPfgG43CbMEeStE0v+r+TucWdx8KFGowPGvyOkDT9+7DHedIDnVw==
+  dependencies:
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-module-transforms@^7.10.4", "@babel/helper-module-transforms@^7.10.5", "@babel/helper-module-transforms@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.11.0.tgz#b16f250229e47211abdd84b34b64737c2ab2d359"
+  integrity sha512-02EVu8COMuTRO1TAzdMtpBPbe6aQ1w/8fePD2YgQmxZU4gpNWaL9gK3Jp7dxlkUlUCJOTaSeA+Hrm1BRQwqIhg==
+  dependencies:
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-simple-access" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.11.0"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.11.0"
+    lodash "^4.17.19"
+
+"@babel/helper-optimise-call-expression@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.10.4.tgz#50dc96413d594f995a77905905b05893cd779673"
+  integrity sha512-n3UGKY4VXwXThEiKrgRAoVPBMqeoPgHVqiHZOanAJCG9nQUL2pLRQirUzl0ioKclHGpGqRgIOkgcIJaIWLpygg==
+  dependencies:
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375"
+  integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg==
+
+"@babel/helper-regex@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0"
+  integrity sha512-68kdUAzDrljqBrio7DYAEgCoJHxppJOERHOgOrDN7WjOzP0ZQ1LsSDRXcemzVZaLvjaJsJEESb6qt+znNuENDg==
+  dependencies:
+    lodash "^4.17.19"
+
+"@babel/helper-remap-async-to-generator@^7.10.4":
+  version "7.11.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.11.4.tgz#4474ea9f7438f18575e30b0cac784045b402a12d"
+  integrity sha512-tR5vJ/vBa9wFy3m5LLv2faapJLnDFxNWff2SAYkSE4rLUdbp7CdObYFgI7wK4T/Mj4UzpjPwzR8Pzmr5m7MHGA==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-wrap-function" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-replace-supers@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.10.4.tgz#d585cd9388ea06e6031e4cd44b6713cbead9e6cf"
+  integrity sha512-sPxZfFXocEymYTdVK1UNmFPBN+Hv5mJkLPsYWwGBxZAxaWfFu+xqp7b6qWD0yjNuNL2VKc6L5M18tOXUP7NU0A==
+  dependencies:
+    "@babel/helper-member-expression-to-functions" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-simple-access@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.10.4.tgz#0f5ccda2945277a2a7a2d3a821e15395edcf3461"
+  integrity sha512-0fMy72ej/VEvF8ULmX6yb5MtHG4uH4Dbd6I/aHDb/JVg0bbivwt9Wg+h3uMvX+QSFtwr5MeItvazbrc4jtRAXw==
+  dependencies:
+    "@babel/template" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/helper-skip-transparent-expression-wrappers@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.11.0.tgz#eec162f112c2f58d3af0af125e3bb57665146729"
+  integrity sha512-0XIdiQln4Elglgjbwo9wuJpL/K7AGCY26kmEt0+pRP0TAj4jjyNq1MjoRvikrTVqKcx4Gysxt4cXvVFXP/JO2Q==
+  dependencies:
+    "@babel/types" "^7.11.0"
+
+"@babel/helper-split-export-declaration@^7.10.4", "@babel/helper-split-export-declaration@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.11.0.tgz#f8a491244acf6a676158ac42072911ba83ad099f"
+  integrity sha512-74Vejvp6mHkGE+m+k5vHY93FX2cAtrw1zXrZXRlG4l410Nm9PxfEiVTn1PjDPV5SnmieiueY4AFg2xqhNFuuZg==
+  dependencies:
+    "@babel/types" "^7.11.0"
+
+"@babel/helper-validator-identifier@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz#a78c7a7251e01f616512d31b10adcf52ada5e0d2"
+  integrity sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==
+
+"@babel/helper-wrap-function@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.10.4.tgz#8a6f701eab0ff39f765b5a1cfef409990e624b87"
+  integrity sha512-6py45WvEF0MhiLrdxtRjKjufwLL1/ob2qDJgg5JgNdojBAZSAKnAjkyOCNug6n+OBl4VW76XjvgSFTdaMcW0Ug==
+  dependencies:
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/helpers@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.10.4.tgz#2abeb0d721aff7c0a97376b9e1f6f65d7a475044"
+  integrity sha512-L2gX/XeUONeEbI78dXSrJzGdz4GQ+ZTA/aazfUsFaWjSe95kiCuOZ5HsXvkiw3iwF+mFHSRUfJU8t6YavocdXA==
+  dependencies:
+    "@babel/template" "^7.10.4"
+    "@babel/traverse" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/highlight@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.10.4.tgz#7d1bdfd65753538fabe6c38596cdb76d9ac60143"
+  integrity sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.10.4"
+    chalk "^2.0.0"
+    js-tokens "^4.0.0"
+
+"@babel/parser@^7.10.4", "@babel/parser@^7.11.5":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037"
+  integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q==
 
-acorn-dynamic-import@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz#c752bd210bef679501b6c6cb7fc84f8f47158cc4"
-  integrity sha1-x1K9IQvvZ5UBtsbLf8hPj0cVjMQ=
+"@babel/plugin-proposal-async-generator-functions@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.10.5.tgz#3491cabf2f7c179ab820606cec27fed15e0e8558"
+  integrity sha512-cNMCVezQbrRGvXJwm9fu/1sJj9bHdGAgKodZdLqOQIpfoH3raqmRPBM17+lh7CzhiKRRBrGtZL9WcjxSoGYUSg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-remap-async-to-generator" "^7.10.4"
+    "@babel/plugin-syntax-async-generators" "^7.8.0"
+
+"@babel/plugin-proposal-class-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.10.4.tgz#a33bf632da390a59c7a8c570045d1115cd778807"
+  integrity sha512-vhwkEROxzcHGNu2mzUC0OFFNXdZ4M23ib8aRRcJSsW8BZK9pQMD7QB7csl97NBbgGZO7ZyHUyKDnxzOaP4IrCg==
   dependencies:
-    acorn "^4.0.3"
-
-acorn-jsx@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
-  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
+    "@babel/helper-create-class-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-proposal-dynamic-import@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.10.4.tgz#ba57a26cb98b37741e9d5bca1b8b0ddf8291f17e"
+  integrity sha512-up6oID1LeidOOASNXgv/CFbgBqTuKJ0cJjz6An5tWD+NVBNlp3VNSBxv2ZdU7SYl3NxJC7agAQDApZusV6uFwQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
+
+"@babel/plugin-proposal-export-namespace-from@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.10.4.tgz#570d883b91031637b3e2958eea3c438e62c05f54"
+  integrity sha512-aNdf0LY6/3WXkhh0Fdb6Zk9j1NMD8ovj3F6r0+3j837Pn1S1PdNtcwJ5EG9WkVPNHPxyJDaxMaAOVq4eki0qbg==
   dependencies:
-    acorn "^3.0.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
 
-acorn@^3.0.4:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
-  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
+"@babel/plugin-proposal-json-strings@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.10.4.tgz#593e59c63528160233bd321b1aebe0820c2341db"
+  integrity sha512-fCL7QF0Jo83uy1K0P2YXrfX11tj3lkpN7l4dMv9Y9VkowkhkQDwFHFd8IiwyK5MZjE8UpbgokkgtcReH88Abaw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-json-strings" "^7.8.0"
 
-acorn@^4.0.3:
-  version "4.0.13"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-4.0.13.tgz#105495ae5361d697bd195c825192e1ad7f253787"
-  integrity sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=
+"@babel/plugin-proposal-logical-assignment-operators@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.11.0.tgz#9f80e482c03083c87125dee10026b58527ea20c8"
+  integrity sha512-/f8p4z+Auz0Uaf+i8Ekf1iM7wUNLcViFUGiPxKeXvxTSl63B875YPiVdUDdem7hREcI0E0kSpEhS8tF5RphK7Q==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
 
-acorn@^5.0.0, acorn@^5.5.0:
-  version "5.7.3"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
-  integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.10.4.tgz#02a7e961fc32e6d5b2db0649e01bf80ddee7e04a"
+  integrity sha512-wq5n1M3ZUlHl9sqT2ok1T2/MTt6AXE0e1Lz4WzWBr95LsAZ5qDXe4KnFuauYyEyLiohvXFMdbsOTMyLZs91Zlw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
 
-ajv-keywords@^1.0.0:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c"
-  integrity sha1-MU3QpLM2j609/NxU7eYXG4htrzw=
+"@babel/plugin-proposal-numeric-separator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.10.4.tgz#ce1590ff0a65ad12970a609d78855e9a4c1aef06"
+  integrity sha512-73/G7QoRoeNkLZFxsoCCvlg4ezE4eM+57PnOqgaPOozd5myfj7p0muD1mRVJvbUWbOzD+q3No2bWbaKy+DJ8DA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
 
-ajv-keywords@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-2.1.1.tgz#617997fc5f60576894c435f940d819e135b80762"
-  integrity sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=
+"@babel/plugin-proposal-object-rest-spread@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.11.0.tgz#bd81f95a1f746760ea43b6c2d3d62b11790ad0af"
+  integrity sha512-wzch41N4yztwoRw0ak+37wxwJM2oiIiy6huGCoqkvSTA9acYWcPfn9Y4aJqmFFJ70KTJUu29f3DQ43uJ9HXzEA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/plugin-transform-parameters" "^7.10.4"
 
-ajv-keywords@^3.1.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.0.tgz#4b831e7b531415a7cc518cd404e73f6193c6349d"
-  integrity sha512-aUjdRFISbuFOl0EIZc+9e4FfZp0bDZgAdOOf30bJmw8VM9v84SHyVyxDfbWxpGYbdZD/9XoKxfHVNmxPkhwyGw==
+"@babel/plugin-proposal-optional-catch-binding@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.10.4.tgz#31c938309d24a78a49d68fdabffaa863758554dd"
+  integrity sha512-LflT6nPh+GK2MnFiKDyLiqSqVHkQnVf7hdoAvyTnnKj9xB3docGRsdPuxp6qqqW19ifK3xgc9U5/FwrSaCNX5g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
 
-ajv@^4.7.0:
-  version "4.11.8"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536"
-  integrity sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=
+"@babel/plugin-proposal-optional-chaining@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.11.0.tgz#de5866d0646f6afdaab8a566382fe3a221755076"
+  integrity sha512-v9fZIu3Y8562RRwhm1BbMRxtqZNFmFA2EG+pT2diuU8PT3H6T/KXoZ54KgYisfOFZHV6PfvAiBIZ9Rcz+/JCxA==
   dependencies:
-    co "^4.6.0"
-    json-stable-stringify "^1.0.1"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
 
-ajv@^5.0.0, ajv@^5.2.3, ajv@^5.3.0:
-  version "5.5.2"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965"
-  integrity sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=
+"@babel/plugin-proposal-private-methods@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.10.4.tgz#b160d972b8fdba5c7d111a145fc8c421fc2a6909"
+  integrity sha512-wh5GJleuI8k3emgTg5KkJK6kHNsGEr0uBTDBuQUBJwckk9xs1ez79ioheEVVxMLyPscB0LfkbVHslQqIzWV6Bw==
   dependencies:
-    co "^4.6.0"
-    fast-deep-equal "^1.0.0"
-    fast-json-stable-stringify "^2.0.0"
-    json-schema-traverse "^0.3.0"
+    "@babel/helper-create-class-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-ajv@^6.1.0, ajv@^6.5.5:
-  version "6.10.0"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1"
-  integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==
+"@babel/plugin-proposal-unicode-property-regex@^7.10.4", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.10.4.tgz#4483cda53041ce3413b7fe2f00022665ddfaa75d"
+  integrity sha512-H+3fOgPnEXFL9zGYtKQe4IDOPKYlZdF1kqFDQRRb8PK4B8af1vAGK04tF5iQAAsui+mHNBQSAtd2/ndEDe9wuA==
   dependencies:
-    fast-deep-equal "^2.0.1"
-    fast-json-stable-stringify "^2.0.0"
-    json-schema-traverse "^0.4.1"
-    uri-js "^4.2.2"
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-align-text@^0.1.1, align-text@^0.1.3:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117"
-  integrity sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=
+"@babel/plugin-syntax-async-generators@^7.8.0":
+  version "7.8.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
+  integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
   dependencies:
-    kind-of "^3.0.2"
-    longest "^1.0.1"
-    repeat-string "^1.5.2"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-alphanum-sort@^1.0.1, alphanum-sort@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/alphanum-sort/-/alphanum-sort-1.0.2.tgz#97a1119649b211ad33691d9f9f486a8ec9fbe0a3"
-  integrity sha1-l6ERlkmyEa0zaR2fn0hqjsn74KM=
+"@babel/plugin-syntax-class-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.10.4.tgz#6644e6a0baa55a61f9e3231f6c9eeb6ee46c124c"
+  integrity sha512-GCSBF7iUle6rNugfURwNmCGG3Z/2+opxAMLs1nND4bhEG5PuxTIggDBoeYYSujAlLtsupzOHYJQgPS3pivwXIA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-amdefine@>=0.0.4:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5"
-  integrity sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=
+"@babel/plugin-syntax-dynamic-import@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
+  integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-ansi-escapes@^1.1.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
-  integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=
+"@babel/plugin-syntax-export-namespace-from@^7.8.3":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a"
+  integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.3"
 
-ansi-escapes@^3.0.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b"
-  integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==
+"@babel/plugin-syntax-json-strings@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a"
+  integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-ansi-regex@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
-  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+"@babel/plugin-syntax-logical-assignment-operators@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699"
+  integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-ansi-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9"
+  integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-ansi-styles@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
-  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
+"@babel/plugin-syntax-numeric-separator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97"
+  integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-ansi-styles@^3.2.1:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
-  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
+"@babel/plugin-syntax-object-rest-spread@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
+  integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
   dependencies:
-    color-convert "^1.9.0"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-anymatch@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
-  integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==
+"@babel/plugin-syntax-optional-catch-binding@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1"
+  integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==
   dependencies:
-    micromatch "^3.1.4"
-    normalize-path "^2.1.1"
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-aproba@^1.0.3:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
-  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
+"@babel/plugin-syntax-optional-chaining@^7.8.0":
+  version "7.8.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a"
+  integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.8.0"
 
-are-we-there-yet@~1.1.2:
-  version "1.1.5"
-  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
-  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
+"@babel/plugin-syntax-top-level-await@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.10.4.tgz#4bbeb8917b54fcf768364e0a81f560e33a3ef57d"
+  integrity sha512-ni1brg4lXEmWyafKr0ccFWkJG0CeMt4WV1oyeBW6EFObF4oOHclbkj5cARxAPQyAQ2UTuplJyK4nfkXIMMFvsQ==
   dependencies:
-    delegates "^1.0.0"
-    readable-stream "^2.0.6"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-argparse@^1.0.7:
-  version "1.0.10"
-  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
-  integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
+"@babel/plugin-transform-arrow-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.10.4.tgz#e22960d77e697c74f41c501d44d73dbf8a6a64cd"
+  integrity sha512-9J/oD1jV0ZCBcgnoFWFq1vJd4msoKb/TCpGNFyyLt0zABdcvgK3aYikZ8HjzB14c26bc7E3Q1yugpwGy2aTPNA==
   dependencies:
-    sprintf-js "~1.0.2"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-arr-diff@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
-  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
+"@babel/plugin-transform-async-to-generator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.10.4.tgz#41a5017e49eb6f3cda9392a51eef29405b245a37"
+  integrity sha512-F6nREOan7J5UXTLsDsZG3DXmZSVofr2tGNwfdrVwkDWHfQckbQXnXSPfD7iO+c/2HGqycwyLST3DnZ16n+cBJQ==
+  dependencies:
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-remap-async-to-generator" "^7.10.4"
 
-arr-flatten@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
-  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
+"@babel/plugin-transform-block-scoped-functions@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.10.4.tgz#1afa595744f75e43a91af73b0d998ecfe4ebc2e8"
+  integrity sha512-WzXDarQXYYfjaV1szJvN3AD7rZgZzC1JtjJZ8dMHUyiK8mxPRahynp14zzNjU3VkPqPsO38CzxiWO1c9ARZ8JA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-arr-union@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
-  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
+"@babel/plugin-transform-block-scoping@^7.10.4":
+  version "7.11.1"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.11.1.tgz#5b7efe98852bef8d652c0b28144cd93a9e4b5215"
+  integrity sha512-00dYeDE0EVEHuuM+26+0w/SCL0BH2Qy7LwHuI4Hi4MH5gkC8/AqMN5uWFJIsoXZrAphiMm1iXzBw6L2T+eA0ew==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-array-find-index@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
-  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
+"@babel/plugin-transform-classes@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.10.4.tgz#405136af2b3e218bc4a1926228bc917ab1a0adc7"
+  integrity sha512-2oZ9qLjt161dn1ZE0Ms66xBncQH4In8Sqw1YWgBUZuGVJJS5c0OFZXL6dP2MRHrkU/eKhWg8CzFJhRQl50rQxA==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-define-map" "^7.10.4"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-optimise-call-expression" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.10.4"
+    globals "^11.1.0"
 
-array-includes@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d"
-  integrity sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0=
+"@babel/plugin-transform-computed-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.10.4.tgz#9ded83a816e82ded28d52d4b4ecbdd810cdfc0eb"
+  integrity sha512-JFwVDXcP/hM/TbyzGq3l/XWGut7p46Z3QvqFMXTfk6/09m7xZHJUN9xHfsv7vqqD4YnfI5ueYdSJtXqqBLyjBw==
   dependencies:
-    define-properties "^1.1.2"
-    es-abstract "^1.7.0"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-array-unique@^0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
-  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
+"@babel/plugin-transform-destructuring@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.10.4.tgz#70ddd2b3d1bea83d01509e9bb25ddb3a74fc85e5"
+  integrity sha512-+WmfvyfsyF603iPa6825mq6Qrb7uLjTOsa3XOFzlYcYDHSS4QmpOWOL0NNBY5qMbvrcf3tq0Cw+v4lxswOBpgA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-asn1.js@^4.0.0:
-  version "4.10.1"
-  resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0"
-  integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==
+"@babel/plugin-transform-dotall-regex@^7.10.4", "@babel/plugin-transform-dotall-regex@^7.4.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.10.4.tgz#469c2062105c1eb6a040eaf4fac4b488078395ee"
+  integrity sha512-ZEAVvUTCMlMFAbASYSVQoxIbHm2OkG2MseW6bV2JjIygOjdVv8tuxrCTzj1+Rynh7ODb8GivUy7dzEXzEhuPaA==
   dependencies:
-    bn.js "^4.0.0"
-    inherits "^2.0.1"
-    minimalistic-assert "^1.0.0"
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-asn1@~0.2.3:
-  version "0.2.4"
-  resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
-  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
+"@babel/plugin-transform-duplicate-keys@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.10.4.tgz#697e50c9fee14380fe843d1f306b295617431e47"
+  integrity sha512-GL0/fJnmgMclHiBTTWXNlYjYsA7rDrtsazHG6mglaGSTh0KsrW04qml+Bbz9FL0LcJIRwBWL5ZqlNHKTkU3xAA==
   dependencies:
-    safer-buffer "~2.1.0"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-assert-plus@1.0.0, assert-plus@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
-  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
+"@babel/plugin-transform-exponentiation-operator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.10.4.tgz#5ae338c57f8cf4001bdb35607ae66b92d665af2e"
+  integrity sha512-S5HgLVgkBcRdyQAHbKj+7KyuWx8C6t5oETmUuwz1pt3WTWJhsUV0WIIXuVvfXMxl/QQyHKlSCNNtaIamG8fysw==
+  dependencies:
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-assert@^1.1.1:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb"
-  integrity sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==
+"@babel/plugin-transform-for-of@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.10.4.tgz#c08892e8819d3a5db29031b115af511dbbfebae9"
+  integrity sha512-ItdQfAzu9AlEqmusA/65TqJ79eRcgGmpPPFvBnGILXZH975G0LNjP1yjHvGgfuCxqrPPueXOPe+FsvxmxKiHHQ==
   dependencies:
-    object-assign "^4.1.1"
-    util "0.10.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-assign-symbols@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
-  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
+"@babel/plugin-transform-function-name@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.10.4.tgz#6a467880e0fc9638514ba369111811ddbe2644b7"
+  integrity sha512-OcDCq2y5+E0dVD5MagT5X+yTRbcvFjDI2ZVAottGH6tzqjx/LKpgkUepu3hp/u4tZBzxxpNGwLsAvGBvQ2mJzg==
+  dependencies:
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-async-each@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
-  integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
+"@babel/plugin-transform-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.10.4.tgz#9f42ba0841100a135f22712d0e391c462f571f3c"
+  integrity sha512-Xd/dFSTEVuUWnyZiMu76/InZxLTYilOSr1UlHV+p115Z/Le2Fi1KXkJUYz0b42DfndostYlPub3m8ZTQlMaiqQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-async-foreach@^0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/async-foreach/-/async-foreach-0.1.3.tgz#36121f845c0578172de419a97dbeb1d16ec34542"
-  integrity sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=
+"@babel/plugin-transform-member-expression-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.10.4.tgz#b1ec44fcf195afcb8db2c62cd8e551c881baf8b7"
+  integrity sha512-0bFOvPyAoTBhtcJLr9VcwZqKmSjFml1iVxvPL0ReomGU53CX53HsM4h2SzckNdkQcHox1bpAqzxBI1Y09LlBSw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-async@^2.1.2, async@^2.4.1:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.2.tgz#18330ea7e6e313887f5d2f2a904bac6fe4dd5381"
-  integrity sha512-H1qVYh1MYhEEFLsP97cVKqCGo7KfCyTt6uEWqsTBr9SO84oK9Uwbyd/yCW+6rKJLHksBNUVWZDAjfS+Ccx0Bbg==
+"@babel/plugin-transform-modules-amd@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.10.5.tgz#1b9cddaf05d9e88b3aad339cb3e445c4f020a9b1"
+  integrity sha512-elm5uruNio7CTLFItVC/rIzKLfQ17+fX7EVz5W0TMgIHFo1zY0Ozzx+lgwhL4plzl8OzVn6Qasx5DeEFyoNiRw==
   dependencies:
-    lodash "^4.17.11"
+    "@babel/helper-module-transforms" "^7.10.5"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
-asynckit@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
-  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
+"@babel/plugin-transform-modules-commonjs@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.10.4.tgz#66667c3eeda1ebf7896d41f1f16b17105a2fbca0"
+  integrity sha512-Xj7Uq5o80HDLlW64rVfDBhao6OX89HKUmb+9vWYaLXBZOma4gA6tw4Ni1O5qVDoZWUV0fxMYA0aYzOawz0l+1w==
+  dependencies:
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-simple-access" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
-atob@^2.1.1:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
-  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
+"@babel/plugin-transform-modules-systemjs@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.10.5.tgz#6270099c854066681bae9e05f87e1b9cadbe8c85"
+  integrity sha512-f4RLO/OL14/FP1AEbcsWMzpbUz6tssRaeQg11RH1BP/XnPpRoVwgeYViMFacnkaw4k4wjRSjn3ip1Uw9TaXuMw==
+  dependencies:
+    "@babel/helper-hoist-variables" "^7.10.4"
+    "@babel/helper-module-transforms" "^7.10.5"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
-autoprefixer@^6.3.1:
-  version "6.7.7"
-  resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.7.7.tgz#1dbd1c835658e35ce3f9984099db00585c782014"
-  integrity sha1-Hb0cg1ZY41zj+ZhAmdsAWFx4IBQ=
+"@babel/plugin-transform-modules-umd@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.10.4.tgz#9a8481fe81b824654b3a0b65da3df89f3d21839e"
+  integrity sha512-mohW5q3uAEt8T45YT7Qc5ws6mWgJAaL/8BfWD9Dodo1A3RKWli8wTS+WiQ/knF+tXlPirW/1/MqzzGfCExKECA==
   dependencies:
-    browserslist "^1.7.6"
-    caniuse-db "^1.0.30000634"
-    normalize-range "^0.1.2"
-    num2fraction "^1.2.2"
-    postcss "^5.2.16"
-    postcss-value-parser "^3.2.3"
+    "@babel/helper-module-transforms" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-awesomplete@^1.1.2:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/awesomplete/-/awesomplete-1.1.4.tgz#cdfcbbb2391857ff3a3340b5b1ebde7701b355e6"
-  integrity sha512-AgYrODNlVD3ZJ6Em54YesLnOSusuVCjoRAt0l5bi3L1Oiv5r5dkPdxVPJaG3/wnPlxRUmGcpGnK02VK7N02kCg==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.10.4.tgz#78b4d978810b6f3bcf03f9e318f2fc0ed41aecb6"
+  integrity sha512-V6LuOnD31kTkxQPhKiVYzYC/Jgdq53irJC/xBSmqcNcqFGV+PER4l6rU5SH2Vl7bH9mLDHcc0+l9HUOe4RNGKA==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
 
-aws-sign2@~0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
-  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
+"@babel/plugin-transform-new-target@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.10.4.tgz#9097d753cb7b024cb7381a3b2e52e9513a9c6888"
+  integrity sha512-YXwWUDAH/J6dlfwqlWsztI2Puz1NtUAubXhOPLQ5gjR/qmQ5U96DY4FQO8At33JN4XPBhrjB8I4eMmLROjjLjw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-aws4@^1.8.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f"
-  integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==
+"@babel/plugin-transform-object-super@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.10.4.tgz#d7146c4d139433e7a6526f888c667e314a093894"
+  integrity sha512-5iTw0JkdRdJvr7sY0vHqTpnruUpTea32JHmq/atIWqsnNussbRzjEDyWep8UNztt1B5IusBYg8Irb0bLbiEBCQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-replace-supers" "^7.10.4"
+
+"@babel/plugin-transform-parameters@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.10.5.tgz#59d339d58d0b1950435f4043e74e2510005e2c4a"
+  integrity sha512-xPHwUj5RdFV8l1wuYiu5S9fqWGM2DrYc24TMvUiRrPVm+SM3XeqU9BcokQX/kEUe+p2RBwy+yoiR1w/Blq6ubw==
+  dependencies:
+    "@babel/helper-get-function-arity" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-property-literals@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.10.4.tgz#f6fe54b6590352298785b83edd815d214c42e3c0"
+  integrity sha512-ofsAcKiUxQ8TY4sScgsGeR2vJIsfrzqvFb9GvJ5UdXDzl+MyYCaBj/FGzXuv7qE0aJcjWMILny1epqelnFlz8g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-regenerator@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.10.4.tgz#2015e59d839074e76838de2159db421966fd8b63"
+  integrity sha512-3thAHwtor39A7C04XucbMg17RcZ3Qppfxr22wYzZNcVIkPHfpM9J0SO8zuCV6SZa265kxBJSrfKTvDCYqBFXGw==
+  dependencies:
+    regenerator-transform "^0.14.2"
+
+"@babel/plugin-transform-reserved-words@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.10.4.tgz#8f2682bcdcef9ed327e1b0861585d7013f8a54dd"
+  integrity sha512-hGsw1O6Rew1fkFbDImZIEqA8GoidwTAilwCyWqLBM9f+e/u/sQMQu7uX6dyokfOayRuuVfKOW4O7HvaBWM+JlQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-shorthand-properties@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.10.4.tgz#9fd25ec5cdd555bb7f473e5e6ee1c971eede4dd6"
+  integrity sha512-AC2K/t7o07KeTIxMoHneyX90v3zkm5cjHJEokrPEAGEy3UCp8sLKfnfOIGdZ194fyN4wfX/zZUWT9trJZ0qc+Q==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-spread@^7.11.0":
+  version "7.11.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.11.0.tgz#fa84d300f5e4f57752fe41a6d1b3c554f13f17cc"
+  integrity sha512-UwQYGOqIdQJe4aWNyS7noqAnN2VbaczPLiEtln+zPowRNlD+79w3oi2TWfYe0eZgd+gjZCbsydN7lzWysDt+gw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.11.0"
+
+"@babel/plugin-transform-sticky-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.10.4.tgz#8f3889ee8657581130a29d9cc91d7c73b7c4a28d"
+  integrity sha512-Ddy3QZfIbEV0VYcVtFDCjeE4xwVTJWTmUtorAJkn6u/92Z/nWJNV+mILyqHKrUxXYKA2EoCilgoPePymKL4DvQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/helper-regex" "^7.10.4"
+
+"@babel/plugin-transform-template-literals@^7.10.4":
+  version "7.10.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.10.5.tgz#78bc5d626a6642db3312d9d0f001f5e7639fde8c"
+  integrity sha512-V/lnPGIb+KT12OQikDvgSuesRX14ck5FfJXt6+tXhdkJ+Vsd0lDCVtF6jcB4rNClYFzaB2jusZ+lNISDk2mMMw==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-typeof-symbol@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.10.4.tgz#9509f1a7eec31c4edbffe137c16cc33ff0bc5bfc"
+  integrity sha512-QqNgYwuuW0y0H+kUE/GWSR45t/ccRhe14Fs/4ZRouNNQsyd4o3PG4OtHiIrepbM2WKUBDAXKCAK/Lk4VhzTaGA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-unicode-escapes@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.10.4.tgz#feae523391c7651ddac115dae0a9d06857892007"
+  integrity sha512-y5XJ9waMti2J+e7ij20e+aH+fho7Wb7W8rNuu72aKRwCHFqQdhkdU2lo3uZ9tQuboEJcUFayXdARhcxLQ3+6Fg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/plugin-transform-unicode-regex@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.10.4.tgz#e56d71f9282fac6db09c82742055576d5e6d80a8"
+  integrity sha512-wNfsc4s8N2qnIwpO/WP2ZiSyjfpTamT2C9V9FDH/Ljub9zw6P3SjkXcFmc0RQUt96k2fmIvtla2MMjgTwIAC+A==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+
+"@babel/preset-env@^7.11.5":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.11.5.tgz#18cb4b9379e3e92ffea92c07471a99a2914e4272"
+  integrity sha512-kXqmW1jVcnB2cdueV+fyBM8estd5mlNfaQi6lwLgRwCby4edpavgbFhiBNjmWA3JpB/yZGSISa7Srf+TwxDQoA==
+  dependencies:
+    "@babel/compat-data" "^7.11.0"
+    "@babel/helper-compilation-targets" "^7.10.4"
+    "@babel/helper-module-imports" "^7.10.4"
+    "@babel/helper-plugin-utils" "^7.10.4"
+    "@babel/plugin-proposal-async-generator-functions" "^7.10.4"
+    "@babel/plugin-proposal-class-properties" "^7.10.4"
+    "@babel/plugin-proposal-dynamic-import" "^7.10.4"
+    "@babel/plugin-proposal-export-namespace-from" "^7.10.4"
+    "@babel/plugin-proposal-json-strings" "^7.10.4"
+    "@babel/plugin-proposal-logical-assignment-operators" "^7.11.0"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.10.4"
+    "@babel/plugin-proposal-numeric-separator" "^7.10.4"
+    "@babel/plugin-proposal-object-rest-spread" "^7.11.0"
+    "@babel/plugin-proposal-optional-catch-binding" "^7.10.4"
+    "@babel/plugin-proposal-optional-chaining" "^7.11.0"
+    "@babel/plugin-proposal-private-methods" "^7.10.4"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.10.4"
+    "@babel/plugin-syntax-async-generators" "^7.8.0"
+    "@babel/plugin-syntax-class-properties" "^7.10.4"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.0"
+    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
+    "@babel/plugin-syntax-json-strings" "^7.8.0"
+    "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.0"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.0"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.0"
+    "@babel/plugin-syntax-top-level-await" "^7.10.4"
+    "@babel/plugin-transform-arrow-functions" "^7.10.4"
+    "@babel/plugin-transform-async-to-generator" "^7.10.4"
+    "@babel/plugin-transform-block-scoped-functions" "^7.10.4"
+    "@babel/plugin-transform-block-scoping" "^7.10.4"
+    "@babel/plugin-transform-classes" "^7.10.4"
+    "@babel/plugin-transform-computed-properties" "^7.10.4"
+    "@babel/plugin-transform-destructuring" "^7.10.4"
+    "@babel/plugin-transform-dotall-regex" "^7.10.4"
+    "@babel/plugin-transform-duplicate-keys" "^7.10.4"
+    "@babel/plugin-transform-exponentiation-operator" "^7.10.4"
+    "@babel/plugin-transform-for-of" "^7.10.4"
+    "@babel/plugin-transform-function-name" "^7.10.4"
+    "@babel/plugin-transform-literals" "^7.10.4"
+    "@babel/plugin-transform-member-expression-literals" "^7.10.4"
+    "@babel/plugin-transform-modules-amd" "^7.10.4"
+    "@babel/plugin-transform-modules-commonjs" "^7.10.4"
+    "@babel/plugin-transform-modules-systemjs" "^7.10.4"
+    "@babel/plugin-transform-modules-umd" "^7.10.4"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.10.4"
+    "@babel/plugin-transform-new-target" "^7.10.4"
+    "@babel/plugin-transform-object-super" "^7.10.4"
+    "@babel/plugin-transform-parameters" "^7.10.4"
+    "@babel/plugin-transform-property-literals" "^7.10.4"
+    "@babel/plugin-transform-regenerator" "^7.10.4"
+    "@babel/plugin-transform-reserved-words" "^7.10.4"
+    "@babel/plugin-transform-shorthand-properties" "^7.10.4"
+    "@babel/plugin-transform-spread" "^7.11.0"
+    "@babel/plugin-transform-sticky-regex" "^7.10.4"
+    "@babel/plugin-transform-template-literals" "^7.10.4"
+    "@babel/plugin-transform-typeof-symbol" "^7.10.4"
+    "@babel/plugin-transform-unicode-escapes" "^7.10.4"
+    "@babel/plugin-transform-unicode-regex" "^7.10.4"
+    "@babel/preset-modules" "^0.1.3"
+    "@babel/types" "^7.11.5"
+    browserslist "^4.12.0"
+    core-js-compat "^3.6.2"
+    invariant "^2.2.2"
+    levenary "^1.1.1"
+    semver "^5.5.0"
 
-babel-code-frame@^6.22.0, babel-code-frame@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
-  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
+"@babel/preset-modules@^0.1.3":
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.4.tgz#362f2b68c662842970fdb5e254ffc8fc1c2e415e"
+  integrity sha512-J36NhwnfdzpmH41M1DrnkkgAqhZaqr/NBdPfQ677mLzlaXo+oDiv1deyCDtgAhz8p328otdob0Du7+xgHGZbKg==
   dependencies:
-    chalk "^1.1.3"
+    "@babel/helper-plugin-utils" "^7.0.0"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.4.4"
+    "@babel/plugin-transform-dotall-regex" "^7.4.4"
+    "@babel/types" "^7.4.4"
     esutils "^2.0.2"
-    js-tokens "^3.0.2"
-
-babel-core@^6.24.1, babel-core@^6.26.0:
-  version "6.26.3"
-  resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-6.26.3.tgz#b2e2f09e342d0f0c88e2f02e067794125e75c207"
-  integrity sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==
-  dependencies:
-    babel-code-frame "^6.26.0"
-    babel-generator "^6.26.0"
-    babel-helpers "^6.24.1"
-    babel-messages "^6.23.0"
-    babel-register "^6.26.0"
-    babel-runtime "^6.26.0"
-    babel-template "^6.26.0"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    babylon "^6.18.0"
-    convert-source-map "^1.5.1"
-    debug "^2.6.9"
-    json5 "^0.5.1"
-    lodash "^4.17.4"
+
+"@babel/runtime@^7.8.4":
+  version "7.11.2"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
+  integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
+  dependencies:
+    regenerator-runtime "^0.13.4"
+
+"@babel/template@^7.10.4":
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278"
+  integrity sha512-ZCjD27cGJFUB6nmCB1Enki3r+L5kJveX9pq1SvAUKoICy6CZ9yD8xO086YXdYhvNjBdnekm4ZnaP5yC8Cs/1tA==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/parser" "^7.10.4"
+    "@babel/types" "^7.10.4"
+
+"@babel/traverse@^7.10.4", "@babel/traverse@^7.11.5":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.11.5.tgz#be777b93b518eb6d76ee2e1ea1d143daa11e61c3"
+  integrity sha512-EjiPXt+r7LiCZXEfRpSJd+jUMnBd4/9OUv7Nx3+0u9+eimMwJmG0Q98lw4/289JCoxSE8OolDMNZaaF/JZ69WQ==
+  dependencies:
+    "@babel/code-frame" "^7.10.4"
+    "@babel/generator" "^7.11.5"
+    "@babel/helper-function-name" "^7.10.4"
+    "@babel/helper-split-export-declaration" "^7.11.0"
+    "@babel/parser" "^7.11.5"
+    "@babel/types" "^7.11.5"
+    debug "^4.1.0"
+    globals "^11.1.0"
+    lodash "^4.17.19"
+
+"@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.4.4":
+  version "7.11.5"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d"
+  integrity sha512-bvM7Qz6eKnJVFIn+1LPtjlBFPVN5jNDc1XmN15vWe7Q3DPBufWWsLiIvUu7xW87uTG6QoggpIDnUgLQvPheU+Q==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.10.4"
+    lodash "^4.17.19"
+    to-fast-properties "^2.0.0"
+
+"@eslint/eslintrc@^0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.1.3.tgz#7d1a2b2358552cc04834c0979bd4275362e37085"
+  integrity sha512-4YVwPkANLeNtRjMekzux1ci8hIaH5eGKktGqR0d3LWsKNn5B2X/1Z6Trxy7jQXl9EBGE6Yj02O+t09FMeRllaA==
+  dependencies:
+    ajv "^6.12.4"
+    debug "^4.1.1"
+    espree "^7.3.0"
+    globals "^12.1.0"
+    ignore "^4.0.6"
+    import-fresh "^3.2.1"
+    js-yaml "^3.13.1"
+    lodash "^4.17.19"
     minimatch "^3.0.4"
-    path-is-absolute "^1.0.1"
-    private "^0.1.8"
-    slash "^1.0.0"
-    source-map "^0.5.7"
-
-babel-generator@^6.26.0:
-  version "6.26.1"
-  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
-  integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==
-  dependencies:
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    detect-indent "^4.0.0"
-    jsesc "^1.3.0"
-    lodash "^4.17.4"
-    source-map "^0.5.7"
-    trim-right "^1.0.1"
-
-babel-helper-builder-binary-assignment-operator-visitor@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz#cce4517ada356f4220bcae8a02c2b346f9a56664"
-  integrity sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=
-  dependencies:
-    babel-helper-explode-assignable-expression "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
-
-babel-helper-call-delegate@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz#ece6aacddc76e41c3461f88bfc575bd0daa2df8d"
-  integrity sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=
-  dependencies:
-    babel-helper-hoist-variables "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-traverse "^6.24.1"
-    babel-types "^6.24.1"
-
-babel-helper-define-map@^6.24.1:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz#a5f56dab41a25f97ecb498c7ebaca9819f95be5f"
-  integrity sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=
-  dependencies:
-    babel-helper-function-name "^6.24.1"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    lodash "^4.17.4"
-
-babel-helper-evaluate-path@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.2.0.tgz#0bb2eb01996c0cef53c5e8405e999fe4a0244c08"
-  integrity sha512-0EK9TUKMxHL549hWDPkQoS7R0Ozg1CDLheVBHYds2B2qoAvmr9ejY3zOXFsrICK73TN7bPhU14PBeKc8jcBTwg==
+    strip-json-comments "^3.1.1"
 
-babel-helper-explode-assignable-expression@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz#f25b82cf7dc10433c55f70592d5746400ac22caa"
-  integrity sha1-8luCz33BBDPFX3BZLVdGQArCLKo=
+"@nodelib/fs.scandir@2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b"
+  integrity sha512-eGmwYQn3gxo4r7jdQnkrrN6bY478C3P+a/y72IJukF8LjB6ZHeB3c+Ehacj3sYeSmUXGlnA67/PmbM9CVwL7Dw==
   dependencies:
-    babel-runtime "^6.22.0"
-    babel-traverse "^6.24.1"
-    babel-types "^6.24.1"
+    "@nodelib/fs.stat" "2.0.3"
+    run-parallel "^1.1.9"
 
-babel-helper-flip-expressions@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.2.0.tgz#160d2090a3d9f9c64a750905321a0bc218f884ec"
-  integrity sha512-rAsPA1pWBc7e2E6HepkP2e1sXugT+Oq/VCqhyuHJ8aJ2d/ifwnJfd4Qxjm21qlW43AN8tqaeByagKK6wECFMSw==
+"@nodelib/fs.stat@2.0.3", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.3.tgz#34dc5f4cabbc720f4e60f75a747e7ecd6c175bd3"
+  integrity sha512-bQBFruR2TAwoevBEd/NWMoAAtNGzTRgdrqnYCc7dhzfoNvqPzLyqlEQnzZ3kVnNrSp25iyxE00/3h2fqGAGArA==
+
+"@nodelib/fs.walk@^1.2.3":
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.4.tgz#011b9202a70a6366e436ca5c065844528ab04976"
+  integrity sha512-1V9XOY4rDW0rehzbrcqAmHnz8e7SKvX27gh8Gt2WgB0+pdzdiLV83p72kZPU+jvMbS1qU5mauP2iOvO8rhmurQ==
+  dependencies:
+    "@nodelib/fs.scandir" "2.1.3"
+    fastq "^1.6.0"
 
-babel-helper-function-name@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz#d3475b8c03ed98242a25b48351ab18399d3580a9"
-  integrity sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=
+"@npmcli/move-file@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.0.1.tgz#de103070dac0f48ce49cf6693c23af59c0f70464"
+  integrity sha512-Uv6h1sT+0DrblvIrolFtbvM1FgWm+/sy4B3pvLp67Zys+thcukzS5ekn7HsZFGpWP4Q3fYJCljbWQE/XivMRLw==
   dependencies:
-    babel-helper-get-function-arity "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-    babel-traverse "^6.24.1"
-    babel-types "^6.24.1"
+    mkdirp "^1.0.4"
 
-babel-helper-get-function-arity@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz#8f7782aa93407c41d3aa50908f89b031b1b6853d"
-  integrity sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=
+"@stylelint/postcss-css-in-js@^0.37.2":
+  version "0.37.2"
+  resolved "https://registry.yarnpkg.com/@stylelint/postcss-css-in-js/-/postcss-css-in-js-0.37.2.tgz#7e5a84ad181f4234a2480803422a47b8749af3d2"
+  integrity sha512-nEhsFoJurt8oUmieT8qy4nk81WRHmJynmVwn/Vts08PL9fhgIsMhk1GId5yAN643OzqEEb5S/6At2TZW7pqPDA==
   dependencies:
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
+    "@babel/core" ">=7.9.0"
 
-babel-helper-hoist-variables@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz#1ecb27689c9d25513eadbc9914a73f5408be7a76"
-  integrity sha1-HssnaJydJVE+rbyZFKc/VAi+enY=
+"@stylelint/postcss-markdown@^0.36.1":
+  version "0.36.1"
+  resolved "https://registry.yarnpkg.com/@stylelint/postcss-markdown/-/postcss-markdown-0.36.1.tgz#829b87e6c0f108014533d9d7b987dc9efb6632e8"
+  integrity sha512-iDxMBWk9nB2BPi1VFQ+Dc5+XpvODBHw2n3tYpaBZuEAFQlbtF9If0Qh5LTTwSi/XwdbJ2jt+0dis3i8omyggpw==
   dependencies:
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
+    remark "^12.0.0"
+    unist-util-find-all-after "^3.0.1"
 
-babel-helper-is-nodes-equiv@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
-  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
+"@types/color-name@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
+  integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
 
-babel-helper-is-void-0@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.2.0.tgz#6ed0ada8a9b1c5b6e88af6b47c1b3b5c080860eb"
-  integrity sha512-Axj1AYuD0E3Dl7nT3KxROP7VekEofz3XtEljzURf3fABalLpr8PamtgLFt+zuxtaCxRf9iuZmbAMMYWri5Bazw==
+"@types/json-schema@^7.0.5":
+  version "7.0.6"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.6.tgz#f4c7ec43e81b319a9815115031709f26987891f0"
+  integrity sha512-3c+yGKvVP5Y9TYBEibGNR+kLtijnj7mYrXRg+WpFb2X9xm04g/DXYkfg4hmzJQosc9snFNUPkbYIhu+KAm6jJw==
 
-babel-helper-mark-eval-scopes@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.2.0.tgz#7648aaf2ec92aae9b09a20ad91e8df5e1fcc94b2"
-  integrity sha512-KJuwrOUcHbvbh6he4xRXZFLaivK9DF9o3CrvpWnK1Wp0B+1ANYABXBMgwrnNFIDK/AvicxQ9CNr8wsgivlp4Aw==
-
-babel-helper-optimise-call-expression@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz#f7a13427ba9f73f8f4fa993c54a97882d1244257"
-  integrity sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=
-  dependencies:
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
-
-babel-helper-regex@^6.24.1:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz#325c59f902f82f24b74faceed0363954f6495e72"
-  integrity sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=
-  dependencies:
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    lodash "^4.17.4"
-
-babel-helper-remap-async-to-generator@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz#5ec581827ad723fecdd381f1c928390676e4551b"
-  integrity sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=
-  dependencies:
-    babel-helper-function-name "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-    babel-traverse "^6.24.1"
-    babel-types "^6.24.1"
-
-babel-helper-remove-or-void@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.2.0.tgz#8e46ad5b30560d57d7510b3fd93f332ee7c67386"
-  integrity sha512-1Z41upf/XR+PwY7Nd+F15Jo5BiQi5205ZXUuKed3yoyQgDkMyoM7vAdjEJS/T+M6jy32sXjskMUgms4zeiVtRA==
-
-babel-helper-replace-supers@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz#bf6dbfe43938d17369a213ca8a8bf74b6a90ab1a"
-  integrity sha1-v22/5Dk40XNpohPKiov3S2qQqxo=
-  dependencies:
-    babel-helper-optimise-call-expression "^6.24.1"
-    babel-messages "^6.23.0"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-    babel-traverse "^6.24.1"
-    babel-types "^6.24.1"
-
-babel-helper-to-multiple-sequence-expressions@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.2.0.tgz#d1a419634c6cb301f27858c659167cfee0a9d318"
-  integrity sha512-ij9lpfdP3+Zc/7kNwa+NXbTrUlsYEWPwt/ugmQO0qflzLrveTIkbfOqQztvitk81aG5NblYDQXDlRohzu3oa8Q==
+"@types/json5@^0.0.29":
+  version "0.0.29"
+  resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
+  integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
-babel-helpers@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-helpers/-/babel-helpers-6.24.1.tgz#3471de9caec388e5c850e597e58a26ddf37602b2"
-  integrity sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=
-  dependencies:
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
+"@types/minimist@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.0.tgz#69a23a3ad29caf0097f06eda59b361ee2f0639f6"
+  integrity sha1-aaI6OtKcrwCX8G7aWbNh7i8GOfY=
 
-babel-loader@^7.1.2:
-  version "7.1.5"
-  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-7.1.5.tgz#e3ee0cd7394aa557e013b02d3e492bfd07aa6d68"
-  integrity sha512-iCHfbieL5d1LfOQeeVJEUyD9rTwBcP/fcEbRCfempxTDuqrKpu0AZjLAQHEQa3Yqyj9ORKe2iHfoj4rHLf7xpw==
-  dependencies:
-    find-cache-dir "^1.0.0"
-    loader-utils "^1.0.2"
-    mkdirp "^0.5.1"
+"@types/node@*":
+  version "14.11.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.2.tgz#2de1ed6670439387da1c9f549a2ade2b0a799256"
+  integrity sha512-jiE3QIxJ8JLNcb1Ps6rDbysDhN4xa8DJJvuC9prr6w+1tIh+QAbYyNF3tyiZNLDBIuBCf4KEcV2UvQm/V60xfA==
 
-babel-messages@^6.23.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
-  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
-  dependencies:
-    babel-runtime "^6.22.0"
+"@types/normalize-package-data@^2.4.0":
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
+  integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
 
-babel-minify-webpack-plugin@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-minify-webpack-plugin/-/babel-minify-webpack-plugin-0.2.0.tgz#ef9694d11a1b8ab8f3204d89f5c9278dd28fc2a9"
-  integrity sha512-+5G5Qqm+DIVl7gY4rkHqlFRkaf1FZtz0imzu/Dy9+88AfOIuy7D5MQjkNgQr5gU6/YSZ+rImgxDqFcWkvvrjkQ==
-  dependencies:
-    babel-core "^6.24.1"
-    babel-preset-minify "^0.2.0"
-    webpack-sources "^1.0.1"
+"@types/parse-json@^4.0.0":
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
+  integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
+
+"@types/unist@^2.0.0", "@types/unist@^2.0.2":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.3.tgz#9c088679876f374eb5983f150d4787aa6fb32d7e"
+  integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ==
+
+"@webassemblyjs/ast@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964"
+  integrity sha512-C6wW5L+b7ogSDVqymbkkvuW9kruN//YisMED04xzeBBqjHa2FYnmvOlS6Xj68xWQRgWvI9cIglsjFowH/RJyEA==
+  dependencies:
+    "@webassemblyjs/helper-module-context" "1.9.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.9.0"
+    "@webassemblyjs/wast-parser" "1.9.0"
+
+"@webassemblyjs/floating-point-hex-parser@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.9.0.tgz#3c3d3b271bddfc84deb00f71344438311d52ffb4"
+  integrity sha512-TG5qcFsS8QB4g4MhrxK5TqfdNe7Ey/7YL/xN+36rRjl/BlGE/NcBvJcqsRgCP6Z92mRE+7N50pRIi8SmKUbcQA==
+
+"@webassemblyjs/helper-api-error@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.9.0.tgz#203f676e333b96c9da2eeab3ccef33c45928b6a2"
+  integrity sha512-NcMLjoFMXpsASZFxJ5h2HZRcEhDkvnNFOAKneP5RbKRzaWJN36NC4jqQHKwStIhGXu5mUWlUUk7ygdtrO8lbmw==
+
+"@webassemblyjs/helper-buffer@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.9.0.tgz#a1442d269c5feb23fcbc9ef759dac3547f29de00"
+  integrity sha512-qZol43oqhq6yBPx7YM3m9Bv7WMV9Eevj6kMi6InKOuZxhw+q9hOkvq5e/PpKSiLfyetpaBnogSbNCfBwyB00CA==
+
+"@webassemblyjs/helper-code-frame@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-code-frame/-/helper-code-frame-1.9.0.tgz#647f8892cd2043a82ac0c8c5e75c36f1d9159f27"
+  integrity sha512-ERCYdJBkD9Vu4vtjUYe8LZruWuNIToYq/ME22igL+2vj2dQ2OOujIZr3MEFvfEaqKoVqpsFKAGsRdBSBjrIvZA==
+  dependencies:
+    "@webassemblyjs/wast-printer" "1.9.0"
+
+"@webassemblyjs/helper-fsm@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-fsm/-/helper-fsm-1.9.0.tgz#c05256b71244214671f4b08ec108ad63b70eddb8"
+  integrity sha512-OPRowhGbshCb5PxJ8LocpdX9Kl0uB4XsAjl6jH/dWKlk/mzsANvhwbiULsaiqT5GZGT9qinTICdj6PLuM5gslw==
+
+"@webassemblyjs/helper-module-context@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-module-context/-/helper-module-context-1.9.0.tgz#25d8884b76839871a08a6c6f806c3979ef712f07"
+  integrity sha512-MJCW8iGC08tMk2enck1aPW+BE5Cw8/7ph/VGZxwyvGbJwjktKkDK7vy7gAmMDx88D7mhDTCNKAW5tED+gZ0W8g==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+
+"@webassemblyjs/helper-wasm-bytecode@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.9.0.tgz#4fed8beac9b8c14f8c58b70d124d549dd1fe5790"
+  integrity sha512-R7FStIzyNcd7xKxCZH5lE0Bqy+hGTwS3LJjuv1ZVxd9O7eHCedSdrId/hMOd20I+v8wDXEn+bjfKDLzTepoaUw==
+
+"@webassemblyjs/helper-wasm-section@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.9.0.tgz#5a4138d5a6292ba18b04c5ae49717e4167965346"
+  integrity sha512-XnMB8l3ek4tvrKUUku+IVaXNHz2YsJyOOmz+MMkZvh8h1uSJpSen6vYnw3IoQ7WwEuAhL8Efjms1ZWjqh2agvw==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/helper-buffer" "1.9.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.9.0"
+    "@webassemblyjs/wasm-gen" "1.9.0"
+
+"@webassemblyjs/ieee754@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.9.0.tgz#15c7a0fbaae83fb26143bbacf6d6df1702ad39e4"
+  integrity sha512-dcX8JuYU/gvymzIHc9DgxTzUUTLexWwt8uCTWP3otys596io0L5aW02Gb1RjYpx2+0Jus1h4ZFqjla7umFniTg==
+  dependencies:
+    "@xtuc/ieee754" "^1.2.0"
+
+"@webassemblyjs/leb128@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.9.0.tgz#f19ca0b76a6dc55623a09cffa769e838fa1e1c95"
+  integrity sha512-ENVzM5VwV1ojs9jam6vPys97B/S65YQtv/aanqnU7D8aSoHFX8GyhGg0CMfyKNIHBuAVjy3tlzd5QMMINa7wpw==
+  dependencies:
+    "@xtuc/long" "4.2.2"
+
+"@webassemblyjs/utf8@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.9.0.tgz#04d33b636f78e6a6813227e82402f7637b6229ab"
+  integrity sha512-GZbQlWtopBTP0u7cHrEx+73yZKrQoBMpwkGEIqlacljhXCkVM1kMQge/Mf+csMJAjEdSwhOyLAS0AoR3AG5P8w==
+
+"@webassemblyjs/wasm-edit@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.9.0.tgz#3fe6d79d3f0f922183aa86002c42dd256cfee9cf"
+  integrity sha512-FgHzBm80uwz5M8WKnMTn6j/sVbqilPdQXTWraSjBwFXSYGirpkSWE2R9Qvz9tNiTKQvoKILpCuTjBKzOIm0nxw==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/helper-buffer" "1.9.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.9.0"
+    "@webassemblyjs/helper-wasm-section" "1.9.0"
+    "@webassemblyjs/wasm-gen" "1.9.0"
+    "@webassemblyjs/wasm-opt" "1.9.0"
+    "@webassemblyjs/wasm-parser" "1.9.0"
+    "@webassemblyjs/wast-printer" "1.9.0"
+
+"@webassemblyjs/wasm-gen@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.9.0.tgz#50bc70ec68ded8e2763b01a1418bf43491a7a49c"
+  integrity sha512-cPE3o44YzOOHvlsb4+E9qSqjc9Qf9Na1OO/BHFy4OI91XDE14MjFN4lTMezzaIWdPqHnsTodGGNP+iRSYfGkjA==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.9.0"
+    "@webassemblyjs/ieee754" "1.9.0"
+    "@webassemblyjs/leb128" "1.9.0"
+    "@webassemblyjs/utf8" "1.9.0"
+
+"@webassemblyjs/wasm-opt@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.9.0.tgz#2211181e5b31326443cc8112eb9f0b9028721a61"
+  integrity sha512-Qkjgm6Anhm+OMbIL0iokO7meajkzQD71ioelnfPEj6r4eOFuqm4YC3VBPqXjFyyNwowzbMD+hizmprP/Fwkl2A==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/helper-buffer" "1.9.0"
+    "@webassemblyjs/wasm-gen" "1.9.0"
+    "@webassemblyjs/wasm-parser" "1.9.0"
+
+"@webassemblyjs/wasm-parser@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.9.0.tgz#9d48e44826df4a6598294aa6c87469d642fff65e"
+  integrity sha512-9+wkMowR2AmdSWQzsPEjFU7njh8HTO5MqO8vjwEHuM+AMHioNqSBONRdr0NQQ3dVQrzp0s8lTcYqzUdb7YgELA==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/helper-api-error" "1.9.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.9.0"
+    "@webassemblyjs/ieee754" "1.9.0"
+    "@webassemblyjs/leb128" "1.9.0"
+    "@webassemblyjs/utf8" "1.9.0"
+
+"@webassemblyjs/wast-parser@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-parser/-/wast-parser-1.9.0.tgz#3031115d79ac5bd261556cecc3fa90a3ef451914"
+  integrity sha512-qsqSAP3QQ3LyZjNC/0jBJ/ToSxfYJ8kYyuiGvtn/8MK89VrNEfwj7BPQzJVHi0jGTRK2dGdJ5PRqhtjzoww+bw==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/floating-point-hex-parser" "1.9.0"
+    "@webassemblyjs/helper-api-error" "1.9.0"
+    "@webassemblyjs/helper-code-frame" "1.9.0"
+    "@webassemblyjs/helper-fsm" "1.9.0"
+    "@xtuc/long" "4.2.2"
+
+"@webassemblyjs/wast-printer@1.9.0":
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.9.0.tgz#4935d54c85fef637b00ce9f52377451d00d47899"
+  integrity sha512-2J0nE95rHXHyQ24cWjMKJ1tqB/ds8z/cyeOZxJhcb+rW+SQASVjuznUSmdz5GpVJTzU8JkhYut0D3siFDD6wsA==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/wast-parser" "1.9.0"
+    "@xtuc/long" "4.2.2"
+
+"@xtuc/ieee754@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
+  integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==
+
+"@xtuc/long@4.2.2":
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
+  integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
 
-babel-plugin-check-es2015-constants@^6.22.0:
-  version "6.22.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz#35157b101426fd2ffd3da3f75c7d1e91835bbf8a"
-  integrity sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=
+acorn-jsx@^5.2.0:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.1.tgz#fc8661e11b7ac1539c47dbfea2e72b3af34d267b"
+  integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==
+
+acorn@^6.4.1:
+  version "6.4.1"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.4.1.tgz#531e58ba3f51b9dacb9a6646ca4debf5b14ca474"
+  integrity sha512-ZVA9k326Nwrj3Cj9jlh3wGFutC2ZornPNARZwsNYqQYgN0EsV2d53w5RN/co65Ohn4sUAUtb1rSUAOD6XN9idA==
+
+acorn@^7.4.0:
+  version "7.4.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c"
+  integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==
+
+aggregate-error@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
+  integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==
   dependencies:
-    babel-runtime "^6.22.0"
+    clean-stack "^2.0.0"
+    indent-string "^4.0.0"
 
-babel-plugin-minify-builtins@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.2.0.tgz#317f824b0907210b6348671bb040ca072e2e0c82"
-  integrity sha512-4i+8ntaS8gwVUcOz5y+zE+55OVOl2nTbmHV51D4wAIiKcRI8U5K//ip1GHfhsgk/NJrrHK7h97Oy5jpqt0Iixg==
+ajv-errors@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d"
+  integrity sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==
+
+ajv-keywords@^3.1.0, ajv-keywords@^3.4.1, ajv-keywords@^3.5.2:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
+  integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
+
+ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.4:
+  version "6.12.5"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.5.tgz#19b0e8bae8f476e5ba666300387775fb1a00a4da"
+  integrity sha512-lRF8RORchjpKG50/WFf8xmg7sgCLFiYNNnqdKflk63whMQcWR5ngGjiSXkL9bjxy6B2npOK2HSMN49jEBMSkag==
   dependencies:
-    babel-helper-evaluate-path "^0.2.0"
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
 
-babel-plugin-minify-constant-folding@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.2.0.tgz#8c70b528b2eb7c13e94d95c8789077d4cdbc3970"
-  integrity sha512-B3ffQBEUQ8ydlIkYv2MkZtTCbV7FAkWAV7NkyhcXlGpD10PaCxNGQ/B9oguXGowR1m16Q5nGhvNn8Pkn1MO6Hw==
+ansi-colors@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
+  integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
+
+ansi-regex@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
+  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
+
+ansi-regex@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
+  integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
+
+ansi-styles@^3.2.0, ansi-styles@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
+  integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==
   dependencies:
-    babel-helper-evaluate-path "^0.2.0"
+    color-convert "^1.9.0"
 
-babel-plugin-minify-dead-code-elimination@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.2.0.tgz#e8025ee10a1e5e4f202633a6928ce892c33747e3"
-  integrity sha512-zE7y3pRyzA4zK5nBou0kTcwUTSQ/AiFrynt1cIEYN7vcO2gS9ZFZoI0aO9JYLUdct5fsC1vfB35408yrzTyVfg==
+ansi-styles@^4.0.0, ansi-styles@^4.1.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359"
+  integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==
   dependencies:
-    babel-helper-evaluate-path "^0.2.0"
-    babel-helper-mark-eval-scopes "^0.2.0"
-    babel-helper-remove-or-void "^0.2.0"
-    lodash.some "^4.6.0"
+    "@types/color-name" "^1.1.1"
+    color-convert "^2.0.1"
 
-babel-plugin-minify-flip-comparisons@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.2.0.tgz#0c9c8e93155c8f09dedad8118b634c259f709ef5"
-  integrity sha512-QOqXSEmD/LhT3LpM1WCyzAGcQZYYKJF7oOHvS6QbpomHenydrV53DMdPX2mK01icBExKZcJAHF209wvDBa+CSg==
+anymatch@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb"
+  integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==
   dependencies:
-    babel-helper-is-void-0 "^0.2.0"
+    micromatch "^3.1.4"
+    normalize-path "^2.1.1"
 
-babel-plugin-minify-guarded-expressions@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.2.0.tgz#8a8c950040fce3e258a12e6eb21eab94ad7235ab"
-  integrity sha512-5+NSPdRQ9mnrHaA+zFj+D5OzmSiv90EX5zGH6cWQgR/OUqmCHSDqgTRPFvOctgpo8MJyO7Rt7ajs2UfLnlAwYg==
+anymatch@~3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.1.tgz#c55ecf02185e2469259399310c173ce31233b142"
+  integrity sha512-mM8522psRCqzV+6LhomX5wgp25YVibjh8Wj23I5RPkPppSVSjyKD2A2mBJmWGa+KN7f2D6LNh9jkBCeyLktzjg==
   dependencies:
-    babel-helper-flip-expressions "^0.2.0"
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
 
-babel-plugin-minify-infinity@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.2.0.tgz#30960c615ddbc657c045bb00a1d8eb4af257cf03"
-  integrity sha512-U694vrla1lN6vDHWGrR832t3a/A2eh+kyl019LxEE2+sS4VTydyOPRsAOIYAdJegWRA4cMX1lm9azAN0cLIr8g==
+aproba@^1.1.1:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
 
-babel-plugin-minify-mangle-names@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.2.0.tgz#719892297ff0106a6ec1a4b0fc062f1f8b6a8529"
-  integrity sha512-Gixuak1/CO7VCdjn15/8Bxe/QsAtDG4zPbnsNoe1mIJGCIH/kcmSjFhMlGJtXDQZd6EKzeMfA5WmX9+jvGRefw==
+argparse@^1.0.7:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
+  integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
   dependencies:
-    babel-helper-mark-eval-scopes "^0.2.0"
+    sprintf-js "~1.0.2"
 
-babel-plugin-minify-numeric-literals@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.2.0.tgz#5746e851700167a380c05e93f289a7070459a0d1"
-  integrity sha512-VcLpb+r1YS7+RIOXdRsFVLLqoh22177USpHf+JM/g1nZbzdqENmfd5v534MLAbRErhbz6SyK+NQViVzVtBxu8g==
+arr-diff@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
+  integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
 
-babel-plugin-minify-replace@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.2.0.tgz#3c1f06bc4e6d3e301eacb763edc1be611efc39b0"
-  integrity sha512-SEW6zoSVxh3OH6E1LCgyhhTWMnCv+JIRu5h5IlJDA11tU4ZeSF7uPQcO4vN/o52+FssRB26dmzJ/8D+z0QPg5Q==
+arr-flatten@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
+  integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
 
-babel-plugin-minify-simplify@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.2.0.tgz#21ceec4857100c5476d7cef121f351156e5c9bc0"
-  integrity sha512-Mj3Mwy2zVosMfXDWXZrQH5/uMAyfJdmDQ1NVqit+ArbHC3LlXVzptuyC1JxTyai/wgFvjLaichm/7vSUshkWqw==
+arr-union@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
+  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
+
+array-includes@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.1.tgz#cdd67e6852bdf9c1215460786732255ed2459348"
+  integrity sha512-c2VXaCHl7zPsvpkFsw4nxvFie4fh1ur9bpcgsVkIjqn0H/Xwdg+7fv3n2r/isyS8EBj5b06M9kHyZuIr4El6WQ==
   dependencies:
-    babel-helper-flip-expressions "^0.2.0"
-    babel-helper-is-nodes-equiv "^0.0.1"
-    babel-helper-to-multiple-sequence-expressions "^0.2.0"
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0"
+    is-string "^1.0.5"
 
-babel-plugin-minify-type-constructors@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.2.0.tgz#7f3b6458be0863cfd59e9985bed6d134aa7a2e17"
-  integrity sha512-NiOvvA9Pq6bki6nP4BayXwT5GZadw7DJFDDzHmkpnOQpENWe8RtHtKZM44MG1R6EQ5XxgbLdsdhswIzTkFlO5g==
-  dependencies:
-    babel-helper-is-void-0 "^0.2.0"
-
-babel-plugin-syntax-async-functions@^6.8.0:
-  version "6.13.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz#cad9cad1191b5ad634bf30ae0872391e0647be95"
-  integrity sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=
-
-babel-plugin-syntax-exponentiation-operator@^6.8.0:
-  version "6.13.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz#9ee7e8337290da95288201a6a57f4170317830de"
-  integrity sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=
-
-babel-plugin-syntax-trailing-function-commas@^6.22.0:
-  version "6.22.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz#ba0360937f8d06e40180a43fe0d5616fff532cf3"
-  integrity sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=
-
-babel-plugin-transform-async-to-generator@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz#6536e378aff6cb1d5517ac0e40eb3e9fc8d08761"
-  integrity sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=
-  dependencies:
-    babel-helper-remap-async-to-generator "^6.24.1"
-    babel-plugin-syntax-async-functions "^6.8.0"
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-arrow-functions@^6.22.0:
-  version "6.22.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz#452692cb711d5f79dc7f85e440ce41b9f244d221"
-  integrity sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-block-scoped-functions@^6.22.0:
-  version "6.22.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz#bbc51b49f964d70cb8d8e0b94e820246ce3a6141"
-  integrity sha1-u8UbSflk1wy42OC5ToICRs46YUE=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-block-scoping@^6.23.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz#d70f5299c1308d05c12f463813b0a09e73b1895f"
-  integrity sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=
-  dependencies:
-    babel-runtime "^6.26.0"
-    babel-template "^6.26.0"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    lodash "^4.17.4"
-
-babel-plugin-transform-es2015-classes@^6.23.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz#5a4c58a50c9c9461e564b4b2a3bfabc97a2584db"
-  integrity sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=
-  dependencies:
-    babel-helper-define-map "^6.24.1"
-    babel-helper-function-name "^6.24.1"
-    babel-helper-optimise-call-expression "^6.24.1"
-    babel-helper-replace-supers "^6.24.1"
-    babel-messages "^6.23.0"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-    babel-traverse "^6.24.1"
-    babel-types "^6.24.1"
-
-babel-plugin-transform-es2015-computed-properties@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz#6fe2a8d16895d5634f4cd999b6d3480a308159b3"
-  integrity sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=
-  dependencies:
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-
-babel-plugin-transform-es2015-destructuring@^6.23.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz#997bb1f1ab967f682d2b0876fe358d60e765c56d"
-  integrity sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-duplicate-keys@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz#73eb3d310ca969e3ef9ec91c53741a6f1576423e"
-  integrity sha1-c+s9MQypaePvnskcU3QabxV2Qj4=
-  dependencies:
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
-
-babel-plugin-transform-es2015-for-of@^6.23.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz#f47c95b2b613df1d3ecc2fdb7573623c75248691"
-  integrity sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-function-name@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz#834c89853bc36b1af0f3a4c5dbaa94fd8eacaa8b"
-  integrity sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=
-  dependencies:
-    babel-helper-function-name "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
-
-babel-plugin-transform-es2015-literals@^6.22.0:
-  version "6.22.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz#4f54a02d6cd66cf915280019a31d31925377ca2e"
-  integrity sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-modules-amd@^6.22.0, babel-plugin-transform-es2015-modules-amd@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz#3b3e54017239842d6d19c3011c4bd2f00a00d154"
-  integrity sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=
-  dependencies:
-    babel-plugin-transform-es2015-modules-commonjs "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-
-babel-plugin-transform-es2015-modules-commonjs@^6.23.0, babel-plugin-transform-es2015-modules-commonjs@^6.24.1:
-  version "6.26.2"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz#58a793863a9e7ca870bdc5a881117ffac27db6f3"
-  integrity sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q==
-  dependencies:
-    babel-plugin-transform-strict-mode "^6.24.1"
-    babel-runtime "^6.26.0"
-    babel-template "^6.26.0"
-    babel-types "^6.26.0"
-
-babel-plugin-transform-es2015-modules-systemjs@^6.23.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz#ff89a142b9119a906195f5f106ecf305d9407d23"
-  integrity sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=
-  dependencies:
-    babel-helper-hoist-variables "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-
-babel-plugin-transform-es2015-modules-umd@^6.23.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz#ac997e6285cd18ed6176adb607d602344ad38468"
-  integrity sha1-rJl+YoXNGO1hdq22B9YCNErThGg=
-  dependencies:
-    babel-plugin-transform-es2015-modules-amd "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-
-babel-plugin-transform-es2015-object-super@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz#24cef69ae21cb83a7f8603dad021f572eb278f8d"
-  integrity sha1-JM72muIcuDp/hgPa0CH1cusnj40=
-  dependencies:
-    babel-helper-replace-supers "^6.24.1"
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-parameters@^6.23.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz#57ac351ab49caf14a97cd13b09f66fdf0a625f2b"
-  integrity sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=
-  dependencies:
-    babel-helper-call-delegate "^6.24.1"
-    babel-helper-get-function-arity "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-template "^6.24.1"
-    babel-traverse "^6.24.1"
-    babel-types "^6.24.1"
-
-babel-plugin-transform-es2015-shorthand-properties@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz#24f875d6721c87661bbd99a4622e51f14de38aa0"
-  integrity sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=
-  dependencies:
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
-
-babel-plugin-transform-es2015-spread@^6.22.0:
-  version "6.22.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz#d6d68a99f89aedc4536c81a542e8dd9f1746f8d1"
-  integrity sha1-1taKmfia7cRTbIGlQujdnxdG+NE=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-sticky-regex@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz#00c1cdb1aca71112cdf0cf6126c2ed6b457ccdbc"
-  integrity sha1-AMHNsaynERLN8M9hJsLta0V8zbw=
-  dependencies:
-    babel-helper-regex "^6.24.1"
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
-
-babel-plugin-transform-es2015-template-literals@^6.22.0:
-  version "6.22.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz#a84b3450f7e9f8f1f6839d6d687da84bb1236d8d"
-  integrity sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-typeof-symbol@^6.23.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz#dec09f1cddff94b52ac73d505c84df59dcceb372"
-  integrity sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-es2015-unicode-regex@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz#d38b12f42ea7323f729387f18a7c5ae1faeb35e9"
-  integrity sha1-04sS9C6nMj9yk4fxinxa4frrNek=
-  dependencies:
-    babel-helper-regex "^6.24.1"
-    babel-runtime "^6.22.0"
-    regexpu-core "^2.0.0"
-
-babel-plugin-transform-exponentiation-operator@^6.22.0:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz#2ab0c9c7f3098fa48907772bb813fe41e8de3a0e"
-  integrity sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=
-  dependencies:
-    babel-helper-builder-binary-assignment-operator-visitor "^6.24.1"
-    babel-plugin-syntax-exponentiation-operator "^6.8.0"
-    babel-runtime "^6.22.0"
-
-babel-plugin-transform-inline-consecutive-adds@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.2.0.tgz#15dae78921057f4004f8eafd79e15ddc5f12f426"
-  integrity sha512-GlhOuLOQ28ua9prg0hT33HslCrEmz9xWXy9ZNZSACppCyRxxRW+haYtRgm7uYXCcd0q8ggCWD2pfWEJp5iiZfQ==
+array-union@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
+  integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
 
-babel-plugin-transform-member-expression-literals@^6.8.5:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
-  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
+array-unique@^0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
+  integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
 
-babel-plugin-transform-merge-sibling-variables@^6.8.6:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
-  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
+array.prototype.flat@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.2.3.tgz#0de82b426b0318dbfdb940089e38b043d37f6c7b"
+  integrity sha512-gBlRZV0VSmfPIeWfuuy56XZMvbVfbEUnOXUvt3F/eUUUSyzlgLxhEX4YAEpxNAogRGehPSnfXyPtYyKAhkzQhQ==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0-next.1"
 
-babel-plugin-transform-minify-booleans@^6.8.3:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
-  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
+arrify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d"
+  integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=
 
-babel-plugin-transform-property-literals@^6.8.5:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
-  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
+asn1.js@^5.2.0:
+  version "5.4.1"
+  resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.4.1.tgz#11a980b84ebb91781ce35b0fdc2ee294e3783f07"
+  integrity sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==
   dependencies:
-    esutils "^2.0.2"
+    bn.js "^4.0.0"
+    inherits "^2.0.1"
+    minimalistic-assert "^1.0.0"
+    safer-buffer "^2.1.0"
 
-babel-plugin-transform-regenerator@^6.22.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz#e0703696fbde27f0a3efcacf8b4dca2f7b3a8f2f"
-  integrity sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=
+assert@^1.1.1:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/assert/-/assert-1.5.0.tgz#55c109aaf6e0aefdb3dc4b71240c70bf574b18eb"
+  integrity sha512-EDsgawzwoun2CZkCgtxJbv392v4nbk9XDD06zI+kQYoBM/3RBWLlEyJARDOmhAAosBjWACEkKL6S+lIZtcAubA==
   dependencies:
-    regenerator-transform "^0.10.0"
+    object-assign "^4.1.1"
+    util "0.10.3"
 
-babel-plugin-transform-regexp-constructors@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.2.0.tgz#6aa5dd0acc515db4be929bbcec4ed4c946c534a3"
-  integrity sha512-7IsQ6aQx6LAaOqy97/PthTf+5Nx9grZww3r6E62IdWe76Yr8KsuwVjxzqSPQvESJqTE3EMADQ9S0RtwWDGNG9Q==
+assign-symbols@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
+  integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
 
-babel-plugin-transform-remove-console@^6.8.5:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
-  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
+astral-regex@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9"
+  integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==
 
-babel-plugin-transform-remove-debugger@^6.8.5:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
-  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
+astral-regex@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31"
+  integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==
 
-babel-plugin-transform-remove-undefined@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.2.0.tgz#94f052062054c707e8d094acefe79416b63452b1"
-  integrity sha512-O8v57tPMHkp89kA4ZfQEYds/pzgvz/QYerBJjIuL5/Jc7RnvMVRA5gJY9zFKP7WayW8WOSBV4vh8Y8FJRio+ow==
-  dependencies:
-    babel-helper-evaluate-path "^0.2.0"
+async-each@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
+  integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
 
-babel-plugin-transform-simplify-comparison-operators@^6.8.5:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
-  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
+atob@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
+  integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
-babel-plugin-transform-strict-mode@^6.24.1:
-  version "6.24.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz#d5faf7aa578a65bbe591cf5edae04a0c67020758"
-  integrity sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=
+autoprefixer@^9.8.6:
+  version "9.8.6"
+  resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f"
+  integrity sha512-XrvP4VVHdRBCdX1S3WXVD8+RyG9qeb1D5Sn1DeLiG2xfSpzellk5k54xbUERJ3M5DggQxes39UGOTP8CFrEGbg==
   dependencies:
-    babel-runtime "^6.22.0"
-    babel-types "^6.24.1"
-
-babel-plugin-transform-undefined-to-void@^6.8.3:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
-  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
-
-babel-preset-env@^1.6.1:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/babel-preset-env/-/babel-preset-env-1.7.0.tgz#dea79fa4ebeb883cd35dab07e260c1c9c04df77a"
-  integrity sha512-9OR2afuKDneX2/q2EurSftUYM0xGu4O2D9adAhVfADDhrYDaxXV0rBbevVYoY9n6nyX1PmQW/0jtpJvUNr9CHg==
-  dependencies:
-    babel-plugin-check-es2015-constants "^6.22.0"
-    babel-plugin-syntax-trailing-function-commas "^6.22.0"
-    babel-plugin-transform-async-to-generator "^6.22.0"
-    babel-plugin-transform-es2015-arrow-functions "^6.22.0"
-    babel-plugin-transform-es2015-block-scoped-functions "^6.22.0"
-    babel-plugin-transform-es2015-block-scoping "^6.23.0"
-    babel-plugin-transform-es2015-classes "^6.23.0"
-    babel-plugin-transform-es2015-computed-properties "^6.22.0"
-    babel-plugin-transform-es2015-destructuring "^6.23.0"
-    babel-plugin-transform-es2015-duplicate-keys "^6.22.0"
-    babel-plugin-transform-es2015-for-of "^6.23.0"
-    babel-plugin-transform-es2015-function-name "^6.22.0"
-    babel-plugin-transform-es2015-literals "^6.22.0"
-    babel-plugin-transform-es2015-modules-amd "^6.22.0"
-    babel-plugin-transform-es2015-modules-commonjs "^6.23.0"
-    babel-plugin-transform-es2015-modules-systemjs "^6.23.0"
-    babel-plugin-transform-es2015-modules-umd "^6.23.0"
-    babel-plugin-transform-es2015-object-super "^6.22.0"
-    babel-plugin-transform-es2015-parameters "^6.23.0"
-    babel-plugin-transform-es2015-shorthand-properties "^6.22.0"
-    babel-plugin-transform-es2015-spread "^6.22.0"
-    babel-plugin-transform-es2015-sticky-regex "^6.22.0"
-    babel-plugin-transform-es2015-template-literals "^6.22.0"
-    babel-plugin-transform-es2015-typeof-symbol "^6.23.0"
-    babel-plugin-transform-es2015-unicode-regex "^6.22.0"
-    babel-plugin-transform-exponentiation-operator "^6.22.0"
-    babel-plugin-transform-regenerator "^6.22.0"
-    browserslist "^3.2.6"
-    invariant "^2.2.2"
-    semver "^5.3.0"
+    browserslist "^4.12.0"
+    caniuse-lite "^1.0.30001109"
+    colorette "^1.2.1"
+    normalize-range "^0.1.2"
+    num2fraction "^1.2.2"
+    postcss "^7.0.32"
+    postcss-value-parser "^4.1.0"
 
-babel-preset-minify@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.2.0.tgz#006566552d9b83834472273f306c0131062a0acc"
-  integrity sha512-mR8Q44RmMzm18bM2Lqd9uiPopzk5GDCtVuquNbLFmX6lOKnqWoenaNBxnWW0UhBFC75lEHTIgNGCbnsRI0pJVw==
-  dependencies:
-    babel-plugin-minify-builtins "^0.2.0"
-    babel-plugin-minify-constant-folding "^0.2.0"
-    babel-plugin-minify-dead-code-elimination "^0.2.0"
-    babel-plugin-minify-flip-comparisons "^0.2.0"
-    babel-plugin-minify-guarded-expressions "^0.2.0"
-    babel-plugin-minify-infinity "^0.2.0"
-    babel-plugin-minify-mangle-names "^0.2.0"
-    babel-plugin-minify-numeric-literals "^0.2.0"
-    babel-plugin-minify-replace "^0.2.0"
-    babel-plugin-minify-simplify "^0.2.0"
-    babel-plugin-minify-type-constructors "^0.2.0"
-    babel-plugin-transform-inline-consecutive-adds "^0.2.0"
-    babel-plugin-transform-member-expression-literals "^6.8.5"
-    babel-plugin-transform-merge-sibling-variables "^6.8.6"
-    babel-plugin-transform-minify-booleans "^6.8.3"
-    babel-plugin-transform-property-literals "^6.8.5"
-    babel-plugin-transform-regexp-constructors "^0.2.0"
-    babel-plugin-transform-remove-console "^6.8.5"
-    babel-plugin-transform-remove-debugger "^6.8.5"
-    babel-plugin-transform-remove-undefined "^0.2.0"
-    babel-plugin-transform-simplify-comparison-operators "^6.8.5"
-    babel-plugin-transform-undefined-to-void "^6.8.3"
-    lodash.isplainobject "^4.0.6"
-
-babel-register@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-register/-/babel-register-6.26.0.tgz#6ed021173e2fcb486d7acb45c6009a856f647071"
-  integrity sha1-btAhFz4vy0htestFxgCahW9kcHE=
-  dependencies:
-    babel-core "^6.26.0"
-    babel-runtime "^6.26.0"
-    core-js "^2.5.0"
-    home-or-tmp "^2.0.0"
-    lodash "^4.17.4"
-    mkdirp "^0.5.1"
-    source-map-support "^0.4.15"
-
-babel-runtime@^6.18.0, babel-runtime@^6.22.0, babel-runtime@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
-  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
-  dependencies:
-    core-js "^2.4.0"
-    regenerator-runtime "^0.11.0"
-
-babel-template@^6.24.1, babel-template@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-template/-/babel-template-6.26.0.tgz#de03e2d16396b069f46dd9fff8521fb1a0e35e02"
-  integrity sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=
-  dependencies:
-    babel-runtime "^6.26.0"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    babylon "^6.18.0"
-    lodash "^4.17.4"
-
-babel-traverse@^6.24.1, babel-traverse@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
-  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
-  dependencies:
-    babel-code-frame "^6.26.0"
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    babylon "^6.18.0"
-    debug "^2.6.8"
-    globals "^9.18.0"
-    invariant "^2.2.2"
-    lodash "^4.17.4"
+awesomplete@^1.1.2:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/awesomplete/-/awesomplete-1.1.5.tgz#1b2b5dd106d3955595619c03da472a1dc0faf0af"
+  integrity sha512-UFw1mPW8NaSECDSTC36HbAOTpF9JK2wBUJcNn4MSvlNtK7SZ9N72gB+ajHtA6D1abYXRcszZnBA4nHBwvFwzHw==
 
-babel-types@^6.19.0, babel-types@^6.24.1, babel-types@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
-  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
+babel-loader@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.1.0.tgz#c611d5112bd5209abe8b9fa84c3e4da25275f1c3"
+  integrity sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw==
   dependencies:
-    babel-runtime "^6.26.0"
-    esutils "^2.0.2"
-    lodash "^4.17.4"
-    to-fast-properties "^1.0.3"
+    find-cache-dir "^2.1.0"
+    loader-utils "^1.4.0"
+    mkdirp "^0.5.3"
+    pify "^4.0.1"
+    schema-utils "^2.6.5"
 
-babylon@^6.18.0:
-  version "6.18.0"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
-  integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
+babel-plugin-dynamic-import-node@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
+  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
+  dependencies:
+    object.assign "^4.1.0"
 
-balanced-match@^0.4.2:
-  version "0.4.2"
-  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-0.4.2.tgz#cb3f3e3c732dc0f01ee70b403f302e61d7709838"
-  integrity sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg=
+bail@^1.0.0:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776"
+  integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==
 
 balanced-match@^1.0.0:
   version "1.0.0"
@@ -1018,9 +1320,9 @@ balanced-match@^1.0.0:
   integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
 
 base64-js@^1.0.2:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"
-  integrity sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
+  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
 
 base@^0.11.1:
   version "0.11.2"
@@ -1035,13 +1337,6 @@ base@^0.11.1:
     mixin-deep "^1.2.0"
     pascalcase "^0.1.1"
 
-bcrypt-pbkdf@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
-  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
-  dependencies:
-    tweetnacl "^0.14.3"
-
 big.js@^5.2.2:
   version "5.2.2"
   resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@@ -1052,22 +1347,37 @@ binary-extensions@^1.0.0:
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
   integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
 
+binary-extensions@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9"
+  integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ==
+
+bindings@^1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
+  integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
+  dependencies:
+    file-uri-to-path "1.0.0"
+
 blazy@^1.8.2:
   version "1.8.2"
   resolved "https://registry.yarnpkg.com/blazy/-/blazy-1.8.2.tgz#50dfd638baaf9003efd6eb3a836aca54184ab6da"
   integrity sha1-UN/WOLqvkAPv1us6g2rKVBhKtto=
 
-block-stream@*:
-  version "0.0.9"
-  resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a"
-  integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=
-  dependencies:
-    inherits "~2.0.0"
+bluebird@^3.5.5:
+  version "3.7.2"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
+  integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
+
+bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.4.0:
+  version "4.11.9"
+  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.9.tgz#26d556829458f9d1e81fc48952493d0ba3507828"
+  integrity sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==
 
-bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
-  version "4.11.8"
-  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
-  integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==
+bn.js@^5.1.1:
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.1.3.tgz#beca005408f642ebebea80b042b4d18d2ac0ee6b"
+  integrity sha512-GkTiFpjFtUzU9CbMeJ5iazkCzGL3jrhzerzZIuqLABjbwRaFt33I9tUdSNryIptM+RxDet6OKm2WnLXzW51KsQ==
 
 brace-expansion@^1.1.7:
   version "1.1.11"
@@ -1093,6 +1403,13 @@ braces@^2.3.1, braces@^2.3.2:
     split-string "^3.0.2"
     to-regex "^3.0.1"
 
+braces@^3.0.1, braces@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  dependencies:
+    fill-range "^7.0.1"
+
 brorand@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
@@ -1129,7 +1446,7 @@ browserify-des@^1.0.0:
     inherits "^2.0.1"
     safe-buffer "^5.1.2"
 
-browserify-rsa@^4.0.0:
+browserify-rsa@^4.0.0, browserify-rsa@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.0.1.tgz#21e0abfaf6f2029cf2fafb133567a701d4135524"
   integrity sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=
@@ -1138,17 +1455,19 @@ browserify-rsa@^4.0.0:
     randombytes "^2.0.1"
 
 browserify-sign@^4.0.0:
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.0.4.tgz#aa4eb68e5d7b658baa6bf6a57e630cbd7a93d298"
-  integrity sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=
-  dependencies:
-    bn.js "^4.1.1"
-    browserify-rsa "^4.0.0"
-    create-hash "^1.1.0"
-    create-hmac "^1.1.2"
-    elliptic "^6.0.0"
-    inherits "^2.0.1"
-    parse-asn1 "^5.0.0"
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.1.tgz#eaf4add46dd54be3bb3b36c0cf15abbeba7956c3"
+  integrity sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==
+  dependencies:
+    bn.js "^5.1.1"
+    browserify-rsa "^4.0.1"
+    create-hash "^1.2.0"
+    create-hmac "^1.1.7"
+    elliptic "^6.5.3"
+    inherits "^2.0.4"
+    parse-asn1 "^5.1.5"
+    readable-stream "^3.6.0"
+    safe-buffer "^5.2.0"
 
 browserify-zlib@^0.2.0:
   version "0.2.0"
@@ -1157,21 +1476,15 @@ browserify-zlib@^0.2.0:
   dependencies:
     pako "~1.0.5"
 
-browserslist@^1.3.6, browserslist@^1.5.2, browserslist@^1.7.6:
-  version "1.7.7"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-1.7.7.tgz#0bd76704258be829b2398bb50e4b62d1a166b0b9"
-  integrity sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=
-  dependencies:
-    caniuse-db "^1.0.30000639"
-    electron-to-chromium "^1.2.7"
-
-browserslist@^3.2.6:
-  version "3.2.8"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-3.2.8.tgz#b0005361d6471f0f5952797a76fc985f1f978fc6"
-  integrity sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==
+browserslist@^4.12.0, browserslist@^4.8.5:
+  version "4.14.3"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.14.3.tgz#381f9e7f13794b2eb17e1761b4f118e8ae665a53"
+  integrity sha512-GcZPC5+YqyPO4SFnz48/B0YaCwS47Q9iPChRGi6t7HhflKBcINzFrJvRfC+jp30sRMKxF+d4EHGs27Z0XP1NaQ==
   dependencies:
-    caniuse-lite "^1.0.30000844"
-    electron-to-chromium "^1.3.47"
+    caniuse-lite "^1.0.30001131"
+    electron-to-chromium "^1.3.570"
+    escalade "^3.1.0"
+    node-releases "^1.1.61"
 
 buffer-from@^1.0.0:
   version "1.1.1"
@@ -1184,9 +1497,9 @@ buffer-xor@^1.0.3:
   integrity sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=
 
 buffer@^4.3.0:
-  version "4.9.1"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.1.tgz#6d1bb601b07a4efced97094132093027c95bc298"
-  integrity sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=
+  version "4.9.2"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-4.9.2.tgz#230ead344002988644841ab0244af8c44bbe3ef8"
+  integrity sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==
   dependencies:
     base64-js "^1.0.2"
     ieee754 "^1.1.4"
@@ -1197,6 +1510,50 @@ builtin-status-codes@^3.0.0:
   resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
   integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=
 
+cacache@^12.0.2:
+  version "12.0.4"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-12.0.4.tgz#668bcbd105aeb5f1d92fe25570ec9525c8faa40c"
+  integrity sha512-a0tMB40oefvuInr4Cwb3GerbL9xTj1D5yg0T5xrjGCGyfvbxseIXX7BAO/u/hIXdafzOI5JC3wDwHyf24buOAQ==
+  dependencies:
+    bluebird "^3.5.5"
+    chownr "^1.1.1"
+    figgy-pudding "^3.5.1"
+    glob "^7.1.4"
+    graceful-fs "^4.1.15"
+    infer-owner "^1.0.3"
+    lru-cache "^5.1.1"
+    mississippi "^3.0.0"
+    mkdirp "^0.5.1"
+    move-concurrently "^1.0.1"
+    promise-inflight "^1.0.1"
+    rimraf "^2.6.3"
+    ssri "^6.0.1"
+    unique-filename "^1.1.1"
+    y18n "^4.0.0"
+
+cacache@^15.0.5:
+  version "15.0.5"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-15.0.5.tgz#69162833da29170d6732334643c60e005f5f17d0"
+  integrity sha512-lloiL22n7sOjEEXdL8NAjTgv9a1u43xICE9/203qonkZUCj5X1UEWIdf2/Y0d6QcCtMzbKQyhrcDbdvlZTs/+A==
+  dependencies:
+    "@npmcli/move-file" "^1.0.1"
+    chownr "^2.0.0"
+    fs-minipass "^2.0.0"
+    glob "^7.1.4"
+    infer-owner "^1.0.4"
+    lru-cache "^6.0.0"
+    minipass "^3.1.1"
+    minipass-collect "^1.0.2"
+    minipass-flush "^1.0.5"
+    minipass-pipeline "^1.2.2"
+    mkdirp "^1.0.3"
+    p-map "^4.0.0"
+    promise-inflight "^1.0.1"
+    rimraf "^3.0.2"
+    ssri "^8.0.0"
+    tar "^6.0.2"
+    unique-filename "^1.1.1"
+
 cache-base@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@@ -1212,91 +1569,41 @@ cache-base@^1.0.1:
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
-caller-path@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/caller-path/-/caller-path-0.1.0.tgz#94085ef63581ecd3daa92444a8fe94e82577751f"
-  integrity sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=
-  dependencies:
-    callsites "^0.2.0"
-
-callsites@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca"
-  integrity sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=
+callsites@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
+  integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
 
-camelcase-keys@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
-  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
+camelcase-keys@^6.2.2:
+  version "6.2.2"
+  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0"
+  integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==
   dependencies:
-    camelcase "^2.0.0"
-    map-obj "^1.0.0"
-
-camelcase@^1.0.2:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-1.2.1.tgz#9bb5304d2e0b56698b2c758b08a3eaa9daa58a39"
-  integrity sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=
+    camelcase "^5.3.1"
+    map-obj "^4.0.0"
+    quick-lru "^4.0.1"
 
-camelcase@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
-  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
+camelcase@^5.0.0, camelcase@^5.3.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
+  integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
-camelcase@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-3.0.0.tgz#32fc4b9fcdaf845fcdf7e73bb97cac2261f0ab0a"
-  integrity sha1-MvxLn82vhF/N9+c7uXysImHwqwo=
+camelcase@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e"
+  integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==
 
-camelcase@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
-  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
+caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001131:
+  version "1.0.30001135"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001135.tgz#995b1eb94404a3c9a0d7600c113c9bb27f2cd8aa"
+  integrity sha512-ziNcheTGTHlu9g34EVoHQdIu5g4foc8EsxMGC7Xkokmvw0dqNtX8BS8RgCgFBaAiSp2IdjvBxNdh0ssib28eVQ==
 
-caniuse-api@^1.5.2:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-1.6.1.tgz#b534e7c734c4f81ec5fbe8aca2ad24354b962c6c"
-  integrity sha1-tTTnxzTE+B7F++isoq0kNUuWLGw=
-  dependencies:
-    browserslist "^1.3.6"
-    caniuse-db "^1.0.30000529"
-    lodash.memoize "^4.1.2"
-    lodash.uniq "^4.5.0"
-
-caniuse-db@^1.0.30000529, caniuse-db@^1.0.30000634, caniuse-db@^1.0.30000639:
-  version "1.0.30000969"
-  resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000969.tgz#e6aeca9b1bac88865990913a0b041f587180cd59"
-  integrity sha512-ttrmwpIXvEL/kg0JSg6Q+xEbMxAEcjZOOgZMGPcMe5JMYgi20Nvs9bqMRGfyIOQtd1jYa6yRWODIR6apj3xPQw==
-
-caniuse-lite@^1.0.30000844:
-  version "1.0.30000969"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000969.tgz#7664f571f2072657bde70b00a1fc1ba41f1942a9"
-  integrity sha512-Kus0yxkoAJgVc0bax7S4gLSlFifCa7MnSZL9p9VuS/HIKEL4seaqh28KIQAAO50cD/rJ5CiJkJFapkdDAlhFxQ==
-
-caseless@~0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
-  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
-
-center-align@^0.1.1:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/center-align/-/center-align-0.1.3.tgz#aa0d32629b6ee972200411cbd4461c907bc2b7ad"
-  integrity sha1-qg0yYptu6XIgBBHL1EYckHvCt60=
-  dependencies:
-    align-text "^0.1.3"
-    lazy-cache "^1.0.3"
-
-chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
-  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
-  dependencies:
-    ansi-styles "^2.2.1"
-    escape-string-regexp "^1.0.2"
-    has-ansi "^2.0.0"
-    strip-ansi "^3.0.0"
-    supports-color "^2.0.0"
+ccount@^1.0.0:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.0.5.tgz#ac82a944905a65ce204eb03023157edf29425c17"
+  integrity sha512-MOli1W+nfbPLlKEhInaxhRdp7KVLFxLN5ykwzHgLsLI3H3gs5jjFAK4Eoj3OzzcxCtumDaI8onoVDeQyWaNTkw==
 
-chalk@^2.0.0, chalk@^2.1.0, chalk@^2.4.1:
+chalk@^2.0.0, chalk@^2.4.2:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -1305,15 +1612,53 @@ chalk@^2.0.0, chalk@^2.1.0, chalk@^2.4.1:
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chardet@^0.4.0:
-  version "0.4.2"
-  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.4.2.tgz#b5473b33dc97c424e5d98dc87d55d4d8a29c8bf2"
-  integrity sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=
+chalk@^4.0.0, chalk@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
+  integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
+character-entities-html4@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/character-entities-html4/-/character-entities-html4-1.1.4.tgz#0e64b0a3753ddbf1fdc044c5fd01d0199a02e125"
+  integrity sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g==
+
+character-entities-legacy@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1"
+  integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==
+
+character-entities@^1.0.0:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b"
+  integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==
 
-chokidar@^2.0.2:
-  version "2.1.6"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.6.tgz#b6cad653a929e244ce8a834244164d241fa954c5"
-  integrity sha512-V2jUo67OKkc6ySiRpJrjlpJKl9kDuG+Xb8VgsGzb+aEouhgS1D0weyPU4lEzdAcsCAvrih2J2BqyXqHWvVLw5g==
+character-reference-invalid@^1.0.0:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560"
+  integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==
+
+"chokidar@>=2.0.0 <4.0.0", chokidar@^3.4.1:
+  version "3.4.2"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.2.tgz#38dc8e658dec3809741eb3ef7bb0a47fe424232d"
+  integrity sha512-IZHaDeBeI+sZJRX7lGcXsdzgvZqKv6sECqsbErJA4mHWfpRrD8B97kSFN4cQz6nGBGiuFia1MKR4d6c1o8Cv7A==
+  dependencies:
+    anymatch "~3.1.1"
+    braces "~3.0.2"
+    glob-parent "~5.1.0"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.4.0"
+  optionalDependencies:
+    fsevents "~2.1.2"
+
+chokidar@^2.1.8:
+  version "2.1.8"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"
+  integrity sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==
   dependencies:
     anymatch "^2.0.0"
     async-each "^1.0.1"
@@ -1330,9 +1675,21 @@ chokidar@^2.0.2:
     fsevents "^1.2.7"
 
 chownr@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.1.tgz#54726b8b8fff4df053c42187e801fb4412df1494"
-  integrity sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chownr@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
+  integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
+
+chrome-trace-event@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4"
+  integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==
+  dependencies:
+    tslib "^1.9.0"
 
 cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
   version "1.0.4"
@@ -1342,18 +1699,6 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
     inherits "^2.0.1"
     safe-buffer "^5.0.1"
 
-circular-json@^0.3.1:
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.3.tgz#815c99ea84f6809529d2f45791bdf82711352d66"
-  integrity sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==
-
-clap@^1.0.9:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/clap/-/clap-1.2.3.tgz#4f36745b32008492557f46412d66d50cb99bce51"
-  integrity sha512-4CoL/A3hf90V3VIEjeuhSvlGFEHKzOz+Wfc2IVZc+FaUgU0ZQafJTP49fvnULipOPcAfqhyI2duwQyns6xqjYA==
-  dependencies:
-    chalk "^1.1.3"
-
 class-utils@^0.3.5:
   version "0.3.6"
   resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@@ -1364,74 +1709,31 @@ class-utils@^0.3.5:
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
-cli-cursor@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
-  integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=
-  dependencies:
-    restore-cursor "^1.0.1"
-
-cli-cursor@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
-  integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
-  dependencies:
-    restore-cursor "^2.0.0"
-
-cli-width@^2.0.0:
+clean-stack@^2.0.0:
   version "2.2.0"
-  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
-  integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=
-
-cliui@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1"
-  integrity sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=
-  dependencies:
-    center-align "^0.1.1"
-    right-align "^0.1.1"
-    wordwrap "0.0.2"
+  resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
+  integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==
 
-cliui@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d"
-  integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=
-  dependencies:
-    string-width "^1.0.1"
-    strip-ansi "^3.0.1"
-    wrap-ansi "^2.0.0"
-
-clone-deep@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-2.0.2.tgz#00db3a1e173656730d1188c3d6aced6d7ea97713"
-  integrity sha512-SZegPTKjCgpQH63E+eN6mVEEPdQBOUzjyJm5Pora4lrwWRFS8I0QAxV/KD6vV/i0WuijHZWQC1fMsPEdxfdVCQ==
+cliui@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
+  integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
   dependencies:
-    for-own "^1.0.0"
-    is-plain-object "^2.0.4"
-    kind-of "^6.0.0"
-    shallow-clone "^1.0.0"
-
-clone@^1.0.2:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
-  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
-
-co@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
-  integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
+    string-width "^3.1.0"
+    strip-ansi "^5.2.0"
+    wrap-ansi "^5.1.0"
 
-coa@~1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/coa/-/coa-1.0.4.tgz#a9ef153660d6a86a8bdec0289a5c684d217432fd"
-  integrity sha1-qe8VNmDWqGqL3sAomlxoTSF0Mv0=
+clone-regexp@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/clone-regexp/-/clone-regexp-2.2.0.tgz#7d65e00885cd8796405c35a737e7a86b7429e36f"
+  integrity sha512-beMpP7BOtTipFuW8hrJvREQ2DrRu3BE7by0ZpibtfBA+qfHYvMGTc2Yb1JMYPKg/JUw0CHYvpg796aNTSW9z7Q==
   dependencies:
-    q "^1.1.2"
+    is-regexp "^2.0.0"
 
-code-point-at@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
-  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+collapse-white-space@^1.0.2:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.6.tgz#e63629c0016665792060dbbeb79c42239d2c5287"
+  integrity sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ==
 
 collection-visit@^1.0.0:
   version "1.0.0"
@@ -1441,64 +1743,39 @@ collection-visit@^1.0.0:
     map-visit "^1.0.0"
     object-visit "^1.0.0"
 
-color-convert@^1.3.0, color-convert@^1.9.0:
+color-convert@^1.9.0:
   version "1.9.3"
   resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
   dependencies:
     color-name "1.1.3"
 
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
 color-name@1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
-color-name@^1.0.0:
+color-name@~1.1.4:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
-color-string@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/color-string/-/color-string-0.3.0.tgz#27d46fb67025c5c2fa25993bfbf579e47841b991"
-  integrity sha1-J9RvtnAlxcL6JZk7+/V55HhBuZE=
-  dependencies:
-    color-name "^1.0.0"
-
-color@^0.11.0:
-  version "0.11.4"
-  resolved "https://registry.yarnpkg.com/color/-/color-0.11.4.tgz#6d7b5c74fb65e841cd48792ad1ed5e07b904d764"
-  integrity sha1-bXtcdPtl6EHNSHkq0e1eB7kE12Q=
-  dependencies:
-    clone "^1.0.2"
-    color-convert "^1.3.0"
-    color-string "^0.3.0"
-
-colormin@^1.0.5:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/colormin/-/colormin-1.1.2.tgz#ea2f7420a72b96881a38aae59ec124a6f7298133"
-  integrity sha1-6i90IKcrlogaOKrlnsEkpvcpgTM=
-  dependencies:
-    color "^0.11.0"
-    css-color-names "0.0.4"
-    has "^1.0.1"
-
-colors@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.1.2.tgz#168a4701756b6a7f51a12ce0c97bfa28c084ed63"
-  integrity sha1-FopHAXVran9RoSzgyXv6KMCE7WM=
-
-combined-stream@^1.0.6, combined-stream@~1.0.6:
-  version "1.0.8"
-  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
-  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
-  dependencies:
-    delayed-stream "~1.0.0"
+colorette@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.1.tgz#4d0b921325c14faf92633086a536db6e89564b1b"
+  integrity sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==
 
-commander@^2.8.1:
-  version "2.20.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
-  integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==
+commander@^2.20.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
 commondir@^1.0.1:
   version "1.0.1"
@@ -1515,7 +1792,7 @@ concat-map@0.0.1:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
-concat-stream@^1.4.6, concat-stream@^1.6.0:
+concat-stream@^1.5.0:
   version "1.6.2"
   resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
   integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
@@ -1525,17 +1802,15 @@ concat-stream@^1.4.6, concat-stream@^1.6.0:
     readable-stream "^2.2.2"
     typedarray "^0.0.6"
 
-console-browserify@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.1.0.tgz#f0241c45730a9fc6323b206dbf38edc741d0bb10"
-  integrity sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=
-  dependencies:
-    date-now "^0.1.4"
+confusing-browser-globals@^1.0.9:
+  version "1.0.9"
+  resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz#72bc13b483c0276801681871d4898516f8f54fdd"
+  integrity sha512-KbS1Y0jMtyPgIxjO7ZzMAuUpAKMt1SzCL9fsrKsX6b0zJPTaT0SiSPmewwVZg9UAO83HVIlEhZF84LIjZ0lmAw==
 
-console-control-strings@^1.0.0, console-control-strings@~1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
-  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
+console-browserify@^1.1.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336"
+  integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==
 
 constants-browserify@^1.0.0:
   version "1.0.0"
@@ -1547,37 +1822,63 @@ contains-path@^0.1.0:
   resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
   integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=
 
-convert-source-map@^1.5.1:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.6.0.tgz#51b537a8c43e0f04dec1993bffcdd504e758ac20"
-  integrity sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==
+convert-source-map@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
+  integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
   dependencies:
     safe-buffer "~5.1.1"
 
+copy-concurrently@^1.0.0:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
+  integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==
+  dependencies:
+    aproba "^1.1.1"
+    fs-write-stream-atomic "^1.0.8"
+    iferr "^0.1.5"
+    mkdirp "^0.5.1"
+    rimraf "^2.5.4"
+    run-queue "^1.0.0"
+
 copy-descriptor@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
   integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
 
-core-js@^2.4.0, core-js@^2.5.0:
-  version "2.6.5"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.5.tgz#44bc8d249e7fb2ff5d00e0341a7ffb94fbf67895"
-  integrity sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==
+core-js-compat@^3.6.2:
+  version "3.6.5"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.6.5.tgz#2a51d9a4e25dfd6e690251aa81f99e3c05481f1c"
+  integrity sha512-7ItTKOhOZbznhXAQ2g/slGg1PJV5zDO/WdkTwi7UEOJmkvsE32PWvx6mKtDjiMpjnR2CNf6BAD6sSxIlv7ptng==
+  dependencies:
+    browserslist "^4.8.5"
+    semver "7.0.0"
 
-core-util-is@1.0.2, core-util-is@~1.0.0:
+core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
   integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
 
+cosmiconfig@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.0.tgz#ef9b44d773959cae63ddecd122de23853b60f8d3"
+  integrity sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==
+  dependencies:
+    "@types/parse-json" "^4.0.0"
+    import-fresh "^3.2.1"
+    parse-json "^5.0.0"
+    path-type "^4.0.0"
+    yaml "^1.10.0"
+
 create-ecdh@^4.0.0:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff"
-  integrity sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e"
+  integrity sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==
   dependencies:
     bn.js "^4.1.0"
-    elliptic "^6.0.0"
+    elliptic "^6.5.3"
 
-create-hash@^1.1.0, create-hash@^1.1.2:
+create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196"
   integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==
@@ -1588,7 +1889,7 @@ create-hash@^1.1.0, create-hash@^1.1.2:
     ripemd160 "^2.0.1"
     sha.js "^2.4.0"
 
-create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
+create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7:
   version "1.1.7"
   resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff"
   integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==
@@ -1600,22 +1901,25 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
-cross-spawn@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982"
-  integrity sha1-ElYDfsufDF9549bvE14wdwGEuYI=
+cross-spawn@^6.0.5:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
+  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
   dependencies:
-    lru-cache "^4.0.1"
+    nice-try "^1.0.4"
+    path-key "^2.0.1"
+    semver "^5.5.0"
+    shebang-command "^1.2.0"
     which "^1.2.9"
 
-cross-spawn@^5.0.1, cross-spawn@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
+cross-spawn@^7.0.2:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
   dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
 
 crypto-browserify@^3.11.0:
   version "3.12.0"
@@ -1634,132 +1938,57 @@ crypto-browserify@^3.11.0:
     randombytes "^2.0.0"
     randomfill "^1.0.3"
 
-css-color-names@0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
-  integrity sha1-gIrcLnnPhHOAabZGyyDsJ762KeA=
-
-css-loader@^0.28.9:
-  version "0.28.11"
-  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.11.tgz#c3f9864a700be2711bb5a2462b2389b1a392dab7"
-  integrity sha512-wovHgjAx8ZIMGSL8pTys7edA1ClmzxHeY6n/d97gg5odgsxEgKjULPR0viqyC+FWMCL9sfqoC/QCUBo62tLvPg==
-  dependencies:
-    babel-code-frame "^6.26.0"
-    css-selector-tokenizer "^0.7.0"
-    cssnano "^3.10.0"
-    icss-utils "^2.1.0"
-    loader-utils "^1.0.2"
-    lodash.camelcase "^4.3.0"
-    object-assign "^4.1.1"
-    postcss "^5.0.6"
-    postcss-modules-extract-imports "^1.2.0"
-    postcss-modules-local-by-default "^1.2.0"
-    postcss-modules-scope "^1.1.0"
-    postcss-modules-values "^1.3.0"
-    postcss-value-parser "^3.3.0"
-    source-list-map "^2.0.0"
-
-css-selector-tokenizer@^0.7.0:
-  version "0.7.1"
-  resolved "https://registry.yarnpkg.com/css-selector-tokenizer/-/css-selector-tokenizer-0.7.1.tgz#a177271a8bca5019172f4f891fc6eed9cbf68d5d"
-  integrity sha512-xYL0AMZJ4gFzJQsHUKa5jiWWi2vH77WVNg7JYRyewwj6oPh4yb/y6Y9ZCw9dsj/9UauMhtuxR+ogQd//EdEVNA==
-  dependencies:
-    cssesc "^0.1.0"
-    fastparse "^1.1.1"
-    regexpu-core "^1.0.0"
-
-cssesc@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-0.1.0.tgz#c814903e45623371a0477b40109aaafbeeaddbb4"
-  integrity sha1-yBSQPkViM3GgR3tAEJqq++6t27Q=
-
-cssnano@^3.10.0:
-  version "3.10.0"
-  resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-3.10.0.tgz#4f38f6cea2b9b17fa01490f23f1dc68ea65c1c38"
-  integrity sha1-Tzj2zqK5sX+gFJDyPx3GjqZcHDg=
-  dependencies:
-    autoprefixer "^6.3.1"
-    decamelize "^1.1.2"
-    defined "^1.0.0"
-    has "^1.0.1"
-    object-assign "^4.0.1"
-    postcss "^5.0.14"
-    postcss-calc "^5.2.0"
-    postcss-colormin "^2.1.8"
-    postcss-convert-values "^2.3.4"
-    postcss-discard-comments "^2.0.4"
-    postcss-discard-duplicates "^2.0.1"
-    postcss-discard-empty "^2.0.1"
-    postcss-discard-overridden "^0.1.1"
-    postcss-discard-unused "^2.2.1"
-    postcss-filter-plugins "^2.0.0"
-    postcss-merge-idents "^2.1.5"
-    postcss-merge-longhand "^2.0.1"
-    postcss-merge-rules "^2.0.3"
-    postcss-minify-font-values "^1.0.2"
-    postcss-minify-gradients "^1.0.1"
-    postcss-minify-params "^1.0.4"
-    postcss-minify-selectors "^2.0.4"
-    postcss-normalize-charset "^1.1.0"
-    postcss-normalize-url "^3.0.7"
-    postcss-ordered-values "^2.1.0"
-    postcss-reduce-idents "^2.2.2"
-    postcss-reduce-initial "^1.0.0"
-    postcss-reduce-transforms "^1.0.3"
-    postcss-svgo "^2.1.1"
-    postcss-unique-selectors "^2.0.2"
-    postcss-value-parser "^3.2.3"
-    postcss-zindex "^2.0.1"
-
-csso@~2.3.1:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/csso/-/csso-2.3.2.tgz#ddd52c587033f49e94b71fc55569f252e8ff5f85"
-  integrity sha1-3dUsWHAz9J6Utx/FVWnyUuj/X4U=
-  dependencies:
-    clap "^1.0.9"
-    source-map "^0.5.3"
-
-currently-unhandled@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
-  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
-  dependencies:
-    array-find-index "^1.0.1"
-
-d@1:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/d/-/d-1.0.0.tgz#754bb5bfe55451da69a58b94d45f4c5b0462d58f"
-  integrity sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=
-  dependencies:
-    es5-ext "^0.10.9"
-
-dashdash@^1.12.0:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
-  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
-  dependencies:
-    assert-plus "^1.0.0"
+css-loader@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-4.3.0.tgz#c888af64b2a5b2e85462c72c0f4a85c7e2e0821e"
+  integrity sha512-rdezjCjScIrsL8BSYszgT4s476IcNKt6yX69t0pHjJVnPUTDpn4WfIpDQTN3wCJvUvfsz/mFjuGOekf3PY3NUg==
+  dependencies:
+    camelcase "^6.0.0"
+    cssesc "^3.0.0"
+    icss-utils "^4.1.1"
+    loader-utils "^2.0.0"
+    postcss "^7.0.32"
+    postcss-modules-extract-imports "^2.0.0"
+    postcss-modules-local-by-default "^3.0.3"
+    postcss-modules-scope "^2.2.0"
+    postcss-modules-values "^3.0.0"
+    postcss-value-parser "^4.1.0"
+    schema-utils "^2.7.1"
+    semver "^7.3.2"
+
+cssesc@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
+  integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
 
-date-now@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
-  integrity sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=
+cyclist@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
+  integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
 
-debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
+debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@^3.1.0, debug@^3.2.6:
-  version "3.2.6"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
-  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1"
+  integrity sha512-IX2ncY78vDTjZMFUdmsvIRFY2Cf4FnD0wRs+nQwJU8Lu99/tPFdb0VybiiMTPe3I6rQmwsqQqRBvxU+bZ/I8sg==
   dependencies:
-    ms "^2.1.1"
+    ms "2.1.2"
 
-decamelize@^1.0.0, decamelize@^1.1.1, decamelize@^1.1.2:
+decamelize-keys@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
+  integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=
+  dependencies:
+    decamelize "^1.1.0"
+    map-obj "^1.0.0"
+
+decamelize@^1.1.0, decamelize@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@@ -1769,17 +1998,12 @@ decode-uri-component@^0.2.0:
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
   integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
 
-deep-extend@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
-  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
-
-deep-is@~0.1.3:
+deep-is@^0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
   integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
 
-define-properties@^1.1.2:
+define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
   integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
@@ -1808,40 +2032,18 @@ define-property@^2.0.2:
     is-descriptor "^1.0.2"
     isobject "^3.0.1"
 
-defined@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
-  integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
-
-delayed-stream@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
-  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
-
-delegates@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
-  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
-
 des.js@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc"
-  integrity sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.1.tgz#5382142e1bdc53f85d86d53e5f4aa7deb91e0843"
+  integrity sha512-Q0I4pfFrv2VPd34/vfLrFOoRmlYj3OV50i7fskps1jZWK1kApMWWT9G6RRUeYedLcBDIhnSDaUvJMb3AhUlaEA==
   dependencies:
     inherits "^2.0.1"
     minimalistic-assert "^1.0.0"
 
-detect-indent@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
-  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
-  dependencies:
-    repeating "^2.0.0"
-
-detect-libc@^1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
-  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+detect-file@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
+  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
 
 diffie-hellman@^5.0.0:
   version "5.0.3"
@@ -1852,7 +2054,14 @@ diffie-hellman@^5.0.0:
     miller-rabin "^4.0.0"
     randombytes "^2.0.0"
 
-doctrine@1.5.0, doctrine@^1.2.2:
+dir-glob@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
+  integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==
+  dependencies:
+    path-type "^4.0.0"
+
+doctrine@1.5.0:
   version "1.5.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa"
   integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=
@@ -1860,35 +2069,70 @@ doctrine@1.5.0, doctrine@^1.2.2:
     esutils "^2.0.2"
     isarray "^1.0.0"
 
-doctrine@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
-  integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
+doctrine@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
+  integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
   dependencies:
     esutils "^2.0.2"
 
+dom-serializer@0:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51"
+  integrity sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==
+  dependencies:
+    domelementtype "^2.0.1"
+    entities "^2.0.0"
+
 domain-browser@^1.1.1:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
   integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==
 
-ecc-jsbn@~0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
-  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
+domelementtype@1, domelementtype@^1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
+  integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
+
+domelementtype@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.0.2.tgz#f3b6e549201e46f588b59463dd77187131fe6971"
+  integrity sha512-wFwTwCVebUrMgGeAwRL/NhZtHAUyT9n9yg4IMDwf10+6iCMxSkVq9MGCVEH+QZWo1nNidy8kNvwmv4zWHDTqvA==
+
+domhandler@^2.3.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803"
+  integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==
   dependencies:
-    jsbn "~0.1.0"
-    safer-buffer "^2.1.0"
+    domelementtype "1"
 
-electron-to-chromium@^1.2.7, electron-to-chromium@^1.3.47:
-  version "1.3.135"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.135.tgz#f5799b95f2bcd8de17cde47d63392d83a4477041"
-  integrity sha512-xXLNstRdVsisPF3pL3H9TVZo2XkMILfqtD6RiWIUmDK2sFX1Bjwqmd8LBp0Kuo2FgKO63JXPoEVGm8WyYdwP0Q==
+domutils@^1.5.1:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a"
+  integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==
+  dependencies:
+    dom-serializer "0"
+    domelementtype "1"
 
-elliptic@^6.0.0:
-  version "6.4.1"
-  resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.1.tgz#c2d0b7776911b86722c632c3c06c60f2f819939a"
-  integrity sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==
+duplexify@^3.4.2, duplexify@^3.6.0:
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
+  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
+  dependencies:
+    end-of-stream "^1.0.0"
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
+    stream-shift "^1.0.0"
+
+electron-to-chromium@^1.3.570:
+  version "1.3.570"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.570.tgz#3f5141cc39b4e3892a276b4889980dabf1d29c7f"
+  integrity sha512-Y6OCoVQgFQBP5py6A/06+yWxUZHDlNr/gNDGatjH8AZqXl8X0tE4LfjLJsXGz/JmWJz8a6K7bR1k+QzZ+k//fg==
+
+elliptic@^6.5.3:
+  version "6.5.3"
+  resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6"
+  integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==
   dependencies:
     bn.js "^4.4.0"
     brorand "^1.0.1"
@@ -1898,325 +2142,284 @@ elliptic@^6.0.0:
     minimalistic-assert "^1.0.0"
     minimalistic-crypto-utils "^1.0.0"
 
-emojis-list@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"
-  integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k=
+emoji-regex@^7.0.1:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
+  integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
 
-enhanced-resolve@^3.4.0:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz#0421e339fd71419b3da13d129b3979040230476e"
-  integrity sha1-BCHjOf1xQZs9oT0Smzl5BAIwR24=
-  dependencies:
-    graceful-fs "^4.1.2"
-    memory-fs "^0.4.0"
-    object-assign "^4.0.1"
-    tapable "^0.2.7"
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
 
-errno@^0.1.3:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
-  integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==
-  dependencies:
-    prr "~1.0.1"
+emojis-list@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
+  integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
 
-error-ex@^1.2.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
-  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
+end-of-stream@^1.0.0, end-of-stream@^1.1.0:
+  version "1.4.4"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
+  integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
-    is-arrayish "^0.2.1"
+    once "^1.4.0"
 
-es-abstract@^1.7.0:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9"
-  integrity sha512-vDZfg/ykNxQVwup/8E1BZhVzFfBxs9NqMzGcvIJrqg5k2/5Za2bWo40dK2J1pgLngZ7c+Shh8lwYtLGyrwPutg==
+enhanced-resolve@^4.1.1, enhanced-resolve@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz#3b806f3bfafc1ec7de69551ef93cca46c1704126"
+  integrity sha512-3e87LvavsdxyoCfGusJnrZ5G8SLPOFeHSNpZI/ATL9a5leXo2k0w6MKnbqhdBad9qTobSfB20Ld7UmgoNbAZkQ==
   dependencies:
-    es-to-primitive "^1.2.0"
-    function-bind "^1.1.1"
-    has "^1.0.3"
-    is-callable "^1.1.4"
-    is-regex "^1.0.4"
-    object-keys "^1.0.12"
+    graceful-fs "^4.1.2"
+    memory-fs "^0.5.0"
+    tapable "^1.0.0"
 
-es-to-primitive@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.0.tgz#edf72478033456e8dda8ef09e00ad9650707f377"
-  integrity sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==
+enquirer@^2.3.5:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
+  integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
   dependencies:
-    is-callable "^1.1.4"
-    is-date-object "^1.0.1"
-    is-symbol "^1.0.2"
+    ansi-colors "^4.1.1"
 
-es5-ext@^0.10.14, es5-ext@^0.10.35, es5-ext@^0.10.9, es5-ext@~0.10.14:
-  version "0.10.50"
-  resolved "https://registry.yarnpkg.com/es5-ext/-/es5-ext-0.10.50.tgz#6d0e23a0abdb27018e5ac4fd09b412bc5517a778"
-  integrity sha512-KMzZTPBkeQV/JcSQhI5/z6d9VWJ3EnQ194USTUwIYZ2ZbpN8+SGXQKt1h68EX44+qt+Fzr8DO17vnxrw7c3agw==
-  dependencies:
-    es6-iterator "~2.0.3"
-    es6-symbol "~3.1.1"
-    next-tick "^1.0.0"
+entities@^1.1.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
+  integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
 
-es6-iterator@^2.0.1, es6-iterator@~2.0.1, es6-iterator@~2.0.3:
+entities@^2.0.0:
   version "2.0.3"
-  resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
-  integrity sha1-p96IkUGgWpSwhUQDstCg+/qY87c=
-  dependencies:
-    d "1"
-    es5-ext "^0.10.35"
-    es6-symbol "^3.1.1"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f"
+  integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==
 
-es6-map@^0.1.3:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/es6-map/-/es6-map-0.1.5.tgz#9136e0503dcc06a301690f0bb14ff4e364e949f0"
-  integrity sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=
+errno@^0.1.3, errno@~0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
+  integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==
   dependencies:
-    d "1"
-    es5-ext "~0.10.14"
-    es6-iterator "~2.0.1"
-    es6-set "~0.1.5"
-    es6-symbol "~3.1.1"
-    event-emitter "~0.3.5"
+    prr "~1.0.1"
 
-es6-set@~0.1.5:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/es6-set/-/es6-set-0.1.5.tgz#d2b3ec5d4d800ced818db538d28974db0a73ccb1"
-  integrity sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=
+error-ex@^1.2.0, error-ex@^1.3.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
+  integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
   dependencies:
-    d "1"
-    es5-ext "~0.10.14"
-    es6-iterator "~2.0.1"
-    es6-symbol "3.1.1"
-    event-emitter "~0.3.5"
+    is-arrayish "^0.2.1"
 
-es6-symbol@3.1.1, es6-symbol@^3.1.1, es6-symbol@~3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/es6-symbol/-/es6-symbol-3.1.1.tgz#bf00ef4fdab6ba1b46ecb7b629b4c7ed5715cc77"
-  integrity sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=
+es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.5:
+  version "1.17.6"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.17.6.tgz#9142071707857b2cacc7b89ecb670316c3e2d52a"
+  integrity sha512-Fr89bON3WFyUi5EvAeI48QTWX0AyekGgLA8H+c+7fbfCkJwRWRMLd8CQedNEyJuoYYhmtEqY92pgte1FAhBlhw==
   dependencies:
-    d "1"
-    es5-ext "~0.10.14"
-
-es6-weak-map@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/es6-weak-map/-/es6-weak-map-2.0.2.tgz#5e3ab32251ffd1538a1f8e5ffa1357772f92d96f"
-  integrity sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=
+    es-to-primitive "^1.2.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.1"
+    is-callable "^1.2.0"
+    is-regex "^1.1.0"
+    object-inspect "^1.7.0"
+    object-keys "^1.1.1"
+    object.assign "^4.1.0"
+    string.prototype.trimend "^1.0.1"
+    string.prototype.trimstart "^1.0.1"
+
+es-abstract@^1.18.0-next.0:
+  version "1.18.0-next.0"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.0.tgz#b302834927e624d8e5837ed48224291f2c66e6fc"
+  integrity sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==
+  dependencies:
+    es-to-primitive "^1.2.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.1"
+    is-callable "^1.2.0"
+    is-negative-zero "^2.0.0"
+    is-regex "^1.1.1"
+    object-inspect "^1.8.0"
+    object-keys "^1.1.1"
+    object.assign "^4.1.0"
+    string.prototype.trimend "^1.0.1"
+    string.prototype.trimstart "^1.0.1"
+
+es-to-primitive@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
+  integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
   dependencies:
-    d "1"
-    es5-ext "^0.10.14"
-    es6-iterator "^2.0.1"
-    es6-symbol "^3.1.1"
+    is-callable "^1.1.4"
+    is-date-object "^1.0.1"
+    is-symbol "^1.0.2"
 
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5:
+escalade@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.0.tgz#e8e2d7c7a8b76f6ee64c2181d6b8151441602d4e"
+  integrity sha512-mAk+hPSO8fLDkhV7V0dXazH5pDc6MrjBTPyD3VeKzxnVFjH1MIxbCdqGZB9O8+EwWakZs3ZCbDS4IpRt79V1ig==
+
+escape-string-regexp@^1.0.5:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
-escope@^3.6.0:
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/escope/-/escope-3.6.0.tgz#e01975e812781a163a6dadfdd80398dc64c889c3"
-  integrity sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=
-  dependencies:
-    es6-map "^0.1.3"
-    es6-weak-map "^2.0.1"
-    esrecurse "^4.1.0"
-    estraverse "^4.1.1"
-
-eslint-config-airbnb-base@^12.1.0:
-  version "12.1.0"
-  resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-12.1.0.tgz#386441e54a12ccd957b0a92564a4bafebd747944"
-  integrity sha512-/vjm0Px5ZCpmJqnjIzcFb9TKZrKWz0gnuG/7Gfkt0Db1ELJR51xkZth+t14rYdqWgX836XbuxtArbIHlVhbLBA==
+eslint-config-airbnb-base@^14.2.0:
+  version "14.2.0"
+  resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.2.0.tgz#fe89c24b3f9dc8008c9c0d0d88c28f95ed65e9c4"
+  integrity sha512-Snswd5oC6nJaevs3nZoLSTvGJBvzTfnBqOIArkf3cbyTyq9UD79wOk8s+RiL6bhca0p/eRO6veczhf6A/7Jy8Q==
   dependencies:
-    eslint-restricted-globals "^0.1.1"
+    confusing-browser-globals "^1.0.9"
+    object.assign "^4.1.0"
+    object.entries "^1.1.2"
 
-eslint-import-resolver-node@^0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a"
-  integrity sha512-sfmTqJfPSizWu4aymbPr4Iidp5yKm8yDkHp+Ir3YiTHiiDfxh69mOUsmiqW6RZ9zRXFaF64GtYmN7e+8GHBv6Q==
+eslint-import-resolver-node@^0.3.3:
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz#85ffa81942c25012d8231096ddf679c03042c717"
+  integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==
   dependencies:
     debug "^2.6.9"
-    resolve "^1.5.0"
+    resolve "^1.13.1"
 
-eslint-module-utils@^2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.4.0.tgz#8b93499e9b00eab80ccb6614e69f03678e84e09a"
-  integrity sha512-14tltLm38Eu3zS+mt0KvILC3q8jyIAH518MlG+HO0p+yK885Lb1UHTY/UgR91eOyGdmxAPb+OLoW4znqIT6Ndw==
+eslint-module-utils@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz#579ebd094f56af7797d19c9866c9c9486629bfa6"
+  integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==
   dependencies:
-    debug "^2.6.8"
+    debug "^2.6.9"
     pkg-dir "^2.0.0"
 
-eslint-plugin-import@^2.8.0:
-  version "2.17.2"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.17.2.tgz#d227d5c6dc67eca71eb590d2bb62fb38d86e9fcb"
-  integrity sha512-m+cSVxM7oLsIpmwNn2WXTJoReOF9f/CtLMo7qOVmKd1KntBy0hEcuNZ3erTmWjx+DxRO0Zcrm5KwAvI9wHcV5g==
+eslint-plugin-import@^2.22.0:
+  version "2.22.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.22.0.tgz#92f7736fe1fde3e2de77623c838dd992ff5ffb7e"
+  integrity sha512-66Fpf1Ln6aIS5Gr/55ts19eUuoDhAbZgnr6UxK5hbDx6l/QgQgx61AePq+BV4PP2uXQFClgMVzep5zZ94qqsxg==
   dependencies:
-    array-includes "^3.0.3"
+    array-includes "^3.1.1"
+    array.prototype.flat "^1.2.3"
     contains-path "^0.1.0"
     debug "^2.6.9"
     doctrine "1.5.0"
-    eslint-import-resolver-node "^0.3.2"
-    eslint-module-utils "^2.4.0"
+    eslint-import-resolver-node "^0.3.3"
+    eslint-module-utils "^2.6.0"
     has "^1.0.3"
-    lodash "^4.17.11"
     minimatch "^3.0.4"
+    object.values "^1.1.1"
     read-pkg-up "^2.0.0"
-    resolve "^1.10.0"
-
-eslint-restricted-globals@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/eslint-restricted-globals/-/eslint-restricted-globals-0.1.1.tgz#35f0d5cbc64c2e3ed62e93b4b1a7af05ba7ed4d7"
-  integrity sha1-NfDVy8ZMLj7WLpO0saevBbp+1Nc=
+    resolve "^1.17.0"
+    tsconfig-paths "^3.9.0"
 
-eslint-scope@^3.7.1:
-  version "3.7.3"
-  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.3.tgz#bb507200d3d17f60247636160b4826284b108535"
-  integrity sha512-W+B0SvF4gamyCTmUc+uITPY0989iXVfKvhwtmJocTaYoc/3khEHmEmvfY/Gn9HA9VV75jrQECsHizkNw1b68FA==
+eslint-scope@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848"
+  integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg==
   dependencies:
     esrecurse "^4.1.0"
     estraverse "^4.1.1"
 
-eslint-visitor-keys@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
-  integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==
-
-eslint@^2.7.0:
-  version "2.13.1"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-2.13.1.tgz#e4cc8fa0f009fb829aaae23855a29360be1f6c11"
-  integrity sha1-5MyPoPAJ+4KaquI4VaKTYL4fbBE=
-  dependencies:
-    chalk "^1.1.3"
-    concat-stream "^1.4.6"
-    debug "^2.1.1"
-    doctrine "^1.2.2"
-    es6-map "^0.1.3"
-    escope "^3.6.0"
-    espree "^3.1.6"
-    estraverse "^4.2.0"
-    esutils "^2.0.2"
-    file-entry-cache "^1.1.1"
-    glob "^7.0.3"
-    globals "^9.2.0"
-    ignore "^3.1.2"
-    imurmurhash "^0.1.4"
-    inquirer "^0.12.0"
-    is-my-json-valid "^2.10.0"
-    is-resolvable "^1.0.0"
-    js-yaml "^3.5.1"
-    json-stable-stringify "^1.0.0"
-    levn "^0.3.0"
-    lodash "^4.0.0"
-    mkdirp "^0.5.0"
-    optionator "^0.8.1"
-    path-is-absolute "^1.0.0"
-    path-is-inside "^1.0.1"
-    pluralize "^1.2.1"
-    progress "^1.1.8"
-    require-uncached "^1.0.2"
-    shelljs "^0.6.0"
-    strip-json-comments "~1.0.1"
-    table "^3.7.8"
-    text-table "~0.2.0"
-    user-home "^2.0.0"
-
-eslint@^4.16.0:
-  version "4.19.1"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-4.19.1.tgz#32d1d653e1d90408854bfb296f076ec7e186a300"
-  integrity sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==
-  dependencies:
-    ajv "^5.3.0"
-    babel-code-frame "^6.22.0"
-    chalk "^2.1.0"
-    concat-stream "^1.6.0"
-    cross-spawn "^5.1.0"
-    debug "^3.1.0"
-    doctrine "^2.1.0"
-    eslint-scope "^3.7.1"
-    eslint-visitor-keys "^1.0.0"
-    espree "^3.5.4"
-    esquery "^1.0.0"
+eslint-scope@^5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^4.1.1"
+
+eslint-utils@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
+  integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
+  dependencies:
+    eslint-visitor-keys "^1.1.0"
+
+eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e"
+  integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==
+
+eslint@^7.9.0:
+  version "7.9.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.9.0.tgz#522aeccc5c3a19017cf0cb46ebfd660a79acf337"
+  integrity sha512-V6QyhX21+uXp4T+3nrNfI3hQNBDa/P8ga7LoQOenwrlEFXrEnUEE+ok1dMtaS3b6rmLXhT1TkTIsG75HMLbknA==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    "@eslint/eslintrc" "^0.1.3"
+    ajv "^6.10.0"
+    chalk "^4.0.0"
+    cross-spawn "^7.0.2"
+    debug "^4.0.1"
+    doctrine "^3.0.0"
+    enquirer "^2.3.5"
+    eslint-scope "^5.1.0"
+    eslint-utils "^2.1.0"
+    eslint-visitor-keys "^1.3.0"
+    espree "^7.3.0"
+    esquery "^1.2.0"
     esutils "^2.0.2"
-    file-entry-cache "^2.0.0"
+    file-entry-cache "^5.0.1"
     functional-red-black-tree "^1.0.1"
-    glob "^7.1.2"
-    globals "^11.0.1"
-    ignore "^3.3.3"
+    glob-parent "^5.0.0"
+    globals "^12.1.0"
+    ignore "^4.0.6"
+    import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
-    inquirer "^3.0.6"
-    is-resolvable "^1.0.0"
-    js-yaml "^3.9.1"
+    is-glob "^4.0.0"
+    js-yaml "^3.13.1"
     json-stable-stringify-without-jsonify "^1.0.1"
-    levn "^0.3.0"
-    lodash "^4.17.4"
-    minimatch "^3.0.2"
-    mkdirp "^0.5.1"
+    levn "^0.4.1"
+    lodash "^4.17.19"
+    minimatch "^3.0.4"
     natural-compare "^1.4.0"
-    optionator "^0.8.2"
-    path-is-inside "^1.0.2"
-    pluralize "^7.0.0"
+    optionator "^0.9.1"
     progress "^2.0.0"
-    regexpp "^1.0.1"
-    require-uncached "^1.0.3"
-    semver "^5.3.0"
-    strip-ansi "^4.0.0"
-    strip-json-comments "~2.0.1"
-    table "4.0.2"
-    text-table "~0.2.0"
-
-espree@^3.1.6, espree@^3.5.4:
-  version "3.5.4"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
-  integrity sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==
-  dependencies:
-    acorn "^5.5.0"
-    acorn-jsx "^3.0.0"
-
-esprima@^2.6.0:
-  version "2.7.3"
-  resolved "https://registry.yarnpkg.com/esprima/-/esprima-2.7.3.tgz#96e3b70d5779f6ad49cd032673d1c312767ba581"
-  integrity sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE=
+    regexpp "^3.1.0"
+    semver "^7.2.1"
+    strip-ansi "^6.0.0"
+    strip-json-comments "^3.1.0"
+    table "^5.2.3"
+    text-table "^0.2.0"
+    v8-compile-cache "^2.0.3"
+
+espree@^7.3.0:
+  version "7.3.0"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.0.tgz#dc30437cf67947cf576121ebd780f15eeac72348"
+  integrity sha512-dksIWsvKCixn1yrEXO8UosNSxaDoSYpq9reEjZSbHLpT5hpaCAKTLBwq0RHtLrIr+c0ByiYzWT8KTMRzoRCNlw==
+  dependencies:
+    acorn "^7.4.0"
+    acorn-jsx "^5.2.0"
+    eslint-visitor-keys "^1.3.0"
 
 esprima@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
   integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
 
-esquery@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708"
-  integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==
+esquery@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.3.1.tgz#b78b5828aa8e214e29fb74c4d5b752e1c033da57"
+  integrity sha512-olpvt9QG0vniUBZspVRN6lwB7hOZoTRtT+jzR+tS4ffYx2mzbw+z0XCOk44aaLYKApNX5nMm+E+P6o25ip/DHQ==
   dependencies:
-    estraverse "^4.0.0"
+    estraverse "^5.1.0"
 
-esrecurse@^4.1.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf"
-  integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==
+esrecurse@^4.1.0, esrecurse@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
+  integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
   dependencies:
-    estraverse "^4.1.0"
+    estraverse "^5.2.0"
 
-estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13"
-  integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=
+estraverse@^4.1.1:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
 
-esutils@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b"
-  integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=
+estraverse@^5.1.0, estraverse@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
+  integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
 
-event-emitter@~0.3.5:
-  version "0.3.5"
-  resolved "https://registry.yarnpkg.com/event-emitter/-/event-emitter-0.3.5.tgz#df8c69eef1647923c7157b9ce83840610b02cc39"
-  integrity sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=
-  dependencies:
-    d "1"
-    es5-ext "~0.10.14"
+esutils@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
+  integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
 events@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/events/-/events-3.0.0.tgz#9a0a0dfaf62893d92b875b8f2698ca4114973e88"
-  integrity sha512-Dc381HFWJzEOhQ+d8pkNon++bk9h6cdAoAj4iE6Q4y6xgTzySWXlKn05/TVNpjnfRqi/X0EpJEJohPjNI3zpVA==
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/events/-/events-3.2.0.tgz#93b87c18f8efcd4202a461aec4dfc0556b639379"
+  integrity sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg==
 
 evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
   version "1.0.3"
@@ -2226,23 +2429,12 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
     md5.js "^1.3.4"
     safe-buffer "^5.1.1"
 
-execa@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
-  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
+execall@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45"
+  integrity sha512-0FU2hZ5Hh6iQnarpRtQurM/aAvp3RIbfvgLHrcqJYzhXyV2KFruhuChf9NC6waAhiUR7FFtlugkI4p7f2Fqlow==
   dependencies:
-    cross-spawn "^5.0.1"
-    get-stream "^3.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-exit-hook@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
-  integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=
+    clone-regexp "^2.1.0"
 
 expand-brackets@^2.1.4:
   version "2.1.4"
@@ -2257,6 +2449,13 @@ expand-brackets@^2.1.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
+expand-tilde@^2.0.0, expand-tilde@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
+  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
+  dependencies:
+    homedir-polyfill "^1.0.1"
+
 extend-shallow@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
@@ -2272,20 +2471,11 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
     assign-symbols "^1.0.0"
     is-extendable "^1.0.1"
 
-extend@~3.0.2:
+extend@^3.0.0:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
   integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
 
-external-editor@^2.0.4:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-2.2.0.tgz#045511cfd8d133f3846673d1047c154e214ad3d5"
-  integrity sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==
-  dependencies:
-    chardet "^0.4.0"
-    iconv-lite "^0.4.17"
-    tmp "^0.0.33"
-
 extglob@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
@@ -2300,81 +2490,56 @@ extglob@^2.0.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
-extract-text-webpack-plugin@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extract-text-webpack-plugin/-/extract-text-webpack-plugin-3.0.2.tgz#5f043eaa02f9750a9258b78c0a6e0dc1408fb2f7"
-  integrity sha512-bt/LZ4m5Rqt/Crl2HiKuAl/oqg0psx1tsTLkvWbJen1CtD+fftkZhMaQ9HOtY2gWsl2Wq+sABmMVi9z3DhKWQQ==
-  dependencies:
-    async "^2.4.1"
-    loader-utils "^1.1.0"
-    schema-utils "^0.3.0"
-    webpack-sources "^1.0.1"
-
-extsprintf@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
-  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
-
-extsprintf@^1.2.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
-  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
-
-fast-deep-equal@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614"
-  integrity sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=
+fast-deep-equal@^3.1.1:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
-fast-deep-equal@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"
-  integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=
+fast-glob@^3.1.1, fast-glob@^3.2.4:
+  version "3.2.4"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.4.tgz#d20aefbf99579383e7f3cc66529158c9b98554d3"
+  integrity sha512-kr/Oo6PX51265qeuCYsyGypiO5uJFgBS0jksyG7FUeCyQzNwYnzrNIMR1NXfkZXsMYXYLRAHgISHBz8gQcxKHQ==
+  dependencies:
+    "@nodelib/fs.stat" "^2.0.2"
+    "@nodelib/fs.walk" "^1.2.3"
+    glob-parent "^5.1.0"
+    merge2 "^1.3.0"
+    micromatch "^4.0.2"
+    picomatch "^2.2.1"
 
 fast-json-stable-stringify@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2"
-  integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I=
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
-fast-levenshtein@~2.0.4:
+fast-levenshtein@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
 
-fastparse@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9"
-  integrity sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==
-
-figures@^1.3.5:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
-  integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=
-  dependencies:
-    escape-string-regexp "^1.0.5"
-    object-assign "^4.1.0"
+fastest-levenshtein@^1.0.12:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2"
+  integrity sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==
 
-figures@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
-  integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=
+fastq@^1.6.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.8.0.tgz#550e1f9f59bbc65fe185cb6a9b4d95357107f481"
+  integrity sha512-SMIZoZdLh/fgofivvIkmknUXyPnvxRE3DhtZ5Me3Mrsk5gyPL42F0xr51TdRXskBxHfMp+07bcYzfsYEsSQA9Q==
   dependencies:
-    escape-string-regexp "^1.0.5"
+    reusify "^1.0.4"
 
-file-entry-cache@^1.1.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-1.3.1.tgz#44c61ea607ae4be9c1402f41f44270cbfe334ff8"
-  integrity sha1-RMYepgeuS+nBQC9B9EJwy/4zT/g=
-  dependencies:
-    flat-cache "^1.2.1"
-    object-assign "^4.0.1"
+figgy-pudding@^3.5.1:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
+  integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==
 
-file-entry-cache@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361"
-  integrity sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=
+file-entry-cache@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c"
+  integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g==
   dependencies:
-    flat-cache "^1.2.1"
-    object-assign "^4.0.1"
+    flat-cache "^2.0.1"
 
 file-loader@^1.1.6:
   version "1.1.11"
@@ -2384,6 +2549,11 @@ file-loader@^1.1.6:
     loader-utils "^1.0.2"
     schema-utils "^0.4.5"
 
+file-uri-to-path@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
+  integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
+
 fill-range@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
@@ -2394,22 +2564,30 @@ fill-range@^4.0.0:
     repeat-string "^1.6.1"
     to-regex-range "^2.1.0"
 
-find-cache-dir@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-1.0.0.tgz#9288e3e9e3cc3748717d39eade17cf71fc30ee6f"
-  integrity sha1-kojj6ePMN0hxfTnq3hfPcfww7m8=
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+find-cache-dir@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-2.1.0.tgz#8d0f94cd13fe43c6c7c261a0d86115ca918c05f7"
+  integrity sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==
   dependencies:
     commondir "^1.0.1"
-    make-dir "^1.0.0"
-    pkg-dir "^2.0.0"
+    make-dir "^2.0.0"
+    pkg-dir "^3.0.0"
 
-find-up@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
-  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
+find-cache-dir@^3.3.1:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.1.tgz#89b33fad4a4670daa94f855f7fbe31d6d84fe880"
+  integrity sha512-t2GDMt3oGC/v+BMwzmllWDuJF/xcDtE5j/fCGbqDD7OLuJkj0cfh1YSA5VKPvwMeLFLNDBkwOKZ2X85jGLVftQ==
   dependencies:
-    path-exists "^2.0.0"
-    pinkie-promise "^2.0.0"
+    commondir "^1.0.1"
+    make-dir "^3.0.2"
+    pkg-dir "^4.1.0"
 
 find-up@^2.0.0, find-up@^2.1.0:
   version "2.1.0"
@@ -2418,57 +2596,63 @@ find-up@^2.0.0, find-up@^2.1.0:
   dependencies:
     locate-path "^2.0.0"
 
-flat-cache@^1.2.1:
-  version "1.3.4"
-  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.4.tgz#2c2ef77525cc2929007dfffa1dd314aa9c9dee6f"
-  integrity sha512-VwyB3Lkgacfik2vhqR4uv2rvebqmDvFu4jlN/C1RzWoJEo8I7z4Q404oiqYCkq41mni8EzQnm95emU9seckwtg==
+find-up@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
+  integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
   dependencies:
-    circular-json "^0.3.1"
-    graceful-fs "^4.1.2"
-    rimraf "~2.6.2"
-    write "^0.2.1"
+    locate-path "^3.0.0"
 
-flatten@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782"
-  integrity sha1-2uRqnXj74lKSJYzB54CkHZXAN4I=
+find-up@^4.0.0, find-up@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
 
-for-in@^0.1.3:
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/for-in/-/for-in-0.1.8.tgz#d8773908e31256109952b1fdb9b3fa867d2775e1"
-  integrity sha1-2Hc5COMSVhCZUrH9ubP6hn0ndeE=
+findup-sync@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1"
+  integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==
+  dependencies:
+    detect-file "^1.0.0"
+    is-glob "^4.0.0"
+    micromatch "^3.0.4"
+    resolve-dir "^1.0.1"
 
-for-in@^1.0.1, for-in@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
-  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
+flat-cache@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0"
+  integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA==
+  dependencies:
+    flatted "^2.0.0"
+    rimraf "2.6.3"
+    write "1.0.3"
 
-for-own@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/for-own/-/for-own-1.0.0.tgz#c63332f415cedc4b04dbfe70cf836494c53cb44b"
-  integrity sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=
+flatted@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
+  integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
+
+flush-write-stream@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
+  integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==
   dependencies:
-    for-in "^1.0.1"
+    inherits "^2.0.3"
+    readable-stream "^2.3.6"
 
-forever-agent@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
-  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
+for-in@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
+  integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
 
 fork-awesome@^1.1.7:
   version "1.1.7"
   resolved "https://registry.yarnpkg.com/fork-awesome/-/fork-awesome-1.1.7.tgz#1427da1cac3d1713046ee88427e5fcecb9501d21"
   integrity sha512-IHI7XCSXrKfUIWslse8c/PaaVDT1oBaYge+ju40ihL2ooiQeBpTr4wvIXhgTd2NuhntlvX+M5jYHAPTzNlmv0g==
 
-form-data@~2.3.2:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
-  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.6"
-    mime-types "^2.1.12"
-
 fragment-cache@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
@@ -2476,28 +2660,30 @@ fragment-cache@^0.2.1:
   dependencies:
     map-cache "^0.2.2"
 
-front-matter@2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/front-matter/-/front-matter-2.1.2.tgz#f75983b9f2f413be658c93dfd7bd8ce4078f5cdb"
-  integrity sha1-91mDufL0E75ljJPf172M5AePXNs=
+from2@^2.1.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af"
+  integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=
   dependencies:
-    js-yaml "^3.4.6"
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
 
-fs-extra@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-3.0.1.tgz#3794f378c58b342ea7dbbb23095109c4b3b62291"
-  integrity sha1-N5TzeMWLNC6n27sjCVEJxLO2IpE=
+fs-minipass@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
+  integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
   dependencies:
-    graceful-fs "^4.1.2"
-    jsonfile "^3.0.0"
-    universalify "^0.1.0"
+    minipass "^3.0.0"
 
-fs-minipass@^1.2.5:
-  version "1.2.6"
-  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07"
-  integrity sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ==
+fs-write-stream-atomic@^1.0.8:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9"
+  integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=
   dependencies:
-    minipass "^2.2.1"
+    graceful-fs "^4.1.2"
+    iferr "^0.1.5"
+    imurmurhash "^0.1.4"
+    readable-stream "1 || 2"
 
 fs.realpath@^1.0.0:
   version "1.0.0"
@@ -2505,22 +2691,17 @@ fs.realpath@^1.0.0:
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
 fsevents@^1.2.7:
-  version "1.2.9"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.9.tgz#3f5ed66583ccd6f400b5a00db6f7e861363e388f"
-  integrity sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw==
+  version "1.2.13"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
+  integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==
   dependencies:
+    bindings "^1.5.0"
     nan "^2.12.1"
-    node-pre-gyp "^0.12.0"
 
-fstream@^1.0.0, fstream@^1.0.12:
-  version "1.0.12"
-  resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
-  integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
-  dependencies:
-    graceful-fs "^4.1.2"
-    inherits "~2.0.0"
-    mkdirp ">=0.5 0"
-    rimraf "2"
+fsevents@~2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
+  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
 
 function-bind@^1.1.1:
   version "1.1.1"
@@ -2532,68 +2713,26 @@ functional-red-black-tree@^1.0.1:
   resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
   integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
 
-gauge@~2.7.3:
-  version "2.7.4"
-  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
-  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
-  dependencies:
-    aproba "^1.0.3"
-    console-control-strings "^1.0.0"
-    has-unicode "^2.0.0"
-    object-assign "^4.1.0"
-    signal-exit "^3.0.0"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.1"
-    wide-align "^1.1.0"
-
-gaze@^1.0.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/gaze/-/gaze-1.1.3.tgz#c441733e13b927ac8c0ff0b4c3b033f28812924a"
-  integrity sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==
-  dependencies:
-    globule "^1.0.0"
-
-generate-function@^2.0.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.3.1.tgz#f069617690c10c868e73b8465746764f97c3479f"
-  integrity sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==
-  dependencies:
-    is-property "^1.0.2"
-
-generate-object-property@^1.1.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0"
-  integrity sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=
-  dependencies:
-    is-property "^1.0.0"
-
-get-caller-file@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a"
-  integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==
+gensync@^1.0.0-beta.1:
+  version "1.0.0-beta.1"
+  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
+  integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==
 
-get-stdin@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
-  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
+get-caller-file@^2.0.1:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+  integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-stream@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
-  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
+get-stdin@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53"
+  integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==
 
 get-value@^2.0.3, get-value@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
   integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
 
-getpass@^0.1.1:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
-  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
-  dependencies:
-    assert-plus "^1.0.0"
-
 glob-parent@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
@@ -2602,10 +2741,17 @@ glob-parent@^3.1.0:
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
 
-glob@^7.0.0, glob@^7.0.3, glob@^7.1.2, glob@^7.1.3, glob@~7.1.1:
-  version "7.1.4"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
-  integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==
+glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
+  integrity sha512-FnI+VGOpnlGHWZxthPGR+QhR78fuiK0sNLkHQv+bL9fQi57lNNdquIbna/WrfROrolq8GK5Ek6BiMwqL/voRYQ==
+  dependencies:
+    is-glob "^4.0.1"
+
+glob@^7.1.3, glob@^7.1.4:
+  version "7.1.6"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
+  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
@@ -2614,81 +2760,102 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.2, glob@^7.1.3, glob@~7.1.1:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-globals@^11.0.1:
-  version "11.12.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
-  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
+global-modules@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
+  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
+  dependencies:
+    global-prefix "^1.0.1"
+    is-windows "^1.0.1"
+    resolve-dir "^1.0.0"
 
-globals@^9.18.0, globals@^9.2.0:
-  version "9.18.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
-  integrity sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==
+global-modules@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
+  integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==
+  dependencies:
+    global-prefix "^3.0.0"
 
-globule@^1.0.0:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/globule/-/globule-1.2.1.tgz#5dffb1b191f22d20797a9369b49eab4e9839696d"
-  integrity sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==
+global-prefix@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
+  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
   dependencies:
-    glob "~7.1.1"
-    lodash "~4.17.10"
-    minimatch "~3.0.2"
+    expand-tilde "^2.0.2"
+    homedir-polyfill "^1.0.1"
+    ini "^1.3.4"
+    is-windows "^1.0.1"
+    which "^1.2.14"
 
-gonzales-pe-sl@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/gonzales-pe-sl/-/gonzales-pe-sl-4.2.3.tgz#6a868bc380645f141feeb042c6f97fcc71b59fe6"
-  integrity sha1-aoaLw4BkXxQf7rBCxvl/zHG1n+Y=
+global-prefix@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97"
+  integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==
   dependencies:
-    minimist "1.1.x"
+    ini "^1.3.5"
+    kind-of "^6.0.2"
+    which "^1.3.1"
 
-graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6:
-  version "4.1.15"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
-  integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==
+globals@^11.1.0:
+  version "11.12.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
+  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
-har-schema@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
-  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
+globals@^12.1.0:
+  version "12.4.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-12.4.0.tgz#a18813576a41b00a24a97e7f815918c2e19925f8"
+  integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==
+  dependencies:
+    type-fest "^0.8.1"
 
-har-validator@~5.1.0:
-  version "5.1.3"
-  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
-  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+globby@^11.0.1:
+  version "11.0.1"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357"
+  integrity sha512-iH9RmgwCmUJHi2z5o2l3eTtGBtXek1OYlHrbcxOYugyHLmAsZrPj43OtHThd62Buh/Vv6VyCBD2bdyWcGNQqoQ==
   dependencies:
-    ajv "^6.5.5"
-    har-schema "^2.0.0"
+    array-union "^2.1.0"
+    dir-glob "^3.0.1"
+    fast-glob "^3.1.1"
+    ignore "^5.1.4"
+    merge2 "^1.3.0"
+    slash "^3.0.0"
 
-has-ansi@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
-  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
+globjoin@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/globjoin/-/globjoin-0.1.4.tgz#2f4494ac8919e3767c5cbb691e9f463324285d43"
+  integrity sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=
+
+gonzales-pe@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/gonzales-pe/-/gonzales-pe-4.3.0.tgz#fe9dec5f3c557eead09ff868c65826be54d067b3"
+  integrity sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==
   dependencies:
-    ansi-regex "^2.0.0"
+    minimist "^1.2.5"
 
-has-flag@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-1.0.0.tgz#9d9e793165ce017a00f00418c43f942a7b1d11fa"
-  integrity sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=
+graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2:
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
+  integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
 
-has-flag@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51"
-  integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=
+hard-rejection@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
+  integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==
 
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
   integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
 
-has-symbols@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44"
-  integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
-has-unicode@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
-  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+has-symbols@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
+  integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
 
 has-value@^0.3.1:
   version "0.3.1"
@@ -2721,7 +2888,7 @@ has-values@^1.0.0:
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
-has@^1.0.1, has@^1.0.3:
+has@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
   integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
@@ -2729,12 +2896,13 @@ has@^1.0.1, has@^1.0.3:
     function-bind "^1.1.1"
 
 hash-base@^3.0.0:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918"
-  integrity sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33"
+  integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==
   dependencies:
-    inherits "^2.0.1"
-    safe-buffer "^5.0.1"
+    inherits "^2.0.4"
+    readable-stream "^3.6.0"
+    safe-buffer "^5.2.0"
 
 hash.js@^1.0.0, hash.js@^1.0.3:
   version "1.1.7"
@@ -2744,6 +2912,11 @@ hash.js@^1.0.0, hash.js@^1.0.3:
     inherits "^2.0.3"
     minimalistic-assert "^1.0.1"
 
+he@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
 hmac-drbg@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@@ -2753,100 +2926,107 @@ hmac-drbg@^1.0.0:
     minimalistic-assert "^1.0.0"
     minimalistic-crypto-utils "^1.0.1"
 
-home-or-tmp@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/home-or-tmp/-/home-or-tmp-2.0.0.tgz#e36c3f2d2cae7d746a857e38d18d5f32a7882db8"
-  integrity sha1-42w/LSyufXRqhX440Y1fMqeILbg=
+homedir-polyfill@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
+  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
   dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.1"
+    parse-passwd "^1.0.0"
 
 hosted-git-info@^2.1.4:
-  version "2.7.1"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047"
-  integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==
-
-html-comment-regex@^1.1.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7"
-  integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==
+  version "2.8.8"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
+  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
 
-http-signature@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
-  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
-  dependencies:
-    assert-plus "^1.0.0"
-    jsprim "^1.2.2"
-    sshpk "^1.7.0"
+html-tags@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.1.0.tgz#7b5e6f7e665e9fb41f30007ed9e0d41e97fb2140"
+  integrity sha512-1qYz89hW3lFDEazhjW0yVAV87lw8lVkrJocr72XmBkMKsoSVJCQx3W8BXsC7hO2qAt8BoVjYjtAcZ9perqGnNg==
+
+htmlparser2@^3.10.0:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
+  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
+  dependencies:
+    domelementtype "^1.3.1"
+    domhandler "^2.3.0"
+    domutils "^1.5.1"
+    entities "^1.1.1"
+    inherits "^2.0.1"
+    readable-stream "^3.1.1"
 
 https-browserify@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
   integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=
 
-iconv-lite@^0.4.17, iconv-lite@^0.4.4:
-  version "0.4.24"
-  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
-  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
-  dependencies:
-    safer-buffer ">= 2.1.2 < 3"
-
-icss-replace-symbols@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
-  integrity sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=
-
-icss-utils@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-2.1.0.tgz#83f0a0ec378bf3246178b6c2ad9136f135b1c962"
-  integrity sha1-g/Cg7DeL8yRheLbCrZE28TWxyWI=
+icss-utils@^4.0.0, icss-utils@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-4.1.1.tgz#21170b53789ee27447c2f47dd683081403f9a467"
+  integrity sha512-4aFq7wvWyMHKgxsH8QQtGpvbASCf+eM3wPRLI6R+MgAnTCZ6STYsRvttLvRWK0Nfif5piF394St3HeJDaljGPA==
   dependencies:
-    postcss "^6.0.1"
+    postcss "^7.0.14"
 
 ieee754@^1.1.4:
   version "1.1.13"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
   integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
 
-ignore-walk@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8"
-  integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==
+iferr@^0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
+  integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE=
+
+ignore@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
+  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
+
+ignore@^5.1.4, ignore@^5.1.8:
+  version "5.1.8"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
+  integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
+
+import-fresh@^3.0.0, import-fresh@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
+  integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==
   dependencies:
-    minimatch "^3.0.4"
+    parent-module "^1.0.0"
+    resolve-from "^4.0.0"
+
+import-lazy@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153"
+  integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==
 
-ignore@^3.1.2, ignore@^3.3.3:
-  version "3.3.10"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
-  integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
+import-local@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/import-local/-/import-local-2.0.0.tgz#55070be38a5993cf18ef6db7e961f5bee5c5a09d"
+  integrity sha512-b6s04m3O+s3CGSbqDIyP4R6aAwAeYlVq9+WUWep6iHa8ETRf9yei1U48C5MmfJmV9AiLYYBKPMq/W+/WRpQmCQ==
+  dependencies:
+    pkg-dir "^3.0.0"
+    resolve-cwd "^2.0.0"
 
 imurmurhash@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
   integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
 
-in-publish@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/in-publish/-/in-publish-2.0.0.tgz#e20ff5e3a2afc2690320b6dc552682a9c7fadf51"
-  integrity sha1-4g/146KvwmkDILbcVSaCqcf631E=
-
-indent-string@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
-  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
-  dependencies:
-    repeating "^2.0.0"
+indent-string@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
+  integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
 
 indexes-of@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/indexes-of/-/indexes-of-1.0.1.tgz#f30f716c8e2bd346c7b67d3df3915566a7c05607"
   integrity sha1-8w9xbI4r00bHtn0985FVZqfAVgc=
 
-indexof@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
-  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
+infer-owner@^1.0.3, infer-owner@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467"
+  integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==
 
 inflight@^1.0.4:
   version "1.0.6"
@@ -2856,82 +3036,38 @@ inflight@^1.0.4:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.3, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+inherits@2, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
 inherits@2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1"
   integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=
 
-ini@~1.3.0:
+inherits@2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+
+ini@^1.3.4, ini@^1.3.5:
   version "1.3.5"
   resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
   integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
 
-inquirer@^0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e"
-  integrity sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=
-  dependencies:
-    ansi-escapes "^1.1.0"
-    ansi-regex "^2.0.0"
-    chalk "^1.0.0"
-    cli-cursor "^1.0.1"
-    cli-width "^2.0.0"
-    figures "^1.3.5"
-    lodash "^4.3.0"
-    readline2 "^1.0.1"
-    run-async "^0.1.0"
-    rx-lite "^3.1.2"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.0"
-    through "^2.3.6"
-
-inquirer@^3.0.6:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9"
-  integrity sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==
-  dependencies:
-    ansi-escapes "^3.0.0"
-    chalk "^2.0.0"
-    cli-cursor "^2.1.0"
-    cli-width "^2.0.0"
-    external-editor "^2.0.4"
-    figures "^2.0.0"
-    lodash "^4.3.0"
-    mute-stream "0.0.7"
-    run-async "^2.2.0"
-    rx-lite "^4.0.8"
-    rx-lite-aggregates "^4.0.8"
-    string-width "^2.1.0"
-    strip-ansi "^4.0.0"
-    through "^2.3.6"
-
-interpret@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
-  integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
+interpret@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
+  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
 
-invariant@^2.2.2:
+invariant@^2.2.2, invariant@^2.2.4:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
   integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
   dependencies:
     loose-envify "^1.0.0"
 
-invert-kv@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
-  integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY=
-
-is-absolute-url@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-absolute-url/-/is-absolute-url-2.1.0.tgz#50530dfb84fcc9aa7dbe7852e83a37b93b9f2aa6"
-  integrity sha1-UFMN+4T8yap9vnhS6Do3uTufKqY=
-
 is-accessor-descriptor@^0.1.6:
   version "0.1.6"
   resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6"
@@ -2946,6 +3082,24 @@ is-accessor-descriptor@^1.0.0:
   dependencies:
     kind-of "^6.0.0"
 
+is-alphabetical@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d"
+  integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==
+
+is-alphanumeric@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-alphanumeric/-/is-alphanumeric-1.0.0.tgz#4a9cef71daf4c001c1d81d63d140cf53fd6889f4"
+  integrity sha1-Spzvcdr0wAHB2B1j0UDPU/1oifQ=
+
+is-alphanumerical@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf"
+  integrity sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==
+  dependencies:
+    is-alphabetical "^1.0.0"
+    is-decimal "^1.0.0"
+
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -2958,15 +3112,27 @@ is-binary-path@^1.0.0:
   dependencies:
     binary-extensions "^1.0.0"
 
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
+
 is-buffer@^1.1.5:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
 
-is-callable@^1.1.4:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75"
-  integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA==
+is-buffer@^2.0.0:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
+  integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
+
+is-callable@^1.1.4, is-callable@^1.2.0:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.2.tgz#c7c6715cd22d4ddb48d3e19970223aceabb080d9"
+  integrity sha512-dnMqspv5nU3LoewK2N/y7KLtxtakvTuaCsU9FU50/QDmdbHNy/4/JuRtMHqRU22o3q+W89YQndQEeCVwK+3qrA==
 
 is-data-descriptor@^0.1.4:
   version "0.1.4"
@@ -2983,9 +3149,14 @@ is-data-descriptor@^1.0.0:
     kind-of "^6.0.0"
 
 is-date-object@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16"
-  integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY=
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e"
+  integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
+
+is-decimal@^1.0.0, is-decimal@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5"
+  integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==
 
 is-descriptor@^0.1.0:
   version "0.1.6"
@@ -3022,25 +3193,16 @@ is-extglob@^2.1.0, is-extglob@^2.1.1:
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-finite@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
-  integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=
-  dependencies:
-    number-is-nan "^1.0.0"
-
-is-fullwidth-code-point@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
-  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
-  dependencies:
-    number-is-nan "^1.0.0"
-
 is-fullwidth-code-point@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
   integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
 is-glob@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
@@ -3048,28 +3210,22 @@ is-glob@^3.1.0:
   dependencies:
     is-extglob "^2.1.0"
 
-is-glob@^4.0.0:
+is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
   integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
   dependencies:
     is-extglob "^2.1.1"
 
-is-my-ip-valid@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz#7b351b8e8edd4d3995d4d066680e664d94696824"
-  integrity sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==
-
-is-my-json-valid@^2.10.0:
-  version "2.20.0"
-  resolved "https://registry.yarnpkg.com/is-my-json-valid/-/is-my-json-valid-2.20.0.tgz#1345a6fca3e8daefc10d0fa77067f54cedafd59a"
-  integrity sha512-XTHBZSIIxNsIsZXg7XB5l8z/OBFosl1Wao4tXLpeC7eKU4Vm/kdop2azkPqULwnfGQjmeDIyey9g7afMMtdWAA==
-  dependencies:
-    generate-function "^2.0.0"
-    generate-object-property "^1.1.0"
-    is-my-ip-valid "^1.0.0"
-    jsonpointer "^4.0.0"
-    xtend "^4.0.0"
+is-hexadecimal@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7"
+  integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==
+
+is-negative-zero@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461"
+  integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=
 
 is-number@^3.0.0:
   version "3.0.0"
@@ -3078,74 +3234,77 @@ is-number@^3.0.0:
   dependencies:
     kind-of "^3.0.2"
 
-is-plain-obj@^1.0.0:
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
   integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
 
-is-plain-object@^2.0.1, is-plain-object@^2.0.3, is-plain-object@^2.0.4:
+is-plain-obj@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+  integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
+
+is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
   integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
   dependencies:
     isobject "^3.0.1"
 
-is-promise@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
-  integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=
-
-is-property@^1.0.0, is-property@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84"
-  integrity sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=
-
-is-regex@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491"
-  integrity sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=
+is-regex@^1.1.0, is-regex@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
+  integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
   dependencies:
-    has "^1.0.1"
-
-is-resolvable@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88"
-  integrity sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==
-
-is-stream@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+    has-symbols "^1.0.1"
 
-is-svg@^2.0.0:
+is-regexp@^2.0.0:
   version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-2.1.0.tgz#cf61090da0d9efbcab8722deba6f032208dbb0e9"
-  integrity sha1-z2EJDaDZ77yrhyLeum8DIgjbsOk=
-  dependencies:
-    html-comment-regex "^1.1.0"
+  resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-2.1.0.tgz#cd734a56864e23b956bf4e7c66c396a4c0b22c2d"
+  integrity sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA==
+
+is-string@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
+  integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
 
 is-symbol@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38"
-  integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
+  integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
   dependencies:
-    has-symbols "^1.0.0"
+    has-symbols "^1.0.1"
 
-is-typedarray@~1.0.0:
+is-typedarray@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
-is-utf8@^0.2.0:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
-  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
+is-whitespace-character@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7"
+  integrity sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w==
 
-is-windows@^1.0.2:
+is-windows@^1.0.1, is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
+is-word-character@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.4.tgz#ce0e73216f98599060592f62ff31354ddbeb0230"
+  integrity sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA==
+
+is-wsl@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d"
+  integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=
+
 isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@@ -3168,99 +3327,58 @@ isobject@^3.0.0, isobject@^3.0.1:
   resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
   integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
 
-isstream@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
-  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
-
-js-base64@^2.1.8, js-base64@^2.1.9:
-  version "2.5.1"
-  resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121"
-  integrity sha512-M7kLczedRMYX4L8Mdh4MzyAMM9O5osx+4FcOQuTvr3A9F2D9S5JXheN0ewNbrvK2UatkTRhL5ejGmGSjNMiZuw==
+jest-worker@^26.3.0:
+  version "26.3.0"
+  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.3.0.tgz#7c8a97e4f4364b4f05ed8bca8ca0c24de091871f"
+  integrity sha512-Vmpn2F6IASefL+DVBhPzI2J9/GJUsqzomdeN+P+dK8/jKxbh8R3BtFnx3FIta7wYlPU62cpJMJQo4kuOowcMnw==
+  dependencies:
+    "@types/node" "*"
+    merge-stream "^2.0.0"
+    supports-color "^7.0.0"
 
-"js-tokens@^3.0.0 || ^4.0.0":
+"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-js-tokens@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
-  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
-
-js-yaml@^3.4.6, js-yaml@^3.5.1, js-yaml@^3.5.4, js-yaml@^3.9.1:
-  version "3.13.1"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847"
-  integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==
+js-yaml@^3.13.1:
+  version "3.14.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.0.tgz#a7a34170f26a21bb162424d8adacb4113a69e482"
+  integrity sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==
   dependencies:
     argparse "^1.0.7"
     esprima "^4.0.0"
 
-js-yaml@~3.7.0:
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.7.0.tgz#5c967ddd837a9bfdca5f2de84253abe8a1c03b80"
-  integrity sha1-XJZ93YN6m/3KXy3oQlOr6KHAO4A=
-  dependencies:
-    argparse "^1.0.7"
-    esprima "^2.6.0"
-
-jsbn@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
-  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
-
-jsesc@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
-  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
+jsesc@^2.5.1:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
+  integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
 
 jsesc@~0.5.0:
   version "0.5.0"
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
   integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
 
-json-loader@^0.5.4:
-  version "0.5.7"
-  resolved "https://registry.yarnpkg.com/json-loader/-/json-loader-0.5.7.tgz#dca14a70235ff82f0ac9a3abeb60d337a365185d"
-  integrity sha512-QLPs8Dj7lnf3e3QYS1zkCo+4ZwqOiF9d/nZnYozTISxXWCfNs9yuky5rJw4/W34s7POaNlbZmQGaB5NiXCbP4w==
+json-parse-better-errors@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
 
-json-schema-traverse@^0.3.0:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz#349a6d44c53a51de89b40805c5d5e59b417d3340"
-  integrity sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=
+json-parse-even-better-errors@^2.3.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
+  integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
 
 json-schema-traverse@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
   integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
 
-json-schema@0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
-  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
-
 json-stable-stringify-without-jsonify@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
   integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
 
-json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af"
-  integrity sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=
-  dependencies:
-    jsonify "~0.0.0"
-
-json-stringify-safe@~5.0.1:
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
-  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
-
-json5@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-0.5.1.tgz#1eade7acc012034ad84e2396767ead9fa5495821"
-  integrity sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=
-
 json5@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe"
@@ -3268,32 +3386,12 @@ json5@^1.0.1:
   dependencies:
     minimist "^1.2.0"
 
-jsonfile@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-3.0.1.tgz#a5ecc6f65f53f662c4415c7675a0331d0992ec66"
-  integrity sha1-pezG9l9T9mLEQVx2daAzHQmS7GY=
-  optionalDependencies:
-    graceful-fs "^4.1.6"
-
-jsonify@~0.0.0:
-  version "0.0.0"
-  resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73"
-  integrity sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=
-
-jsonpointer@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9"
-  integrity sha1-T9kss04OnbPInIYi7PUfm5eMbLk=
-
-jsprim@^1.2.2:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
-  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
+json5@^2.1.2:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43"
+  integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA==
   dependencies:
-    assert-plus "1.0.0"
-    extsprintf "1.3.0"
-    json-schema "0.2.3"
-    verror "1.10.0"
+    minimist "^1.2.5"
 
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
@@ -3314,46 +3412,45 @@ kind-of@^5.0.0:
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d"
   integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==
 
-kind-of@^6.0.0, kind-of@^6.0.2:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051"
-  integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==
+kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
+  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
 
-known-css-properties@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.3.0.tgz#a3d135bbfc60ee8c6eacf2f7e7e6f2d4755e49a4"
-  integrity sha512-QMQcnKAiQccfQTqtBh/qwquGZ2XK/DXND1jrcN9M8gMMy99Gwla7GQjndVUsEqIaRyP6bsFRuhwRj5poafBGJQ==
+klona@^2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.4.tgz#7bb1e3affb0cb8624547ef7e8f6708ea2e39dfc0"
+  integrity sha512-ZRbnvdg/NxqzC7L9Uyqzf4psi1OM4Cuc+sJAkQPjO6XkQIJTNbfK2Rsmbw8fx1p2mkZdp2FZYo2+LwXYY/uwIA==
 
-lazy-cache@^1.0.3:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-1.0.4.tgz#a1d78fc3a50474cb80845d3b3b6e1da49a446e8e"
-  integrity sha1-odePw6UEdMuAhF07O24dpJpEbo4=
+known-css-properties@^0.19.0:
+  version "0.19.0"
+  resolved "https://registry.yarnpkg.com/known-css-properties/-/known-css-properties-0.19.0.tgz#5d92b7fa16c72d971bda9b7fe295bdf61836ee5b"
+  integrity sha512-eYboRV94Vco725nKMlpkn3nV2+96p9c3gKXRsYqAJSswSENvBhN7n5L+uDhY58xQa0UukWsDMTGELzmD8Q+wTA==
 
-lcid@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
-  integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=
-  dependencies:
-    invert-kv "^1.0.0"
+leven@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
+  integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
 
-levn@^0.3.0, levn@~0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"
-  integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=
+levenary@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/levenary/-/levenary-1.1.1.tgz#842a9ee98d2075aa7faeedbe32679e9205f46f77"
+  integrity sha512-mkAdOIt79FD6irqjYSs4rdbnlT5vRonMEvBVPVb3XmevfS8kgRXwfes0dhPdEtzTWD/1eNE/Bm/G1iRt6DcnQQ==
   dependencies:
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
+    leven "^3.1.0"
 
-load-json-file@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
-  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
+levn@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
+  integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
   dependencies:
-    graceful-fs "^4.1.2"
-    parse-json "^2.2.0"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-    strip-bom "^2.0.0"
+    prelude-ls "^1.2.1"
+    type-check "~0.4.0"
+
+lines-and-columns@^1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
+  integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
 
 load-json-file@^2.0.0:
   version "2.0.0"
@@ -3365,20 +3462,29 @@ load-json-file@^2.0.0:
     pify "^2.0.0"
     strip-bom "^3.0.0"
 
-loader-runner@^2.3.0:
+loader-runner@^2.4.0:
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.4.0.tgz#ed47066bfe534d7e84c4c7b9998c2a75607d9357"
   integrity sha512-Jsmr89RcXGIwivFY21FcRrisYZfvLMTWx5kOLc+JTxtpBOG6xML0vzbc6SEQG2FO9/4Fc3wW4LVcB5DmGflaRw==
 
-loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7"
-  integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA==
+loader-utils@^1.0.2, loader-utils@^1.1.0, loader-utils@^1.2.3, loader-utils@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613"
+  integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==
   dependencies:
     big.js "^5.2.2"
-    emojis-list "^2.0.0"
+    emojis-list "^3.0.0"
     json5 "^1.0.1"
 
+loader-utils@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0"
+  integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==
+  dependencies:
+    big.js "^5.2.2"
+    emojis-list "^3.0.0"
+    json5 "^2.1.2"
+
 locate-path@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -3387,55 +3493,37 @@ locate-path@^2.0.0:
     p-locate "^2.0.0"
     path-exists "^3.0.0"
 
-lodash.camelcase@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
-  integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
-
-lodash.capitalize@^4.1.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz#f826c9b4e2a8511d84e3aca29db05e1a4f3b72a9"
-  integrity sha1-+CbJtOKoUR2E46yinbBeGk87cqk=
-
-lodash.isplainobject@^4.0.6:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
-  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
-
-lodash.kebabcase@^4.0.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/lodash.kebabcase/-/lodash.kebabcase-4.1.1.tgz#8489b1cb0d29ff88195cceca448ff6d6cc295c36"
-  integrity sha1-hImxyw0p/4gZXM7KRI/21swpXDY=
-
-lodash.memoize@^4.1.2:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
-  integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
-
-lodash.some@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d"
-  integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0=
+locate-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
+  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
+  dependencies:
+    p-locate "^3.0.0"
+    path-exists "^3.0.0"
 
-lodash.tail@^4.1.1:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
-  integrity sha1-0jM6NtnncXyK0vfKyv7HwytERmQ=
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
 
-lodash.uniq@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
-  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
+lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20:
+  version "4.17.20"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
+  integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
 
-lodash@^4.0.0, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.3.0, lodash@~4.17.10:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+log-symbols@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920"
+  integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA==
+  dependencies:
+    chalk "^4.0.0"
 
-longest@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/longest/-/longest-1.0.1.tgz#30a0b2da38f73770e8294a0d22e6625ed77d0097"
-  integrity sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=
+longest-streak@^2.0.1:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4"
+  integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==
 
 loose-envify@^1.0.0:
   version "1.4.0"
@@ -3444,39 +3532,50 @@ loose-envify@^1.0.0:
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
-loud-rejection@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
-  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
+lru-cache@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
+  integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
   dependencies:
-    currently-unhandled "^0.4.1"
-    signal-exit "^3.0.0"
+    yallist "^3.0.2"
 
-lru-cache@^4.0.1:
-  version "4.1.5"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
-  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
+lru-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
   dependencies:
-    pseudomap "^1.0.2"
-    yallist "^2.1.2"
+    yallist "^4.0.0"
 
-make-dir@^1.0.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
-  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
+make-dir@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
+  integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==
+  dependencies:
+    pify "^4.0.1"
+    semver "^5.6.0"
+
+make-dir@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
   dependencies:
-    pify "^3.0.0"
+    semver "^6.0.0"
 
 map-cache@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
   integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
 
-map-obj@^1.0.0, map-obj@^1.0.1:
+map-obj@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
   integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
 
+map-obj@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.1.0.tgz#b91221b542734b9f14256c0132c897c5d7256fd5"
+  integrity sha512-glc9y00wgtwcDmp7GaE/0b0OnxpNJsVf3ael/An6Fe2Q51LLwN1er6sdomLRzz5h0+yMpiYLhWYF5R7HeqVd4g==
+
 map-visit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f"
@@ -3484,10 +3583,22 @@ map-visit@^1.0.0:
   dependencies:
     object-visit "^1.0.0"
 
-math-expression-evaluator@^1.2.14:
-  version "1.2.17"
-  resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
-  integrity sha1-3oGf282E3M2PrlnGrreWFbnSZqw=
+markdown-escapes@^1.0.0:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535"
+  integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg==
+
+markdown-table@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-2.0.0.tgz#194a90ced26d31fe753d8b9434430214c011865b"
+  integrity sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==
+  dependencies:
+    repeat-string "^1.0.0"
+
+mathml-tag-names@^2.1.3:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3"
+  integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
 
 md5.js@^1.3.4:
   version "1.3.5"
@@ -3498,14 +3609,14 @@ md5.js@^1.3.4:
     inherits "^2.0.1"
     safe-buffer "^5.1.2"
 
-mem@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
-  integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=
+mdast-util-compact@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-2.0.1.tgz#cabc69a2f43103628326f35b1acf735d55c99490"
+  integrity sha512-7GlnT24gEwDrdAwEHrU4Vv5lLWrEer4KOkAiKT9nYstsTad7Oc1TwqT2zIMKRdZF7cTuaf+GA1E4Kv7jJh8mPA==
   dependencies:
-    mimic-fn "^1.0.0"
+    unist-util-visit "^2.0.0"
 
-memory-fs@^0.4.0, memory-fs@~0.4.1:
+memory-fs@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.4.1.tgz#3a9a20b8462523e447cfbc7e8bb80ed667bfc552"
   integrity sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=
@@ -3513,28 +3624,42 @@ memory-fs@^0.4.0, memory-fs@~0.4.1:
     errno "^0.1.3"
     readable-stream "^2.0.1"
 
-meow@^3.7.0:
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
-  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
-  dependencies:
-    camelcase-keys "^2.0.0"
-    decamelize "^1.1.2"
-    loud-rejection "^1.0.0"
-    map-obj "^1.0.1"
-    minimist "^1.1.3"
-    normalize-package-data "^2.3.4"
-    object-assign "^4.0.1"
-    read-pkg-up "^1.0.1"
-    redent "^1.0.0"
-    trim-newlines "^1.0.0"
+memory-fs@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c"
+  integrity sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==
+  dependencies:
+    errno "^0.1.3"
+    readable-stream "^2.0.1"
 
-merge@^1.2.0:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145"
-  integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==
+meow@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-7.1.1.tgz#7c01595e3d337fcb0ec4e8eed1666ea95903d306"
+  integrity sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==
+  dependencies:
+    "@types/minimist" "^1.2.0"
+    camelcase-keys "^6.2.2"
+    decamelize-keys "^1.1.0"
+    hard-rejection "^2.1.0"
+    minimist-options "4.1.0"
+    normalize-package-data "^2.5.0"
+    read-pkg-up "^7.0.1"
+    redent "^3.0.0"
+    trim-newlines "^3.0.0"
+    type-fest "^0.13.1"
+    yargs-parser "^18.1.3"
+
+merge-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
+  integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
-micromatch@^3.1.10, micromatch@^3.1.4:
+merge2@^1.3.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
+  integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
+
+micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4:
   version "3.1.10"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
   integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
@@ -3553,6 +3678,14 @@ micromatch@^3.1.10, micromatch@^3.1.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
+micromatch@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259"
+  integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==
+  dependencies:
+    braces "^3.0.1"
+    picomatch "^2.0.5"
+
 miller-rabin@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"
@@ -3561,27 +3694,20 @@ miller-rabin@^4.0.0:
     bn.js "^4.0.0"
     brorand "^1.0.1"
 
-mime-db@1.40.0:
-  version "1.40.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32"
-  integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==
+min-indent@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
+  integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
-mime-types@^2.1.12, mime-types@~2.1.19:
-  version "2.1.24"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81"
-  integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==
+mini-css-extract-plugin@^0.11.2:
+  version "0.11.2"
+  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.11.2.tgz#e3af4d5e04fbcaaf11838ab230510073060b37bf"
+  integrity sha512-h2LknfX4U1kScXxH8xE9LCOqT5B+068EAj36qicMb8l4dqdJoyHcmWmpd+ueyZfgu/POvIn+teoUnTtei2ikug==
   dependencies:
-    mime-db "1.40.0"
-
-mime@^1.4.1:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
-  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
-
-mimic-fn@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
-  integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==
+    loader-utils "^1.1.0"
+    normalize-url "1.9.1"
+    schema-utils "^1.0.0"
+    webpack-sources "^1.1.0"
 
 minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
   version "1.0.1"
@@ -3593,42 +3719,78 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
   integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
 
-minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2:
+minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist@0.0.8:
-  version "0.0.8"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
+minimist-options@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
+  integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==
+  dependencies:
+    arrify "^1.0.1"
+    is-plain-obj "^1.1.0"
+    kind-of "^6.0.3"
 
-minimist@1.1.x:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.1.3.tgz#3bedfd91a92d39016fcfaa1c681e8faa1a1efda8"
-  integrity sha1-O+39kaktOQFvz6ocaB6Pqhoe/ag=
+minimist@^1.2.0, minimist@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
+  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
-minimist@^1.1.3, minimist@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
-  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
+minipass-collect@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617"
+  integrity sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==
+  dependencies:
+    minipass "^3.0.0"
 
-minipass@^2.2.1, minipass@^2.3.4:
-  version "2.3.5"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848"
-  integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==
+minipass-flush@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/minipass-flush/-/minipass-flush-1.0.5.tgz#82e7135d7e89a50ffe64610a787953c4c4cbb373"
+  integrity sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==
   dependencies:
-    safe-buffer "^5.1.2"
-    yallist "^3.0.0"
+    minipass "^3.0.0"
 
-minizlib@^1.1.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614"
-  integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==
+minipass-pipeline@^1.2.2:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz#68472f79711c084657c067c5c6ad93cddea8214c"
+  integrity sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==
+  dependencies:
+    minipass "^3.0.0"
+
+minipass@^3.0.0, minipass@^3.1.1:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
+  integrity sha512-Mgd2GdMVzY+x3IJ+oHnVM+KG3lA5c8tnabyJKmHSaG2kAGpudxuOf8ToDkhumF7UzME7DecbQE9uOZhNm7PuJg==
+  dependencies:
+    yallist "^4.0.0"
+
+minizlib@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
+  integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
   dependencies:
-    minipass "^2.2.1"
+    minipass "^3.0.0"
+    yallist "^4.0.0"
+
+mississippi@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022"
+  integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==
+  dependencies:
+    concat-stream "^1.5.0"
+    duplexify "^3.4.2"
+    end-of-stream "^1.1.0"
+    flush-write-stream "^1.0.0"
+    from2 "^2.1.0"
+    parallel-transform "^1.1.0"
+    pump "^3.0.0"
+    pumpify "^1.3.3"
+    stream-each "^1.1.0"
+    through2 "^2.0.0"
 
 mixin-deep@^1.2.0:
   version "1.3.2"
@@ -3638,45 +3800,44 @@ mixin-deep@^1.2.0:
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
-mixin-object@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/mixin-object/-/mixin-object-2.0.1.tgz#4fb949441dab182540f1fe035ba60e1947a5e57e"
-  integrity sha1-T7lJRB2rGCVA8f4DW6YOGUel5X4=
+mkdirp@^0.5.1, mkdirp@^0.5.3:
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
+  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
   dependencies:
-    for-in "^0.1.3"
-    is-extendable "^0.1.1"
+    minimist "^1.2.5"
+
+mkdirp@^1.0.3, mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
-"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
-  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
+move-concurrently@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
+  integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=
   dependencies:
-    minimist "0.0.8"
+    aproba "^1.1.1"
+    copy-concurrently "^1.0.0"
+    fs-write-stream-atomic "^1.0.8"
+    mkdirp "^0.5.1"
+    rimraf "^2.5.4"
+    run-queue "^1.0.3"
 
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
   integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
 
-ms@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
-  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
-
-mute-stream@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0"
-  integrity sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=
-
-mute-stream@0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
-  integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
-nan@^2.12.1, nan@^2.13.2:
-  version "2.14.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
-  integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
+nan@^2.12.1:
+  version "2.14.1"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
+  integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
 
 nanomatch@^1.2.9:
   version "1.2.13"
@@ -3700,47 +3861,20 @@ natural-compare@^1.4.0:
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
-needle@^2.2.1:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c"
-  integrity sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==
-  dependencies:
-    debug "^3.2.6"
-    iconv-lite "^0.4.4"
-    sax "^1.2.4"
-
-neo-async@^2.5.0:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.1.tgz#ac27ada66167fa8849a6addd837f6b189ad2081c"
-  integrity sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw==
+neo-async@^2.5.0, neo-async@^2.6.1, neo-async@^2.6.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
+  integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
 
-next-tick@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
-  integrity sha1-yobR/ogoFpsBICCOPchCS524NCw=
+nice-try@^1.0.4:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
+  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
 
-node-gyp@^3.8.0:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c"
-  integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==
-  dependencies:
-    fstream "^1.0.0"
-    glob "^7.0.3"
-    graceful-fs "^4.1.2"
-    mkdirp "^0.5.0"
-    nopt "2 || 3"
-    npmlog "0 || 1 || 2 || 3 || 4"
-    osenv "0"
-    request "^2.87.0"
-    rimraf "2"
-    semver "~5.3.0"
-    tar "^2.0.0"
-    which "1"
-
-node-libs-browser@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.0.tgz#c72f60d9d46de08a940dedbb25f3ffa2f9bbaa77"
-  integrity sha512-5MQunG/oyOaBdttrL40dA7bUfPORLRWMUJLQtMg7nluxUvk5XwnLdL9twQHFAjRx/y7mIMkLKT9++qPbbk6BZA==
+node-libs-browser@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425"
+  integrity sha512-h/zcD8H9kaDZ9ALUWwlBUDo6TKF8a7qBSCSEGfjTVIYeqsioSKaAX+BN7NgiMGp6iSIXZ3PxgCu8KS3b71YK5Q==
   dependencies:
     assert "^1.1.1"
     browserify-zlib "^0.2.0"
@@ -3752,7 +3886,7 @@ node-libs-browser@^2.0.0:
     events "^3.0.0"
     https-browserify "^1.0.0"
     os-browserify "^0.3.0"
-    path-browserify "0.0.0"
+    path-browserify "0.0.1"
     process "^0.11.10"
     punycode "^1.2.4"
     querystring-es3 "^0.2.0"
@@ -3764,63 +3898,14 @@ node-libs-browser@^2.0.0:
     tty-browserify "0.0.0"
     url "^0.11.0"
     util "^0.11.0"
-    vm-browserify "0.0.4"
+    vm-browserify "^1.0.1"
 
-node-pre-gyp@^0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149"
-  integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==
-  dependencies:
-    detect-libc "^1.0.2"
-    mkdirp "^0.5.1"
-    needle "^2.2.1"
-    nopt "^4.0.1"
-    npm-packlist "^1.1.6"
-    npmlog "^4.0.2"
-    rc "^1.2.7"
-    rimraf "^2.6.1"
-    semver "^5.3.0"
-    tar "^4"
-
-node-sass@^4.12.0:
-  version "4.12.0"
-  resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.12.0.tgz#0914f531932380114a30cc5fa4fa63233a25f017"
-  integrity sha512-A1Iv4oN+Iel6EPv77/HddXErL2a+gZ4uBeZUy+a8O35CFYTXhgA8MgLCWBtwpGZdCvTvQ9d+bQxX/QC36GDPpQ==
-  dependencies:
-    async-foreach "^0.1.3"
-    chalk "^1.1.1"
-    cross-spawn "^3.0.0"
-    gaze "^1.0.0"
-    get-stdin "^4.0.1"
-    glob "^7.0.3"
-    in-publish "^2.0.0"
-    lodash "^4.17.11"
-    meow "^3.7.0"
-    mkdirp "^0.5.1"
-    nan "^2.13.2"
-    node-gyp "^3.8.0"
-    npmlog "^4.0.0"
-    request "^2.88.0"
-    sass-graph "^2.2.4"
-    stdout-stream "^1.4.0"
-    "true-case-path" "^1.0.2"
-
-"nopt@2 || 3":
-  version "3.0.6"
-  resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9"
-  integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k=
-  dependencies:
-    abbrev "1"
-
-nopt@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d"
-  integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=
-  dependencies:
-    abbrev "1"
-    osenv "^0.1.4"
+node-releases@^1.1.61:
+  version "1.1.61"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.61.tgz#707b0fca9ce4e11783612ba4a2fcba09047af16e"
+  integrity sha512-DD5vebQLg8jLCOzwupn954fbIiZht05DAZs0k2u8NStSe6h9XdsuIQL8hSRKYiU8WUQRznmSDrKGbv3ObOmC7g==
 
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
   integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
@@ -3837,7 +3922,7 @@ normalize-path@^2.1.1:
   dependencies:
     remove-trailing-separator "^1.0.1"
 
-normalize-path@^3.0.0:
+normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
@@ -3847,7 +3932,12 @@ normalize-range@^0.1.2:
   resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
   integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
 
-normalize-url@^1.4.0:
+normalize-selector@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/normalize-selector/-/normalize-selector-0.2.0.tgz#d0b145eb691189c63a78d201dc4fdb1293ef0c03"
+  integrity sha1-0LFF62kRicY6eNIB3E/bEpPvDAM=
+
+normalize-url@1.9.1:
   version "1.9.1"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-1.9.1.tgz#2cc0d66b31ea23036458436e3620d85954c66c3c"
   integrity sha1-LMDWazHqIwNkWENuNiDYWVTGbDw=
@@ -3857,51 +3947,11 @@ normalize-url@^1.4.0:
     query-string "^4.1.0"
     sort-keys "^1.0.0"
 
-npm-bundled@^1.0.1:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd"
-  integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==
-
-npm-packlist@^1.1.6:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc"
-  integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==
-  dependencies:
-    ignore-walk "^3.0.1"
-    npm-bundled "^1.0.1"
-
-npm-run-path@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
-  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
-  dependencies:
-    path-key "^2.0.0"
-
-"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2:
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
-  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
-  dependencies:
-    are-we-there-yet "~1.1.2"
-    console-control-strings "~1.1.0"
-    gauge "~2.7.3"
-    set-blocking "~2.0.0"
-
 num2fraction@^1.2.2:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede"
   integrity sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=
 
-number-is-nan@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-
-oauth-sign@~0.9.0:
-  version "0.9.0"
-  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
-  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
-
 object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@@ -3916,7 +3966,12 @@ object-copy@^0.1.0:
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-keys@^1.0.12:
+object-inspect@^1.7.0, object-inspect@^1.8.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
+  integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
+
+object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
@@ -3928,6 +3983,25 @@ object-visit@^1.0.0:
   dependencies:
     isobject "^3.0.0"
 
+object.assign@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.1.tgz#303867a666cdd41936ecdedfb1f8f3e32a478cdd"
+  integrity sha512-VT/cxmx5yaoHSOTSyrCygIDFco+RsibY2NM0a4RdEeY/4KgqezwFtK1yr3U67xYhqJSlASm2pKhLVzPj2lr4bA==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.18.0-next.0"
+    has-symbols "^1.0.1"
+    object-keys "^1.1.1"
+
+object.entries@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.2.tgz#bc73f00acb6b6bb16c203434b10f9a7e797d3add"
+  integrity sha512-BQdB9qKmb/HyNdMNWVr7O3+z5MUIx3aiegEIJqjMBbBf0YT9RRxTJSim4mzFqtyr7PDAHigq0N9dO0m0tRakQA==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.5"
+    has "^1.0.3"
+
 object.pick@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
@@ -3935,81 +4009,40 @@ object.pick@^1.3.0:
   dependencies:
     isobject "^3.0.1"
 
-once@^1.3.0:
+object.values@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.1.tgz#68a99ecde356b7e9295a3c5e0ce31dc8c953de5e"
+  integrity sha512-WTa54g2K8iu0kmS/us18jEmdv1a4Wi//BZ/DTVYEcH0XhLM5NYdpDHja3gt57VrZLcNAO2WGA+KpWsDBaHt6eA==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0-next.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+
+once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
   dependencies:
     wrappy "1"
 
-onetime@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
-  integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
-
-onetime@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
-  integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=
-  dependencies:
-    mimic-fn "^1.0.0"
-
-optionator@^0.8.1, optionator@^0.8.2:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
-  integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=
+optionator@^0.9.1:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
+  integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
   dependencies:
-    deep-is "~0.1.3"
-    fast-levenshtein "~2.0.4"
-    levn "~0.3.0"
-    prelude-ls "~1.1.2"
-    type-check "~0.3.2"
-    wordwrap "~1.0.0"
+    deep-is "^0.1.3"
+    fast-levenshtein "^2.0.6"
+    levn "^0.4.1"
+    prelude-ls "^1.2.1"
+    type-check "^0.4.0"
+    word-wrap "^1.2.3"
 
 os-browserify@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27"
   integrity sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=
 
-os-homedir@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
-  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-
-os-locale@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-1.4.0.tgz#20f9f17ae29ed345e8bde583b13d2009803c14d9"
-  integrity sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=
-  dependencies:
-    lcid "^1.0.0"
-
-os-locale@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2"
-  integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==
-  dependencies:
-    execa "^0.7.0"
-    lcid "^1.0.0"
-    mem "^1.1.0"
-
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
-  integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
-
-osenv@0, osenv@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
-  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
-  dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.0"
-
-p-finally@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
-  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
-
 p-limit@^1.1.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
@@ -4017,35 +4050,102 @@ p-limit@^1.1.0:
   dependencies:
     p-try "^1.0.0"
 
+p-limit@^2.0.0, p-limit@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+  dependencies:
+    p-try "^2.0.0"
+
+p-limit@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.0.2.tgz#1664e010af3cadc681baafd3e2a437be7b0fb5fe"
+  integrity sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==
+  dependencies:
+    p-try "^2.0.0"
+
 p-locate@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
   integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=
   dependencies:
-    p-limit "^1.1.0"
+    p-limit "^1.1.0"
+
+p-locate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
+  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
+  dependencies:
+    p-limit "^2.0.0"
+
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
+p-map@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b"
+  integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==
+  dependencies:
+    aggregate-error "^3.0.0"
 
 p-try@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
   integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=
 
+p-try@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
 pako@~1.0.5:
-  version "1.0.10"
-  resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.10.tgz#4328badb5086a426aa90f541977d4955da5c9732"
-  integrity sha512-0DTvPVU3ed8+HNXOu5Bs+o//Mbdj9VNQMUOe9oKCwh8l0GNwpTDMKCWbRjgtD291AWnkAgkqA/LOnQS8AmS1tw==
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
+  integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
+
+parallel-transform@^1.1.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"
+  integrity sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==
+  dependencies:
+    cyclist "^1.0.1"
+    inherits "^2.0.3"
+    readable-stream "^2.1.5"
+
+parent-module@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
+  integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==
+  dependencies:
+    callsites "^3.0.0"
 
-parse-asn1@^5.0.0:
-  version "5.1.4"
-  resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.4.tgz#37f6628f823fbdeb2273b4d540434a22f3ef1fcc"
-  integrity sha512-Qs5duJcuvNExRfFZ99HDD3z4mAi3r9Wl/FOjEOijlxwCZs7E7mW2vjTpgQ4J8LpTF8x5v+1Vn5UQFejmWT11aw==
+parse-asn1@^5.0.0, parse-asn1@^5.1.5:
+  version "5.1.6"
+  resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.6.tgz#385080a3ec13cb62a62d39409cb3e88844cdaed4"
+  integrity sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==
   dependencies:
-    asn1.js "^4.0.0"
+    asn1.js "^5.2.0"
     browserify-aes "^1.0.0"
-    create-hash "^1.1.0"
     evp_bytestokey "^1.0.0"
     pbkdf2 "^3.0.3"
     safe-buffer "^5.1.1"
 
+parse-entities@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8"
+  integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==
+  dependencies:
+    character-entities "^1.0.0"
+    character-entities-legacy "^1.0.0"
+    character-reference-invalid "^1.0.0"
+    is-alphanumerical "^1.0.0"
+    is-decimal "^1.0.0"
+    is-hexadecimal "^1.0.0"
+
 parse-json@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9"
@@ -4053,62 +4153,66 @@ parse-json@^2.2.0:
   dependencies:
     error-ex "^1.2.0"
 
+parse-json@^5.0.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.1.0.tgz#f96088cdf24a8faa9aea9a009f2d9d942c999646"
+  integrity sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    error-ex "^1.3.1"
+    json-parse-even-better-errors "^2.3.0"
+    lines-and-columns "^1.1.6"
+
+parse-passwd@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
+  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
+
 pascalcase@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
   integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
 
-path-browserify@0.0.0:
-  version "0.0.0"
-  resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.0.tgz#a0b870729aae214005b7d5032ec2cbbb0fb4451a"
-  integrity sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo=
+path-browserify@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a"
+  integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ==
 
 path-dirname@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
   integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
 
-path-exists@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
-  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
-  dependencies:
-    pinkie-promise "^2.0.0"
-
 path-exists@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
   integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
 
-path-is-absolute@^1.0.0, path-is-absolute@^1.0.1:
+path-exists@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-is-absolute@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
-path-is-inside@^1.0.1, path-is-inside@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
-  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
-
-path-key@^2.0.0:
+path-key@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
   integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
 
+path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
 path-parse@^1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
   integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
 
-path-type@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
-  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
-  dependencies:
-    graceful-fs "^4.1.2"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
 path-type@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73"
@@ -4116,10 +4220,15 @@ path-type@^2.0.0:
   dependencies:
     pify "^2.0.0"
 
+path-type@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
+  integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
+
 pbkdf2@^3.0.3:
-  version "3.0.17"
-  resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.0.17.tgz#976c206530617b14ebb32114239f7b09336e93a6"
-  integrity sha512-U/il5MsrZp7mGg3mSQfn742na2T+1/vHDCG5/iTI3X9MKUuYUZVLQhyRsg06mCgDBTd57TxzgZt7P+fYfjRLtA==
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.1.tgz#cb8724b0fada984596856d1a6ebafd3584654b94"
+  integrity sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg==
   dependencies:
     create-hash "^1.1.2"
     create-hmac "^1.1.4"
@@ -4127,32 +4236,20 @@ pbkdf2@^3.0.3:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
-performance-now@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
-  integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
+picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
+  integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
 
 pify@^2.0.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
   integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
 
-pify@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
-  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
-
-pinkie-promise@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
-  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
-  dependencies:
-    pinkie "^2.0.0"
-
-pinkie@^2.0.0:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
-  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
+pify@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
+  integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
 
 pkg-dir@^2.0.0:
   version "2.0.0"
@@ -4161,350 +4258,168 @@ pkg-dir@^2.0.0:
   dependencies:
     find-up "^2.1.0"
 
-pluralize@^1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-1.2.1.tgz#d1a21483fd22bb41e58a12fa3421823140897c45"
-  integrity sha1-0aIUg/0iu0HlihL6NCGCMUCJfEU=
+pkg-dir@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-3.0.0.tgz#2749020f239ed990881b1f71210d51eb6523bea3"
+  integrity sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==
+  dependencies:
+    find-up "^3.0.0"
 
-pluralize@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
-  integrity sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==
+pkg-dir@^4.1.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
 
 posix-character-classes@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab"
   integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=
 
-postcss-calc@^5.2.0:
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e"
-  integrity sha1-d7rnypKK2FcW4v2kLyYb98HWW14=
-  dependencies:
-    postcss "^5.0.2"
-    postcss-message-helpers "^2.0.0"
-    reduce-css-calc "^1.2.6"
-
-postcss-colormin@^2.1.8:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-2.2.2.tgz#6631417d5f0e909a3d7ec26b24c8a8d1e4f96e4b"
-  integrity sha1-ZjFBfV8OkJo9fsJrJMio0eT5bks=
-  dependencies:
-    colormin "^1.0.5"
-    postcss "^5.0.13"
-    postcss-value-parser "^3.2.3"
-
-postcss-convert-values@^2.3.4:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-2.6.1.tgz#bbd8593c5c1fd2e3d1c322bb925dcae8dae4d62d"
-  integrity sha1-u9hZPFwf0uPRwyK7kl3K6Nrk1i0=
-  dependencies:
-    postcss "^5.0.11"
-    postcss-value-parser "^3.1.2"
-
-postcss-discard-comments@^2.0.4:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz#befe89fafd5b3dace5ccce51b76b81514be00e3d"
-  integrity sha1-vv6J+v1bPazlzM5Rt2uBUUvgDj0=
-  dependencies:
-    postcss "^5.0.14"
-
-postcss-discard-duplicates@^2.0.1:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-2.1.0.tgz#b9abf27b88ac188158a5eb12abcae20263b91932"
-  integrity sha1-uavye4isGIFYpesSq8riAmO5GTI=
-  dependencies:
-    postcss "^5.0.4"
-
-postcss-discard-empty@^2.0.1:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz#d2b4bd9d5ced5ebd8dcade7640c7d7cd7f4f92b5"
-  integrity sha1-0rS9nVztXr2Nyt52QMfXzX9PkrU=
-  dependencies:
-    postcss "^5.0.14"
-
-postcss-discard-overridden@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz#8b1eaf554f686fb288cd874c55667b0aa3668d58"
-  integrity sha1-ix6vVU9ob7KIzYdMVWZ7CqNmjVg=
-  dependencies:
-    postcss "^5.0.16"
-
-postcss-discard-unused@^2.2.1:
-  version "2.2.3"
-  resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz#bce30b2cc591ffc634322b5fb3464b6d934f4433"
-  integrity sha1-vOMLLMWR/8Y0Mitfs0ZLbZNPRDM=
-  dependencies:
-    postcss "^5.0.14"
-    uniqs "^2.0.0"
-
-postcss-filter-plugins@^2.0.0:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/postcss-filter-plugins/-/postcss-filter-plugins-2.0.3.tgz#82245fdf82337041645e477114d8e593aa18b8ec"
-  integrity sha512-T53GVFsdinJhgwm7rg1BzbeBRomOg9y5MBVhGcsV0CxurUdVj1UlPdKtn7aqYA/c/QVkzKMjq2bSV5dKG5+AwQ==
-  dependencies:
-    postcss "^5.0.4"
-
-postcss-merge-idents@^2.1.5:
-  version "2.1.7"
-  resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz#4c5530313c08e1d5b3bbf3d2bbc747e278eea270"
-  integrity sha1-TFUwMTwI4dWzu/PSu8dH4njuonA=
+postcss-html@^0.36.0:
+  version "0.36.0"
+  resolved "https://registry.yarnpkg.com/postcss-html/-/postcss-html-0.36.0.tgz#b40913f94eaacc2453fd30a1327ad6ee1f88b204"
+  integrity sha512-HeiOxGcuwID0AFsNAL0ox3mW6MHH5cstWN1Z3Y+n6H+g12ih7LHdYxWwEA/QmrebctLjo79xz9ouK3MroHwOJw==
   dependencies:
-    has "^1.0.1"
-    postcss "^5.0.10"
-    postcss-value-parser "^3.1.1"
+    htmlparser2 "^3.10.0"
 
-postcss-merge-longhand@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-2.0.2.tgz#23d90cd127b0a77994915332739034a1a4f3d658"
-  integrity sha1-I9kM0Sewp3mUkVMyc5A0oaTz1lg=
+postcss-less@^3.1.4:
+  version "3.1.4"
+  resolved "https://registry.yarnpkg.com/postcss-less/-/postcss-less-3.1.4.tgz#369f58642b5928ef898ffbc1a6e93c958304c5ad"
+  integrity sha512-7TvleQWNM2QLcHqvudt3VYjULVB49uiW6XzEUFmvwHzvsOEF5MwBrIXZDJQvJNFGjJQTzSzZnDoCJ8h/ljyGXA==
   dependencies:
-    postcss "^5.0.4"
+    postcss "^7.0.14"
 
-postcss-merge-rules@^2.0.3:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-2.1.2.tgz#d1df5dfaa7b1acc3be553f0e9e10e87c61b5f721"
-  integrity sha1-0d9d+qexrMO+VT8OnhDofGG19yE=
-  dependencies:
-    browserslist "^1.5.2"
-    caniuse-api "^1.5.2"
-    postcss "^5.0.4"
-    postcss-selector-parser "^2.2.2"
-    vendors "^1.0.0"
+postcss-media-query-parser@^0.2.3:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz#27b39c6f4d94f81b1a73b8f76351c609e5cef244"
+  integrity sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ=
 
-postcss-message-helpers@^2.0.0:
+postcss-modules-extract-imports@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz#a4f2f4fab6e4fe002f0aed000478cdf52f9ba60e"
-  integrity sha1-pPL0+rbk/gAvCu0ABHjN9S+bpg4=
-
-postcss-minify-font-values@^1.0.2:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz#4b58edb56641eba7c8474ab3526cafd7bbdecb69"
-  integrity sha1-S1jttWZB66fIR0qzUmyv17vey2k=
-  dependencies:
-    object-assign "^4.0.1"
-    postcss "^5.0.4"
-    postcss-value-parser "^3.0.2"
-
-postcss-minify-gradients@^1.0.1:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz#5dbda11373703f83cfb4a3ea3881d8d75ff5e6e1"
-  integrity sha1-Xb2hE3NwP4PPtKPqOIHY11/15uE=
-  dependencies:
-    postcss "^5.0.12"
-    postcss-value-parser "^3.3.0"
-
-postcss-minify-params@^1.0.4:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-1.2.2.tgz#ad2ce071373b943b3d930a3fa59a358c28d6f1f3"
-  integrity sha1-rSzgcTc7lDs9kwo/pZo1jCjW8fM=
-  dependencies:
-    alphanum-sort "^1.0.1"
-    postcss "^5.0.2"
-    postcss-value-parser "^3.0.2"
-    uniqs "^2.0.0"
-
-postcss-minify-selectors@^2.0.4:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-2.1.1.tgz#b2c6a98c0072cf91b932d1a496508114311735bf"
-  integrity sha1-ssapjAByz5G5MtGkllCBFDEXNb8=
-  dependencies:
-    alphanum-sort "^1.0.2"
-    has "^1.0.1"
-    postcss "^5.0.14"
-    postcss-selector-parser "^2.0.0"
-
-postcss-modules-extract-imports@^1.2.0:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.1.tgz#dc87e34148ec7eab5f791f7cd5849833375b741a"
-  integrity sha512-6jt9XZwUhwmRUhb/CkyJY020PYaPJsCyt3UjbaWo6XEbH/94Hmv6MP7fG2C5NDU/BcHzyGYxNtHvM+LTf9HrYw==
-  dependencies:
-    postcss "^6.0.1"
-
-postcss-modules-local-by-default@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz#f7d80c398c5a393fa7964466bd19500a7d61c069"
-  integrity sha1-99gMOYxaOT+nlkRmvRlQCn1hwGk=
-  dependencies:
-    css-selector-tokenizer "^0.7.0"
-    postcss "^6.0.1"
-
-postcss-modules-scope@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz#d6ea64994c79f97b62a72b426fbe6056a194bb90"
-  integrity sha1-1upkmUx5+XtipytCb75gVqGUu5A=
+  resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-2.0.0.tgz#818719a1ae1da325f9832446b01136eeb493cd7e"
+  integrity sha512-LaYLDNS4SG8Q5WAWqIJgdHPJrDDr/Lv775rMBFUbgjTz6j34lUznACHcdRWroPvXANP2Vj7yNK57vp9eFqzLWQ==
   dependencies:
-    css-selector-tokenizer "^0.7.0"
-    postcss "^6.0.1"
+    postcss "^7.0.5"
 
-postcss-modules-values@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz#ecffa9d7e192518389f42ad0e83f72aec456ea20"
-  integrity sha1-7P+p1+GSUYOJ9CrQ6D9yrsRW6iA=
+postcss-modules-local-by-default@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-3.0.3.tgz#bb14e0cc78279d504dbdcbfd7e0ca28993ffbbb0"
+  integrity sha512-e3xDq+LotiGesympRlKNgaJ0PCzoUIdpH0dj47iWAui/kyTgh3CiAr1qP54uodmJhl6p9rN6BoNcdEDVJx9RDw==
   dependencies:
-    icss-replace-symbols "^1.1.0"
-    postcss "^6.0.1"
+    icss-utils "^4.1.1"
+    postcss "^7.0.32"
+    postcss-selector-parser "^6.0.2"
+    postcss-value-parser "^4.1.0"
 
-postcss-normalize-charset@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz#ef9ee71212d7fe759c78ed162f61ed62b5cb93f1"
-  integrity sha1-757nEhLX/nWceO0WL2HtYrXLk/E=
+postcss-modules-scope@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-2.2.0.tgz#385cae013cc7743f5a7d7602d1073a89eaae62ee"
+  integrity sha512-YyEgsTMRpNd+HmyC7H/mh3y+MeFWevy7V1evVhJWewmMbjDHIbZbOXICC2y+m1xI1UVfIT1HMW/O04Hxyu9oXQ==
   dependencies:
-    postcss "^5.0.5"
+    postcss "^7.0.6"
+    postcss-selector-parser "^6.0.0"
 
-postcss-normalize-url@^3.0.7:
-  version "3.0.8"
-  resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-3.0.8.tgz#108f74b3f2fcdaf891a2ffa3ea4592279fc78222"
-  integrity sha1-EI90s/L82viRov+j6kWSJ5/HgiI=
+postcss-modules-values@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-3.0.0.tgz#5b5000d6ebae29b4255301b4a3a54574423e7f10"
+  integrity sha512-1//E5jCBrZ9DmRX+zCtmQtRSV6PV42Ix7Bzj9GbwJceduuf7IqP8MgeTXuRDHOWj2m0VzZD5+roFWDuU8RQjcg==
   dependencies:
-    is-absolute-url "^2.0.0"
-    normalize-url "^1.4.0"
-    postcss "^5.0.14"
-    postcss-value-parser "^3.2.3"
+    icss-utils "^4.0.0"
+    postcss "^7.0.6"
 
-postcss-ordered-values@^2.1.0:
-  version "2.2.3"
-  resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz#eec6c2a67b6c412a8db2042e77fe8da43f95c11d"
-  integrity sha1-7sbCpntsQSqNsgQud/6NpD+VwR0=
-  dependencies:
-    postcss "^5.0.4"
-    postcss-value-parser "^3.0.1"
+postcss-resolve-nested-selector@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz#29ccbc7c37dedfac304e9fff0bf1596b3f6a0e4e"
+  integrity sha1-Kcy8fDfe36wwTp//C/FZaz9qDk4=
 
-postcss-reduce-idents@^2.2.2:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-2.4.0.tgz#c2c6d20cc958284f6abfbe63f7609bf409059ad3"
-  integrity sha1-wsbSDMlYKE9qv75j92Cb9AkFmtM=
+postcss-safe-parser@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/postcss-safe-parser/-/postcss-safe-parser-4.0.2.tgz#a6d4e48f0f37d9f7c11b2a581bf00f8ba4870b96"
+  integrity sha512-Uw6ekxSWNLCPesSv/cmqf2bY/77z11O7jZGPax3ycZMFU/oi2DMH9i89AdHc1tRwFg/arFoEwX0IS3LCUxJh1g==
   dependencies:
-    postcss "^5.0.4"
-    postcss-value-parser "^3.0.2"
+    postcss "^7.0.26"
 
-postcss-reduce-initial@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-1.0.1.tgz#68f80695f045d08263a879ad240df8dd64f644ea"
-  integrity sha1-aPgGlfBF0IJjqHmtJA343WT2ROo=
+postcss-sass@^0.4.4:
+  version "0.4.4"
+  resolved "https://registry.yarnpkg.com/postcss-sass/-/postcss-sass-0.4.4.tgz#91f0f3447b45ce373227a98b61f8d8f0785285a3"
+  integrity sha512-BYxnVYx4mQooOhr+zer0qWbSPYnarAy8ZT7hAQtbxtgVf8gy+LSLT/hHGe35h14/pZDTw1DsxdbrwxBN++H+fg==
   dependencies:
-    postcss "^5.0.4"
+    gonzales-pe "^4.3.0"
+    postcss "^7.0.21"
 
-postcss-reduce-transforms@^1.0.3:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz#ff76f4d8212437b31c298a42d2e1444025771ae1"
-  integrity sha1-/3b02CEkN7McKYpC0uFEQCV3GuE=
+postcss-scss@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/postcss-scss/-/postcss-scss-2.1.1.tgz#ec3a75fa29a55e016b90bf3269026c53c1d2b383"
+  integrity sha512-jQmGnj0hSGLd9RscFw9LyuSVAa5Bl1/KBPqG1NQw9w8ND55nY4ZEsdlVuYJvLPpV+y0nwTV5v/4rHPzZRihQbA==
   dependencies:
-    has "^1.0.1"
-    postcss "^5.0.8"
-    postcss-value-parser "^3.0.1"
+    postcss "^7.0.6"
 
-postcss-selector-parser@^2.0.0, postcss-selector-parser@^2.2.2:
-  version "2.2.3"
-  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz#f9437788606c3c9acee16ffe8d8b16297f27bb90"
-  integrity sha1-+UN3iGBsPJrO4W/+jYsWKX8nu5A=
+postcss-selector-parser@^6.0.0, postcss-selector-parser@^6.0.2:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.3.tgz#766d77728728817cc140fa1ac6da5e77f9fada98"
+  integrity sha512-0ClFaY4X1ra21LRqbW6y3rUbWcxnSVkDFG57R7Nxus9J9myPFlv+jYDMohzpkBx0RrjjiqjtycpchQ+PLGmZ9w==
   dependencies:
-    flatten "^1.0.2"
+    cssesc "^3.0.0"
     indexes-of "^1.0.1"
     uniq "^1.0.1"
+    util-deprecate "^1.0.2"
 
-postcss-svgo@^2.1.1:
-  version "2.1.6"
-  resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-2.1.6.tgz#b6df18aa613b666e133f08adb5219c2684ac108d"
-  integrity sha1-tt8YqmE7Zm4TPwittSGcJoSsEI0=
-  dependencies:
-    is-svg "^2.0.0"
-    postcss "^5.0.14"
-    postcss-value-parser "^3.2.3"
-    svgo "^0.7.0"
-
-postcss-unique-selectors@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz#981d57d29ddcb33e7b1dfe1fd43b8649f933ca1d"
-  integrity sha1-mB1X0p3csz57Hf4f1DuGSfkzyh0=
-  dependencies:
-    alphanum-sort "^1.0.1"
-    postcss "^5.0.4"
-    uniqs "^2.0.0"
-
-postcss-value-parser@^3.0.1, postcss-value-parser@^3.0.2, postcss-value-parser@^3.1.1, postcss-value-parser@^3.1.2, postcss-value-parser@^3.2.3, postcss-value-parser@^3.3.0:
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281"
-  integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==
-
-postcss-zindex@^2.0.1:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-2.2.0.tgz#d2109ddc055b91af67fc4cb3b025946639d2af22"
-  integrity sha1-0hCd3AVbka9n/EyzsCWUZjnSryI=
-  dependencies:
-    has "^1.0.1"
-    postcss "^5.0.4"
-    uniqs "^2.0.0"
+postcss-syntax@^0.36.2:
+  version "0.36.2"
+  resolved "https://registry.yarnpkg.com/postcss-syntax/-/postcss-syntax-0.36.2.tgz#f08578c7d95834574e5593a82dfbfa8afae3b51c"
+  integrity sha512-nBRg/i7E3SOHWxF3PpF5WnJM/jQ1YpY9000OaVXlAQj6Zp/kIqJxEDWIZ67tAd7NLuk7zqN4yqe9nc0oNAOs1w==
 
-postcss@^5.0.10, postcss@^5.0.11, postcss@^5.0.12, postcss@^5.0.13, postcss@^5.0.14, postcss@^5.0.16, postcss@^5.0.2, postcss@^5.0.4, postcss@^5.0.5, postcss@^5.0.6, postcss@^5.0.8, postcss@^5.2.16:
-  version "5.2.18"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.18.tgz#badfa1497d46244f6390f58b319830d9107853c5"
-  integrity sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==
-  dependencies:
-    chalk "^1.1.3"
-    js-base64 "^2.1.9"
-    source-map "^0.5.6"
-    supports-color "^3.2.3"
+postcss-value-parser@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.1.0.tgz#443f6a20ced6481a2bda4fa8532a6e55d789a2cb"
+  integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ==
 
-postcss@^6.0.1:
-  version "6.0.23"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324"
-  integrity sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==
+postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.21, postcss@^7.0.26, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6:
+  version "7.0.34"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.34.tgz#f2baf57c36010df7de4009940f21532c16d65c20"
+  integrity sha512-H/7V2VeNScX9KE83GDrDZNiGT1m2H+UTnlinIzhjlLX9hfMUn1mHNnGeX81a1c8JSBdBvqk7c2ZOG6ZPn5itGw==
   dependencies:
-    chalk "^2.4.1"
+    chalk "^2.4.2"
     source-map "^0.6.1"
-    supports-color "^5.4.0"
+    supports-color "^6.1.0"
 
-prelude-ls@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
-  integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
+prelude-ls@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
+  integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
 
 prepend-http@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
   integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
 
-private@^0.1.6, private@^0.1.8:
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
-  integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
-
 process-nextick-args@~2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa"
-  integrity sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
 
 process@^0.11.10:
   version "0.11.10"
   resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
   integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
 
-progress@^1.1.8:
-  version "1.1.8"
-  resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be"
-  integrity sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=
-
 progress@^2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
   integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
 
+promise-inflight@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
+  integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
+
 prr@~1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
   integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY=
 
-pseudomap@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
-
-psl@^1.1.24:
-  version "1.1.31"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184"
-  integrity sha512-/6pt4+C+T+wZUieKR620OpzN/LlnNKuWjy1iFLQ/UG35JqHlR/89MP1d96dUfkf6Dne3TuLQzOYEYshJ+Hx8mw==
-
 public-encrypt@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"
@@ -4517,12 +4432,37 @@ public-encrypt@^4.0.0:
     randombytes "^2.0.1"
     safe-buffer "^5.1.2"
 
+pump@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
+  integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pump@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
+  integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pumpify@^1.3.3:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
+  integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
+  dependencies:
+    duplexify "^3.6.0"
+    inherits "^2.0.3"
+    pump "^2.0.0"
+
 punycode@1.3.2:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d"
   integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=
 
-punycode@^1.2.4, punycode@^1.4.1:
+punycode@^1.2.4:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
   integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
@@ -4538,19 +4478,9 @@ pure-extras@^1.0.0:
   integrity sha1-N+PMNZDLqFCYFFTNpdso4npjhxo=
 
 purecss@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/purecss/-/purecss-1.0.0.tgz#3dbcd9e2a7592448a69acb705cce16311bf4b785"
-  integrity sha512-gfC78WCOWNnfkzulx9aoWwcl+0JflhwKeJ+k9s/ZyIawfYNA4bqBmt0DtfgtQK9iuYMtGfbdE8R2AQMjSWR2VQ==
-
-q@^1.1.2:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
-  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
-
-qs@~6.5.2:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
-  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/purecss/-/purecss-1.0.1.tgz#c83d84326a10beb5c3b36d20c0254e946e5568a7"
+  integrity sha512-mTUc5ZzpzafswEhCmTDfSRMMyRFdLYdd+KywMwnBC/MuA/Th7jug2z0Xso4WkxvtxoU/BS9aRb7WnBNyuA7YJQ==
 
 query-string@^4.1.0:
   version "4.3.4"
@@ -4570,7 +4500,12 @@ querystring@0.2.0:
   resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620"
   integrity sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=
 
-randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5:
+quick-lru@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
+  integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
+
+randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
   integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
@@ -4585,24 +4520,6 @@ randomfill@^1.0.3:
     randombytes "^2.0.5"
     safe-buffer "^5.1.0"
 
-rc@^1.2.7:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
-  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
-  dependencies:
-    deep-extend "^0.6.0"
-    ini "~1.3.0"
-    minimist "^1.2.0"
-    strip-json-comments "~2.0.1"
-
-read-pkg-up@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
-  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
-  dependencies:
-    find-up "^1.0.0"
-    read-pkg "^1.0.0"
-
 read-pkg-up@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be"
@@ -4611,14 +4528,14 @@ read-pkg-up@^2.0.0:
     find-up "^2.0.0"
     read-pkg "^2.0.0"
 
-read-pkg@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
-  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
+read-pkg-up@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
+  integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==
   dependencies:
-    load-json-file "^1.0.0"
-    normalize-package-data "^2.3.2"
-    path-type "^1.0.0"
+    find-up "^4.1.0"
+    read-pkg "^5.2.0"
+    type-fest "^0.8.1"
 
 read-pkg@^2.0.0:
   version "2.0.0"
@@ -4629,10 +4546,20 @@ read-pkg@^2.0.0:
     normalize-package-data "^2.3.2"
     path-type "^2.0.0"
 
-readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf"
-  integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==
+read-pkg@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
+  integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
+  dependencies:
+    "@types/normalize-package-data" "^2.4.0"
+    normalize-package-data "^2.5.0"
+    parse-json "^5.0.0"
+    type-fest "^0.6.0"
+
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
   dependencies:
     core-util-is "~1.0.0"
     inherits "~2.0.3"
@@ -4642,6 +4569,15 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6, readable
     string_decoder "~1.1.1"
     util-deprecate "~1.0.1"
 
+readable-stream@^3.1.1, readable-stream@^3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
+  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
+  dependencies:
+    inherits "^2.0.3"
+    string_decoder "^1.1.1"
+    util-deprecate "^1.0.1"
+
 readdirp@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
@@ -4651,57 +4587,44 @@ readdirp@^2.2.1:
     micromatch "^3.1.10"
     readable-stream "^2.0.2"
 
-readline2@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35"
-  integrity sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=
-  dependencies:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    mute-stream "0.0.5"
-
-redent@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
-  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
+readdirp@~3.4.0:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada"
+  integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==
   dependencies:
-    indent-string "^2.1.0"
-    strip-indent "^1.0.1"
+    picomatch "^2.2.1"
 
-reduce-css-calc@^1.2.6:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz#747c914e049614a4c9cfbba629871ad1d2927716"
-  integrity sha1-dHyRTgSWFKTJz7umKYca0dKSdxY=
+redent@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
+  integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
   dependencies:
-    balanced-match "^0.4.2"
-    math-expression-evaluator "^1.2.14"
-    reduce-function-call "^1.0.1"
+    indent-string "^4.0.0"
+    strip-indent "^3.0.0"
 
-reduce-function-call@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/reduce-function-call/-/reduce-function-call-1.0.2.tgz#5a200bf92e0e37751752fe45b0ab330fd4b6be99"
-  integrity sha1-WiAL+S4ON3UXUv5FsKszD9S2vpk=
+regenerate-unicode-properties@^8.2.0:
+  version "8.2.0"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
+  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
   dependencies:
-    balanced-match "^0.4.2"
+    regenerate "^1.4.0"
 
-regenerate@^1.2.1:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
-  integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
+regenerate@^1.4.0:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.1.tgz#cad92ad8e6b591773485fbe05a485caf4f457e6f"
+  integrity sha512-j2+C8+NtXQgEKWk49MMP5P/u2GhnahTtVkRIHr5R5lVRlbKvmQ+oS+A5aLKWp2ma5VkT8sh6v+v4hbH0YHR66A==
 
-regenerator-runtime@^0.11.0:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
-  integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
+regenerator-runtime@^0.13.4:
+  version "0.13.7"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
+  integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
 
-regenerator-transform@^0.10.0:
-  version "0.10.1"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.10.1.tgz#1e4996837231da8b7f3cf4114d71b5691a0680dd"
-  integrity sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==
+regenerator-transform@^0.14.2:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
+  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
   dependencies:
-    babel-runtime "^6.18.0"
-    babel-types "^6.19.0"
-    private "^0.1.6"
+    "@babel/runtime" "^7.8.4"
 
 regex-not@^1.0.0, regex-not@^1.0.2:
   version "1.0.2"
@@ -4711,41 +4634,86 @@ regex-not@^1.0.0, regex-not@^1.0.2:
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
-regexpp@^1.0.1:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-1.1.0.tgz#0e3516dd0b7904f413d2d4193dce4618c3a689ab"
-  integrity sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==
-
-regexpu-core@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b"
-  integrity sha1-hqdj9Y7k18L2sQLkdkBQ3n7ZDGs=
-  dependencies:
-    regenerate "^1.2.1"
-    regjsgen "^0.2.0"
-    regjsparser "^0.1.4"
-
-regexpu-core@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-2.0.0.tgz#49d038837b8dcf8bfa5b9a42139938e6ea2ae240"
-  integrity sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=
-  dependencies:
-    regenerate "^1.2.1"
-    regjsgen "^0.2.0"
-    regjsparser "^0.1.4"
-
-regjsgen@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.2.0.tgz#6c016adeac554f75823fe37ac05b92d5a4edb1f7"
-  integrity sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=
+regexpp@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.1.0.tgz#206d0ad0a5648cffbdb8ae46438f3dc51c9f78e2"
+  integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
+
+regexpu-core@^4.7.0:
+  version "4.7.1"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6"
+  integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==
+  dependencies:
+    regenerate "^1.4.0"
+    regenerate-unicode-properties "^8.2.0"
+    regjsgen "^0.5.1"
+    regjsparser "^0.6.4"
+    unicode-match-property-ecmascript "^1.0.4"
+    unicode-match-property-value-ecmascript "^1.2.0"
+
+regjsgen@^0.5.1:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
+  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
 
-regjsparser@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.1.5.tgz#7ee8f84dc6fa792d3fd0ae228d24bd949ead205c"
-  integrity sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=
+regjsparser@^0.6.4:
+  version "0.6.4"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.4.tgz#a769f8684308401a66e9b529d2436ff4d0666272"
+  integrity sha512-64O87/dPDgfk8/RQqC4gkZoGyyWFIEUTTh80CU6CWuK5vkCGyekIx+oKcEIYtP/RAxSQltCZHCNu/mdd7fqlJw==
   dependencies:
     jsesc "~0.5.0"
 
+remark-parse@^8.0.0:
+  version "8.0.3"
+  resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-8.0.3.tgz#9c62aa3b35b79a486454c690472906075f40c7e1"
+  integrity sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q==
+  dependencies:
+    ccount "^1.0.0"
+    collapse-white-space "^1.0.2"
+    is-alphabetical "^1.0.0"
+    is-decimal "^1.0.0"
+    is-whitespace-character "^1.0.0"
+    is-word-character "^1.0.0"
+    markdown-escapes "^1.0.0"
+    parse-entities "^2.0.0"
+    repeat-string "^1.5.4"
+    state-toggle "^1.0.0"
+    trim "0.0.1"
+    trim-trailing-lines "^1.0.0"
+    unherit "^1.0.4"
+    unist-util-remove-position "^2.0.0"
+    vfile-location "^3.0.0"
+    xtend "^4.0.1"
+
+remark-stringify@^8.0.0:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-8.1.1.tgz#e2a9dc7a7bf44e46a155ec78996db896780d8ce5"
+  integrity sha512-q4EyPZT3PcA3Eq7vPpT6bIdokXzFGp9i85igjmhRyXWmPs0Y6/d2FYwUNotKAWyLch7g0ASZJn/KHHcHZQ163A==
+  dependencies:
+    ccount "^1.0.0"
+    is-alphanumeric "^1.0.0"
+    is-decimal "^1.0.0"
+    is-whitespace-character "^1.0.0"
+    longest-streak "^2.0.1"
+    markdown-escapes "^1.0.0"
+    markdown-table "^2.0.0"
+    mdast-util-compact "^2.0.0"
+    parse-entities "^2.0.0"
+    repeat-string "^1.5.4"
+    state-toggle "^1.0.0"
+    stringify-entities "^3.0.0"
+    unherit "^1.0.4"
+    xtend "^4.0.1"
+
+remark@^12.0.0:
+  version "12.0.1"
+  resolved "https://registry.yarnpkg.com/remark/-/remark-12.0.1.tgz#f1ddf68db7be71ca2bad0a33cd3678b86b9c709f"
+  integrity sha512-gS7HDonkdIaHmmP/+shCPejCEEW+liMp/t/QwmF0Xt47Rpuhl32lLtDV1uKWvGoq+kxr5jSgg5oAIpGuyULjUw==
+  dependencies:
+    remark-parse "^8.0.0"
+    remark-stringify "^8.0.0"
+    unified "^9.0.0"
+
 remove-trailing-separator@^1.0.1:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@@ -4756,114 +4724,99 @@ repeat-element@^1.1.2:
   resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
   integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==
 
-repeat-string@^1.5.2, repeat-string@^1.6.1:
+repeat-string@^1.0.0, repeat-string@^1.5.4, repeat-string@^1.6.1:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
   integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
 
-repeating@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
-  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
-  dependencies:
-    is-finite "^1.0.0"
-
-request@^2.87.0, request@^2.88.0:
-  version "2.88.0"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
-  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.0"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.4.3"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
+replace-ext@1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
+  integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=
 
 require-directory@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
   integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
 
-require-main-filename@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"
-  integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=
+require-main-filename@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
+  integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
 
-require-uncached@^1.0.2, require-uncached@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/require-uncached/-/require-uncached-1.0.3.tgz#4e0d56d6c9662fd31e43011c4b95aa49955421d3"
-  integrity sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=
+resolve-cwd@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"
+  integrity sha1-AKn3OHVW4nA46uIyyqNypqWbZlo=
   dependencies:
-    caller-path "^0.1.0"
-    resolve-from "^1.0.0"
+    resolve-from "^3.0.0"
 
-resolve-from@^1.0.0:
+resolve-dir@^1.0.0, resolve-dir@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226"
-  integrity sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=
+  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
+  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
+  dependencies:
+    expand-tilde "^2.0.0"
+    global-modules "^1.0.0"
+
+resolve-from@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748"
+  integrity sha1-six699nWiBvItuZTM17rywoYh0g=
+
+resolve-from@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
+  integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
+
+resolve-from@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69"
+  integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
 
 resolve-url@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
   integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
 
-resolve@^1.10.0, resolve@^1.5.0:
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.0.tgz#4014870ba296176b86343d50b60f3b50609ce232"
-  integrity sha512-WL2pBDjqT6pGUNSUzMw00o4T7If+z4H2x3Gz893WoUQ5KW8Vr9txp00ykiP16VBaZF5+j/OcXJHZ9+PCvdiDKw==
+resolve@^1.10.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.3.2:
+  version "1.17.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.17.0.tgz#b25941b54968231cc2d1bb76a79cb7f2c0bf8444"
+  integrity sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==
   dependencies:
     path-parse "^1.0.6"
 
-restore-cursor@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
-  integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=
-  dependencies:
-    exit-hook "^1.0.0"
-    onetime "^1.0.0"
-
-restore-cursor@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
-  integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368=
-  dependencies:
-    onetime "^2.0.0"
-    signal-exit "^3.0.2"
-
 ret@~0.1.10:
   version "0.1.15"
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
   integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
 
-right-align@^0.1.1:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/right-align/-/right-align-0.1.3.tgz#61339b722fe6a3515689210d24e14c96148613ef"
-  integrity sha1-YTObci/mo1FWiSENJOFMlhSGE+8=
-  dependencies:
-    align-text "^0.1.1"
+reusify@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
+  integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
 
-rimraf@2, rimraf@^2.6.1, rimraf@~2.6.2:
+rimraf@2.6.3:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
   integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
   dependencies:
     glob "^7.1.3"
 
+rimraf@^2.5.4, rimraf@^2.6.3:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
+  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
 ripemd160@^2.0.0, ripemd160@^2.0.1:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c"
@@ -4872,38 +4825,24 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
     hash-base "^3.0.0"
     inherits "^2.0.1"
 
-run-async@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/run-async/-/run-async-0.1.0.tgz#c8ad4a5e110661e402a7d21b530e009f25f8e389"
-  integrity sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=
-  dependencies:
-    once "^1.3.0"
-
-run-async@^2.2.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0"
-  integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA=
-  dependencies:
-    is-promise "^2.1.0"
+run-parallel@^1.1.9:
+  version "1.1.9"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679"
+  integrity sha512-DEqnSRTDw/Tc3FXf49zedI638Z9onwUotBMiUFKmrO2sdFKIbXamXGQ3Axd4qgphxKB4kw/qP1w5kTxnfU1B9Q==
 
-rx-lite-aggregates@^4.0.8:
-  version "4.0.8"
-  resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
-  integrity sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=
+run-queue@^1.0.0, run-queue@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47"
+  integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=
   dependencies:
-    rx-lite "*"
+    aproba "^1.1.1"
 
-rx-lite@*, rx-lite@^4.0.8:
-  version "4.0.8"
-  resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
-  integrity sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
-rx-lite@^3.1.2:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102"
-  integrity sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=
-
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
@@ -4915,63 +4854,28 @@ safe-regex@^1.1.0:
   dependencies:
     ret "~0.1.10"
 
-"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+safer-buffer@^2.1.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-sass-graph@^2.2.4:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49"
-  integrity sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=
-  dependencies:
-    glob "^7.0.0"
-    lodash "^4.0.0"
-    scss-tokenizer "^0.2.3"
-    yargs "^7.0.0"
-
-sass-lint@^1.12.1:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/sass-lint/-/sass-lint-1.13.1.tgz#5fd2b2792e9215272335eb0f0dc607f61e8acc8f"
-  integrity sha512-DSyah8/MyjzW2BWYmQWekYEKir44BpLqrCFsgs9iaWiVTcwZfwXHF586hh3D1n+/9ihUNMfd8iHAyb9KkGgs7Q==
-  dependencies:
-    commander "^2.8.1"
-    eslint "^2.7.0"
-    front-matter "2.1.2"
-    fs-extra "^3.0.1"
-    glob "^7.0.0"
-    globule "^1.0.0"
-    gonzales-pe-sl "^4.2.3"
-    js-yaml "^3.5.4"
-    known-css-properties "^0.3.0"
-    lodash.capitalize "^4.1.0"
-    lodash.kebabcase "^4.0.0"
-    merge "^1.2.0"
-    path-is-absolute "^1.0.0"
-    util "^0.10.3"
-
-sass-loader@^6.0.6:
-  version "6.0.7"
-  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-6.0.7.tgz#dd2fdb3e7eeff4a53f35ba6ac408715488353d00"
-  integrity sha512-JoiyD00Yo1o61OJsoP2s2kb19L1/Y2p3QFcCdWdF6oomBGKVYuZyqHWemRBfQ2uGYsk+CH3eCguXNfpjzlcpaA==
+sass-loader@^10.0.2:
+  version "10.0.2"
+  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-10.0.2.tgz#c7b73010848b264792dd45372eea0b87cba4401e"
+  integrity sha512-wV6NDUVB8/iEYMalV/+139+vl2LaRFlZGEd5/xmdcdzQcgmis+npyco6NsDTVOlNA3y2NV9Gcz+vHyFMIT+ffg==
   dependencies:
-    clone-deep "^2.0.1"
-    loader-utils "^1.0.1"
-    lodash.tail "^4.1.1"
-    neo-async "^2.5.0"
-    pify "^3.0.0"
-
-sax@^1.2.4, sax@~1.2.1:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
-  integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
+    klona "^2.0.3"
+    loader-utils "^2.0.0"
+    neo-async "^2.6.2"
+    schema-utils "^2.7.1"
+    semver "^7.3.2"
 
-schema-utils@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.3.0.tgz#f5877222ce3e931edae039f17eb3716e7137f8cf"
-  integrity sha1-9YdyIs4+kx7a4DnxfrNxbnE3+M8=
+sass@^1.26.11:
+  version "1.26.11"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.26.11.tgz#0f22cc4ab2ba27dad1d4ca30837beb350b709847"
+  integrity sha512-W1l/+vjGjIamsJ6OnTe0K37U2DBO/dgsv2Z4c89XQ8ZOO6l/VwkqwLSqoYzJeJs6CLuGSTRWc91GbQFL3lvrvw==
   dependencies:
-    ajv "^5.0.0"
+    chokidar ">=2.0.0 <4.0.0"
 
 schema-utils@^0.4.5:
   version "0.4.7"
@@ -4981,43 +4885,67 @@ schema-utils@^0.4.5:
     ajv "^6.1.0"
     ajv-keywords "^3.1.0"
 
-scss-tokenizer@^0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz#8eb06db9a9723333824d3f5530641149847ce5d1"
-  integrity sha1-jrBtualyMzOCTT9VMGQRSYR85dE=
+schema-utils@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-1.0.0.tgz#0b79a93204d7b600d4b2850d1f66c2a34951c770"
+  integrity sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==
+  dependencies:
+    ajv "^6.1.0"
+    ajv-errors "^1.0.0"
+    ajv-keywords "^3.1.0"
+
+schema-utils@^2.6.5, schema-utils@^2.7.1:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7"
+  integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg==
   dependencies:
-    js-base64 "^2.1.8"
-    source-map "^0.4.2"
+    "@types/json-schema" "^7.0.5"
+    ajv "^6.12.4"
+    ajv-keywords "^3.5.2"
 
-"semver@2 || 3 || 4 || 5", semver@^5.3.0:
-  version "5.7.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b"
-  integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==
+"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
+  integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
 
-semver@~5.3.0:
-  version "5.3.0"
-  resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
-  integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8=
+semver@7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
+  integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
 
-set-blocking@^2.0.0, set-blocking@~2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
-  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+semver@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
+  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-set-value@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-0.4.3.tgz#7db08f9d3d22dc7f78e53af3c3bf4666ecdfccf1"
-  integrity sha1-fbCPnT0i3H945Trzw79GZuzfzPE=
+semver@^7.2.1, semver@^7.3.2:
+  version "7.3.2"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938"
+  integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==
+
+serialize-javascript@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
+  integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
   dependencies:
-    extend-shallow "^2.0.1"
-    is-extendable "^0.1.1"
-    is-plain-object "^2.0.1"
-    to-object-path "^0.3.0"
+    randombytes "^2.1.0"
+
+serialize-javascript@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4"
+  integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==
+  dependencies:
+    randombytes "^2.1.0"
 
-set-value@^2.0.0:
+set-blocking@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.0.tgz#71ae4a88f0feefbbf52d1ea604f3fb315ebb6274"
-  integrity sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==
+  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+
+set-value@^2.0.0, set-value@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
+  integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==
   dependencies:
     extend-shallow "^2.0.1"
     is-extendable "^0.1.1"
@@ -5037,15 +4965,6 @@ sha.js@^2.4.0, sha.js@^2.4.8:
     inherits "^2.0.1"
     safe-buffer "^5.0.1"
 
-shallow-clone@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-1.0.0.tgz#4480cd06e882ef68b2ad88a3ea54832e2c48b571"
-  integrity sha512-oeXreoKR/SyNJtRJMAKPDSvd28OqEwG4eR/xc856cRGBII7gX9lvAqDxusPm0846z/w/hWYjI1NpKwJ00NHzRA==
-  dependencies:
-    is-extendable "^0.1.1"
-    kind-of "^5.0.0"
-    mixin-object "^2.0.1"
-
 shebang-command@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -5053,38 +4972,51 @@ shebang-command@^1.2.0:
   dependencies:
     shebang-regex "^1.0.0"
 
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.0.0"
+
 shebang-regex@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
   integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
 
-shelljs@^0.6.0:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.6.1.tgz#ec6211bed1920442088fe0f70b2837232ed2c8a8"
-  integrity sha1-7GIRvtGSBEIIj+D3Cyg3Iy7SyKg=
-
-signal-exit@^3.0.0, signal-exit@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
-  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
-slash@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
-  integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
+signal-exit@^3.0.2:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
+  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
 
-slice-ansi@0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
-  integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=
+slash@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
+  integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
 
-slice-ansi@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-1.0.0.tgz#044f1a49d8842ff307aad6b505ed178bd950134d"
-  integrity sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==
+slice-ansi@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636"
+  integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==
   dependencies:
+    ansi-styles "^3.2.0"
+    astral-regex "^1.0.0"
     is-fullwidth-code-point "^2.0.0"
 
+slice-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
+  integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
+  dependencies:
+    ansi-styles "^4.0.0"
+    astral-regex "^2.0.0"
+    is-fullwidth-code-point "^3.0.0"
+
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -5128,70 +5060,69 @@ source-list-map@^2.0.0:
   integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
 
 source-map-resolve@^0.5.0:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259"
-  integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==
+  version "0.5.3"
+  resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
+  integrity sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==
   dependencies:
-    atob "^2.1.1"
+    atob "^2.1.2"
     decode-uri-component "^0.2.0"
     resolve-url "^0.2.1"
     source-map-url "^0.4.0"
     urix "^0.1.0"
 
-source-map-support@^0.4.15:
-  version "0.4.18"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.4.18.tgz#0286a6de8be42641338594e97ccea75f0a2c585f"
-  integrity sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==
+source-map-support@~0.5.12:
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
+  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
   dependencies:
-    source-map "^0.5.6"
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
 
 source-map-url@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
   integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=
 
-source-map@^0.4.2:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.4.4.tgz#eba4f5da9c0dc999de68032d8b4f76173652036b"
-  integrity sha1-66T12pwNyZneaAMti092FzZSA2s=
-  dependencies:
-    amdefine ">=0.0.4"
-
-source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1:
+source-map@^0.5.0, source-map@^0.5.6:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
 
-source-map@^0.6.1, source-map@~0.6.1:
+source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
 spdx-correct@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4"
-  integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
+  integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
   dependencies:
     spdx-expression-parse "^3.0.0"
     spdx-license-ids "^3.0.0"
 
 spdx-exceptions@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977"
-  integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
+  integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
 
 spdx-expression-parse@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
-  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz#cf70f50482eefdc98e3ce0a6833e4a53ceeba679"
+  integrity sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==
   dependencies:
     spdx-exceptions "^2.1.0"
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz#75ecd1a88de8c184ef015eafb51b5b48bfd11bb1"
-  integrity sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA==
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.6.tgz#c80757383c28abf7296744998cbc106ae8b854ce"
+  integrity sha512-+orQK83kyMva3WyPf59k1+Y525csj5JejicWut55zeTWANuN17qSiSLUXWtzHeNWORSvT7GLDJ/E/XiIWoXBTw==
+
+specificity@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019"
+  integrity sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==
 
 split-string@^3.0.1, split-string@^3.0.2:
   version "3.1.0"
@@ -5205,20 +5136,24 @@ sprintf-js@~1.0.2:
   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
   integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
 
-sshpk@^1.7.0:
-  version "1.16.1"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
-  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
-  dependencies:
-    asn1 "~0.2.3"
-    assert-plus "^1.0.0"
-    bcrypt-pbkdf "^1.0.0"
-    dashdash "^1.12.0"
-    ecc-jsbn "~0.1.1"
-    getpass "^0.1.1"
-    jsbn "~0.1.0"
-    safer-buffer "^2.0.2"
-    tweetnacl "~0.14.0"
+ssri@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
+  integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==
+  dependencies:
+    figgy-pudding "^3.5.1"
+
+ssri@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.0.tgz#79ca74e21f8ceaeddfcb4b90143c458b8d988808"
+  integrity sha512-aq/pz989nxVYwn16Tsbj1TqFpD5LLrQxHf5zaHuieFV+R0Bbr4y8qUsOA45hXT/N4/9UNXTarBjnjVmjSOVaAA==
+  dependencies:
+    minipass "^3.1.1"
+
+state-toggle@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe"
+  integrity sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ==
 
 static-extend@^0.1.1:
   version "0.1.2"
@@ -5228,13 +5163,6 @@ static-extend@^0.1.1:
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
-stdout-stream@^1.4.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/stdout-stream/-/stdout-stream-1.4.1.tgz#5ac174cdd5cd726104aa0c0b2bd83815d8d535de"
-  integrity sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==
-  dependencies:
-    readable-stream "^2.0.1"
-
 stream-browserify@^2.0.1:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"
@@ -5243,6 +5171,14 @@ stream-browserify@^2.0.1:
     inherits "~2.0.1"
     readable-stream "^2.0.2"
 
+stream-each@^1.1.0:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae"
+  integrity sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==
+  dependencies:
+    end-of-stream "^1.1.0"
+    stream-shift "^1.0.0"
+
 stream-http@^2.7.2:
   version "2.8.3"
   resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-2.8.3.tgz#b2d242469288a5a27ec4fe8933acf623de6514fc"
@@ -5254,34 +5190,56 @@ stream-http@^2.7.2:
     to-arraybuffer "^1.0.0"
     xtend "^4.0.0"
 
+stream-shift@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
+  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
+
 strict-uri-encode@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
   integrity sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=
 
-string-width@^1.0.1, string-width@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
-  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+string-width@^3.0.0, string-width@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
+  integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
   dependencies:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    strip-ansi "^3.0.0"
+    emoji-regex "^7.0.1"
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^5.1.0"
 
-"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
+string-width@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5"
+  integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==
   dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.0"
 
-string_decoder@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d"
-  integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==
+string.prototype.trimend@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz#85812a6b847ac002270f5808146064c995fb6913"
+  integrity sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==
   dependencies:
-    safe-buffer "~5.1.0"
+    define-properties "^1.1.3"
+    es-abstract "^1.17.5"
+
+string.prototype.trimstart@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz#14af6d9f34b053f7cfc89b72f8f2ee14b9039a54"
+  integrity sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.5"
+
+string_decoder@^1.0.0, string_decoder@^1.1.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+  integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+  dependencies:
+    safe-buffer "~5.2.0"
 
 string_decoder@~1.1.1:
   version "1.1.1"
@@ -5290,185 +5248,277 @@ string_decoder@~1.1.1:
   dependencies:
     safe-buffer "~5.1.0"
 
-strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+stringify-entities@^3.0.0:
   version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
-  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  resolved "https://registry.yarnpkg.com/stringify-entities/-/stringify-entities-3.0.1.tgz#32154b91286ab0869ab2c07696223bd23b6dbfc0"
+  integrity sha512-Lsk3ISA2++eJYqBMPKcr/8eby1I6L0gP0NlxF8Zja6c05yr/yCYyb2c9PwXjd08Ib3If1vn1rbs1H5ZtVuOfvQ==
   dependencies:
-    ansi-regex "^2.0.0"
+    character-entities-html4 "^1.0.0"
+    character-entities-legacy "^1.0.0"
+    is-alphanumerical "^1.0.0"
+    is-decimal "^1.0.2"
+    is-hexadecimal "^1.0.0"
 
-strip-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
+  integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
   dependencies:
-    ansi-regex "^3.0.0"
+    ansi-regex "^4.1.0"
 
-strip-bom@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
-  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
+strip-ansi@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
+  integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
   dependencies:
-    is-utf8 "^0.2.0"
+    ansi-regex "^5.0.0"
 
 strip-bom@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
   integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
 
-strip-eof@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
-  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
-
-strip-indent@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
-  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
+strip-indent@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
+  integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
   dependencies:
-    get-stdin "^4.0.1"
-
-strip-json-comments@~1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91"
-  integrity sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E=
+    min-indent "^1.0.0"
 
-strip-json-comments@~2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
-  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
+  integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
-style-loader@^0.19.1:
-  version "0.19.1"
-  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-0.19.1.tgz#591ffc80bcefe268b77c5d9ebc0505d772619f85"
-  integrity sha512-IRE+ijgojrygQi3rsqT0U4dd+UcPCqcVvauZpCnQrGAlEe+FUIyrK93bUDScamesjP08JlQNsFJU+KmPedP5Og==
-  dependencies:
-    loader-utils "^1.0.2"
-    schema-utils "^0.3.0"
+style-search@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/style-search/-/style-search-0.1.0.tgz#7958c793e47e32e07d2b5cafe5c0bf8e12e77902"
+  integrity sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI=
 
-supports-color@^2.0.0:
+stylelint-config-recommended@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/stylelint-config-recommended/-/stylelint-config-recommended-3.0.0.tgz#e0e547434016c5539fe2650afd58049a2fd1d657"
+  integrity sha512-F6yTRuc06xr1h5Qw/ykb2LuFynJ2IxkKfCMf+1xqPffkxh0S09Zc902XCffcsw/XMFq/OzQ1w54fLIDtmRNHnQ==
+
+stylelint-config-standard@^20.0.0:
+  version "20.0.0"
+  resolved "https://registry.yarnpkg.com/stylelint-config-standard/-/stylelint-config-standard-20.0.0.tgz#06135090c9e064befee3d594289f50e295b5e20d"
+  integrity sha512-IB2iFdzOTA/zS4jSVav6z+wGtin08qfj+YyExHB3LF9lnouQht//YyB0KZq9gGz5HNPkddHOzcY8HsUey6ZUlA==
+  dependencies:
+    stylelint-config-recommended "^3.0.0"
+
+stylelint-scss@^3.18.0:
+  version "3.18.0"
+  resolved "https://registry.yarnpkg.com/stylelint-scss/-/stylelint-scss-3.18.0.tgz#8f06371c223909bf3f62e839548af1badeed31e9"
+  integrity sha512-LD7+hv/6/ApNGt7+nR/50ft7cezKP2HM5rI8avIdGaUWre3xlHfV4jKO/DRZhscfuN+Ewy9FMhcTq0CcS0C/SA==
+  dependencies:
+    lodash "^4.17.15"
+    postcss-media-query-parser "^0.2.3"
+    postcss-resolve-nested-selector "^0.1.1"
+    postcss-selector-parser "^6.0.2"
+    postcss-value-parser "^4.1.0"
+
+stylelint@^13.7.1:
+  version "13.7.1"
+  resolved "https://registry.yarnpkg.com/stylelint/-/stylelint-13.7.1.tgz#bee97ee78d778a3f1dbe3f7397b76414973e263e"
+  integrity sha512-qzqazcyRxrSRdmFuO0/SZOJ+LyCxYy0pwcvaOBBnl8/2VfHSMrtNIE+AnyJoyq6uKb+mt+hlgmVrvVi6G6XHfQ==
+  dependencies:
+    "@stylelint/postcss-css-in-js" "^0.37.2"
+    "@stylelint/postcss-markdown" "^0.36.1"
+    autoprefixer "^9.8.6"
+    balanced-match "^1.0.0"
+    chalk "^4.1.0"
+    cosmiconfig "^7.0.0"
+    debug "^4.1.1"
+    execall "^2.0.0"
+    fast-glob "^3.2.4"
+    fastest-levenshtein "^1.0.12"
+    file-entry-cache "^5.0.1"
+    get-stdin "^8.0.0"
+    global-modules "^2.0.0"
+    globby "^11.0.1"
+    globjoin "^0.1.4"
+    html-tags "^3.1.0"
+    ignore "^5.1.8"
+    import-lazy "^4.0.0"
+    imurmurhash "^0.1.4"
+    known-css-properties "^0.19.0"
+    lodash "^4.17.20"
+    log-symbols "^4.0.0"
+    mathml-tag-names "^2.1.3"
+    meow "^7.1.1"
+    micromatch "^4.0.2"
+    normalize-selector "^0.2.0"
+    postcss "^7.0.32"
+    postcss-html "^0.36.0"
+    postcss-less "^3.1.4"
+    postcss-media-query-parser "^0.2.3"
+    postcss-resolve-nested-selector "^0.1.1"
+    postcss-safe-parser "^4.0.2"
+    postcss-sass "^0.4.4"
+    postcss-scss "^2.1.1"
+    postcss-selector-parser "^6.0.2"
+    postcss-syntax "^0.36.2"
+    postcss-value-parser "^4.1.0"
+    resolve-from "^5.0.0"
+    slash "^3.0.0"
+    specificity "^0.4.1"
+    string-width "^4.2.0"
+    strip-ansi "^6.0.0"
+    style-search "^0.1.0"
+    sugarss "^2.0.0"
+    svg-tags "^1.0.0"
+    table "^6.0.1"
+    v8-compile-cache "^2.1.1"
+    write-file-atomic "^3.0.3"
+
+sugarss@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
-  integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
-
-supports-color@^3.2.3:
-  version "3.2.3"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-3.2.3.tgz#65ac0504b3954171d8a64946b2ae3cbb8a5f54f6"
-  integrity sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=
-  dependencies:
-    has-flag "^1.0.0"
-
-supports-color@^4.2.1:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b"
-  integrity sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=
+  resolved "https://registry.yarnpkg.com/sugarss/-/sugarss-2.0.0.tgz#ddd76e0124b297d40bf3cca31c8b22ecb43bc61d"
+  integrity sha512-WfxjozUk0UVA4jm+U1d736AUpzSrNsQcIbyOkoE364GrtWmIrFdk5lksEupgWMD4VaT/0kVx1dobpiDumSgmJQ==
   dependencies:
-    has-flag "^2.0.0"
+    postcss "^7.0.2"
 
-supports-color@^5.3.0, supports-color@^5.4.0:
+supports-color@^5.3.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
   integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
   dependencies:
     has-flag "^3.0.0"
 
-svgo@^0.7.0:
-  version "0.7.2"
-  resolved "https://registry.yarnpkg.com/svgo/-/svgo-0.7.2.tgz#9f5772413952135c6fefbf40afe6a4faa88b4bb5"
-  integrity sha1-n1dyQTlSE1xv779Ar+ak+qiLS7U=
+supports-color@^6.1.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
+  integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
   dependencies:
-    coa "~1.0.1"
-    colors "~1.1.2"
-    csso "~2.3.1"
-    js-yaml "~3.7.0"
-    mkdirp "~0.5.1"
-    sax "~1.2.1"
-    whet.extend "~0.9.9"
+    has-flag "^3.0.0"
 
-table@4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/table/-/table-4.0.2.tgz#a33447375391e766ad34d3486e6e2aedc84d2e36"
-  integrity sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==
-  dependencies:
-    ajv "^5.2.3"
-    ajv-keywords "^2.1.0"
-    chalk "^2.1.0"
-    lodash "^4.17.4"
-    slice-ansi "1.0.0"
-    string-width "^2.1.1"
-
-table@^3.7.8:
-  version "3.8.3"
-  resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"
-  integrity sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=
-  dependencies:
-    ajv "^4.7.0"
-    ajv-keywords "^1.0.0"
-    chalk "^1.1.1"
-    lodash "^4.0.0"
-    slice-ansi "0.0.4"
-    string-width "^2.0.0"
-
-tapable@^0.2.7:
-  version "0.2.9"
-  resolved "https://registry.yarnpkg.com/tapable/-/tapable-0.2.9.tgz#af2d8bbc9b04f74ee17af2b4d9048f807acd18a8"
-  integrity sha512-2wsvQ+4GwBvLPLWsNfLCDYGsW6xb7aeC6utq2Qh0PFwgEy7K7dsma9Jsmb2zSQj7GvYAyUGSntLtsv++GmgL1A==
-
-tar@^2.0.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40"
-  integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==
+supports-color@^7.0.0, supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
-    block-stream "*"
-    fstream "^1.0.12"
-    inherits "2"
+    has-flag "^4.0.0"
 
-tar@^4:
-  version "4.4.8"
-  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d"
-  integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==
+svg-tags@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/svg-tags/-/svg-tags-1.0.0.tgz#58f71cee3bd519b59d4b2a843b6c7de64ac04764"
+  integrity sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=
+
+table@^5.2.3:
+  version "5.4.6"
+  resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"
+  integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug==
+  dependencies:
+    ajv "^6.10.2"
+    lodash "^4.17.14"
+    slice-ansi "^2.1.0"
+    string-width "^3.0.0"
+
+table@^6.0.1:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/table/-/table-6.0.3.tgz#e5b8a834e37e27ad06de2e0fda42b55cfd8a0123"
+  integrity sha512-8321ZMcf1B9HvVX/btKv8mMZahCjn2aYrDlpqHaBFCfnox64edeH9kEid0vTLTRR8gWR2A20aDgeuTTea4sVtw==
+  dependencies:
+    ajv "^6.12.4"
+    lodash "^4.17.20"
+    slice-ansi "^4.0.0"
+    string-width "^4.2.0"
+
+tapable@^1.0.0, tapable@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
+  integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
+
+tar@^6.0.2:
+  version "6.0.5"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f"
+  integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==
+  dependencies:
+    chownr "^2.0.0"
+    fs-minipass "^2.0.0"
+    minipass "^3.0.0"
+    minizlib "^2.1.1"
+    mkdirp "^1.0.3"
+    yallist "^4.0.0"
+
+terser-webpack-plugin@^1.4.3:
+  version "1.4.5"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b"
+  integrity sha512-04Rfe496lN8EYruwi6oPQkG0vo8C+HT49X687FZnpPF0qMAIHONI6HEXYPKDOE8e5HjXTyKfqRd/agHtH0kOtw==
+  dependencies:
+    cacache "^12.0.2"
+    find-cache-dir "^2.1.0"
+    is-wsl "^1.1.0"
+    schema-utils "^1.0.0"
+    serialize-javascript "^4.0.0"
+    source-map "^0.6.1"
+    terser "^4.1.2"
+    webpack-sources "^1.4.0"
+    worker-farm "^1.7.0"
+
+terser-webpack-plugin@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-4.2.2.tgz#d86200c700053bba637913fe4310ba1bdeb5568e"
+  integrity sha512-3qAQpykRTD5DReLu5/cwpsg7EZFzP3Q0Hp2XUWJUw2mpq2jfgOKTZr8IZKKnNieRVVo1UauROTdhbQJZveGKtQ==
+  dependencies:
+    cacache "^15.0.5"
+    find-cache-dir "^3.3.1"
+    jest-worker "^26.3.0"
+    p-limit "^3.0.2"
+    schema-utils "^2.7.1"
+    serialize-javascript "^5.0.1"
+    source-map "^0.6.1"
+    terser "^5.3.2"
+    webpack-sources "^1.4.3"
+
+terser@^4.1.2:
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
   dependencies:
-    chownr "^1.1.1"
-    fs-minipass "^1.2.5"
-    minipass "^2.3.4"
-    minizlib "^1.1.1"
-    mkdirp "^0.5.0"
-    safe-buffer "^5.1.2"
-    yallist "^3.0.2"
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
+
+terser@^5.3.2:
+  version "5.3.2"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.3.2.tgz#f4bea90eb92945b2a028ceef79181b9bb586e7af"
+  integrity sha512-H67sydwBz5jCUA32ZRL319ULu+Su1cAoZnnc+lXnenGRYWyLE3Scgkt8mNoAsMx0h5kdo758zdoS0LG9rYZXDQ==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
 
-text-table@~0.2.0:
+text-table@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
   integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
 
-through@^2.3.6:
-  version "2.3.8"
-  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
-  integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
+through2@^2.0.0:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
+  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
+  dependencies:
+    readable-stream "~2.3.6"
+    xtend "~4.0.1"
 
 timers-browserify@^2.0.4:
-  version "2.0.10"
-  resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.10.tgz#1d28e3d2aadf1d5a5996c4e9f95601cd053480ae"
-  integrity sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg==
+  version "2.0.11"
+  resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.11.tgz#800b1f3eee272e5bc53ee465a04d0e804c31211f"
+  integrity sha512-60aV6sgJ5YEbzUdn9c8kYGIqOubPoUdqQCul3SBAsRCZ40s6Y5cMcrW4dt3/k/EsbLVJNl9n6Vz3fTc+k2GeKQ==
   dependencies:
     setimmediate "^1.0.4"
 
-tmp@^0.0.33:
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
-  integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
-  dependencies:
-    os-tmpdir "~1.0.2"
-
 to-arraybuffer@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
   integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=
 
-to-fast-properties@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
-  integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=
+to-fast-properties@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
+  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
 
 to-object-path@^0.3.0:
   version "0.3.0"
@@ -5485,6 +5535,13 @@ to-regex-range@^2.1.0:
     is-number "^3.0.0"
     repeat-string "^1.6.1"
 
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
 to-regex@^3.0.1, to-regex@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce"
@@ -5495,108 +5552,194 @@ to-regex@^3.0.1, to-regex@^3.0.2:
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
-tough-cookie@~2.4.3:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
-  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
-  dependencies:
-    psl "^1.1.24"
-    punycode "^1.4.1"
+trim-newlines@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.0.tgz#79726304a6a898aa8373427298d54c2ee8b1cb30"
+  integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==
 
-trim-newlines@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
-  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
+trim-trailing-lines@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.3.tgz#7f0739881ff76657b7776e10874128004b625a94"
+  integrity sha512-4ku0mmjXifQcTVfYDfR5lpgV7zVqPg6zV9rdZmwOPqq0+Zq19xDqEgagqVbc4pOOShbncuAOIs59R3+3gcF3ZA==
 
-trim-right@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
-  integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
+trim@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.1.tgz#5858547f6b290757ee95cccc666fb50084c460dd"
+  integrity sha1-WFhUf2spB1fulczMZm+1AITEYN0=
 
-"true-case-path@^1.0.2":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/true-case-path/-/true-case-path-1.0.3.tgz#f813b5a8c86b40da59606722b144e3225799f47d"
-  integrity sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==
+trough@^1.0.0:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406"
+  integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==
+
+tsconfig-paths@^3.9.0:
+  version "3.9.0"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz#098547a6c4448807e8fcb8eae081064ee9a3c90b"
+  integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==
   dependencies:
-    glob "^7.1.2"
+    "@types/json5" "^0.0.29"
+    json5 "^1.0.1"
+    minimist "^1.2.0"
+    strip-bom "^3.0.0"
+
+tslib@^1.9.0:
+  version "1.13.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
+  integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
 tty-browserify@0.0.0:
   version "0.0.0"
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
   integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=
 
-tunnel-agent@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
-  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+type-check@^0.4.0, type-check@~0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
+  integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
   dependencies:
-    safe-buffer "^5.0.1"
+    prelude-ls "^1.2.1"
 
-tweetnacl@^0.14.3, tweetnacl@~0.14.0:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
-  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+type-fest@^0.13.1:
+  version "0.13.1"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"
+  integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==
 
-type-check@~0.3.2:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"
-  integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=
+type-fest@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
+  integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
+
+type-fest@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
+  integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
+
+typedarray-to-buffer@^3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
+  integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
   dependencies:
-    prelude-ls "~1.1.2"
+    is-typedarray "^1.0.0"
 
 typedarray@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-uglify-js@^2.8.29:
-  version "2.8.29"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
-  integrity sha1-KcVzMUgFe7Th913zW3qcty5qWd0=
+unherit@^1.0.4:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22"
+  integrity sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ==
   dependencies:
-    source-map "~0.5.1"
-    yargs "~3.10.0"
-  optionalDependencies:
-    uglify-to-browserify "~1.0.0"
+    inherits "^2.0.0"
+    xtend "^4.0.0"
 
-uglify-to-browserify@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7"
-  integrity sha1-bgkk1r2mta/jSeOabWMoUKD4grc=
+unicode-canonical-property-names-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
+  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
 
-uglifyjs-webpack-plugin@^0.4.6:
-  version "0.4.6"
-  resolved "https://registry.yarnpkg.com/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz#b951f4abb6bd617e66f63eb891498e391763e309"
-  integrity sha1-uVH0q7a9YX5m9j64kUmOORdj4wk=
+unicode-match-property-ecmascript@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
+  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
   dependencies:
-    source-map "^0.5.6"
-    uglify-js "^2.8.29"
-    webpack-sources "^1.0.1"
+    unicode-canonical-property-names-ecmascript "^1.0.4"
+    unicode-property-aliases-ecmascript "^1.0.4"
+
+unicode-match-property-value-ecmascript@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
+  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
+
+unicode-property-aliases-ecmascript@^1.0.4:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
+  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
+
+unified@^9.0.0:
+  version "9.2.0"
+  resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8"
+  integrity sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg==
+  dependencies:
+    bail "^1.0.0"
+    extend "^3.0.0"
+    is-buffer "^2.0.0"
+    is-plain-obj "^2.0.0"
+    trough "^1.0.0"
+    vfile "^4.0.0"
 
 union-value@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.0.tgz#5c71c34cb5bad5dcebe3ea0cd08207ba5aa1aea4"
-  integrity sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
+  integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==
   dependencies:
     arr-union "^3.1.0"
     get-value "^2.0.6"
     is-extendable "^0.1.1"
-    set-value "^0.4.3"
+    set-value "^2.0.1"
 
 uniq@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"
   integrity sha1-sxxa6CVIRKOoKBVBzisEuGWnNP8=
 
-uniqs@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02"
-  integrity sha1-/+3ks2slKQaW5uFl1KWe25mOawI=
+unique-filename@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"
+  integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==
+  dependencies:
+    unique-slug "^2.0.0"
 
-universalify@^0.1.0:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
-  integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
+unique-slug@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c"
+  integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==
+  dependencies:
+    imurmurhash "^0.1.4"
+
+unist-util-find-all-after@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/unist-util-find-all-after/-/unist-util-find-all-after-3.0.1.tgz#95cc62f48812d879b4685a0512bf1b838da50e9a"
+  integrity sha512-0GICgc++sRJesLwEYDjFVJPJttBpVQaTNgc6Jw0Jhzvfs+jtKePEMu+uD+PqkRUrAvGQqwhpDwLGWo1PK8PDEw==
+  dependencies:
+    unist-util-is "^4.0.0"
+
+unist-util-is@^4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.0.2.tgz#c7d1341188aa9ce5b3cff538958de9895f14a5de"
+  integrity sha512-Ofx8uf6haexJwI1gxWMGg6I/dLnF2yE+KibhD3/diOqY2TinLcqHXCV6OI5gFVn3xQqDH+u0M625pfKwIwgBKQ==
+
+unist-util-remove-position@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz#5d19ca79fdba712301999b2b73553ca8f3b352cc"
+  integrity sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA==
+  dependencies:
+    unist-util-visit "^2.0.0"
+
+unist-util-stringify-position@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz#cce3bfa1cdf85ba7375d1d5b17bdc4cada9bd9da"
+  integrity sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==
+  dependencies:
+    "@types/unist" "^2.0.2"
+
+unist-util-visit-parents@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-3.1.0.tgz#4dd262fb9dcfe44f297d53e882fc6ff3421173d5"
+  integrity sha512-0g4wbluTF93npyPrp/ymd3tCDTMnP0yo2akFD2FIBAYXq/Sga3lwaU1D8OYKbtpioaI6CkDcQ6fsMnmtzt7htw==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    unist-util-is "^4.0.0"
+
+unist-util-visit@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c"
+  integrity sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    unist-util-is "^4.0.0"
+    unist-util-visit-parents "^3.0.0"
 
 unset-value@^1.0.0:
   version "1.0.0"
@@ -5607,14 +5750,14 @@ unset-value@^1.0.0:
     isobject "^3.0.0"
 
 upath@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068"
-  integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q==
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"
+  integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==
 
 uri-js@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
-  integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.0.tgz#aa714261de793e8a82347a7bcc9ce74e86f28602"
+  integrity sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==
   dependencies:
     punycode "^2.1.0"
 
@@ -5623,15 +5766,6 @@ urix@^0.1.0:
   resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
   integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
 
-url-loader@^0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-0.6.2.tgz#a007a7109620e9d988d14bce677a1decb9a993f7"
-  integrity sha512-h3qf9TNn53BpuXTTcpC+UehiRrl0Cv45Yr/xWayApjw6G8Bg2dGke7rIwDQ39piciWCWrC+WiqLjOh3SUp9n0Q==
-  dependencies:
-    loader-utils "^1.0.2"
-    mime "^1.4.1"
-    schema-utils "^0.3.0"
-
 url@^0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@@ -5645,14 +5779,7 @@ use@^3.1.0:
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
   integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
 
-user-home@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/user-home/-/user-home-2.0.0.tgz#9c70bfd8169bc1dcbf48604e0f04b8b49cde9e9f"
-  integrity sha1-nHC/2Babwdy/SGBODwS4tJzenp8=
-  dependencies:
-    os-homedir "^1.0.0"
-
-util-deprecate@~1.0.1:
+util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
@@ -5664,13 +5791,6 @@ util@0.10.3:
   dependencies:
     inherits "2.0.1"
 
-util@^0.10.3:
-  version "0.10.4"
-  resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901"
-  integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==
-  dependencies:
-    inherits "2.0.3"
-
 util@^0.11.0:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61"
@@ -5678,10 +5798,10 @@ util@^0.11.0:
   dependencies:
     inherits "2.0.3"
 
-uuid@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
-  integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
+v8-compile-cache@^2.0.3, v8-compile-cache@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"
+  integrity sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==
 
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
@@ -5691,214 +5811,222 @@ validate-npm-package-license@^3.0.1:
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
-vendors@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/vendors/-/vendors-1.0.3.tgz#a6467781abd366217c050f8202e7e50cc9eef8c0"
-  integrity sha512-fOi47nsJP5Wqefa43kyWSg80qF+Q3XA6MUkgi7Hp1HQaKDQW4cQrK2D0P7mmbFtsV1N89am55Yru/nyEwRubcw==
+vfile-location@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-3.1.0.tgz#81cd8a04b0ac935185f4fce16f270503fc2f692f"
+  integrity sha512-FCZ4AN9xMcjFIG1oGmZKo61PjwJHRVA+0/tPUP2ul4uIwjGGndIxavEMRpWn5p4xwm/ZsdXp9YNygf1ZyE4x8g==
 
-verror@1.10.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
-  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+vfile-message@^2.0.0:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a"
+  integrity sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==
   dependencies:
-    assert-plus "^1.0.0"
-    core-util-is "1.0.2"
-    extsprintf "^1.2.0"
+    "@types/unist" "^2.0.0"
+    unist-util-stringify-position "^2.0.0"
+
+vfile@^4.0.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.0.tgz#26c78ac92eb70816b01d4565e003b7e65a2a0e01"
+  integrity sha512-a/alcwCvtuc8OX92rqqo7PflxiCgXRFjdyoGVuYV+qbgCb0GgZJRvIgCD4+U/Kl1yhaRsaTwksF88xbPyGsgpw==
+  dependencies:
+    "@types/unist" "^2.0.0"
+    is-buffer "^2.0.0"
+    replace-ext "1.0.0"
+    unist-util-stringify-position "^2.0.0"
+    vfile-message "^2.0.0"
+
+vm-browserify@^1.0.1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
+  integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
 
-vm-browserify@0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-0.0.4.tgz#5d7ea45bbef9e4a6ff65f95438e0a87c357d5a73"
-  integrity sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=
+watchpack-chokidar2@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/watchpack-chokidar2/-/watchpack-chokidar2-2.0.0.tgz#9948a1866cbbd6cb824dea13a7ed691f6c8ddff0"
+  integrity sha512-9TyfOyN/zLUbA288wZ8IsMZ+6cbzvsNyEzSBp6e/zkifi6xxbl8SmQ/CxQq32k8NNqrdVEVUVSEf56L4rQ/ZxA==
   dependencies:
-    indexof "0.0.1"
+    chokidar "^2.1.8"
 
-watchpack@^1.4.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.6.0.tgz#4bc12c2ebe8aa277a71f1d3f14d685c7b446cd00"
-  integrity sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA==
+watchpack@^1.7.4:
+  version "1.7.4"
+  resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.4.tgz#6e9da53b3c80bb2d6508188f5b200410866cd30b"
+  integrity sha512-aWAgTW4MoSJzZPAicljkO1hsi1oKj/RRq/OJQh2PKI2UKL04c2Bs+MBOB+BBABHTXJpf9mCwHN7ANCvYsvY2sg==
   dependencies:
-    chokidar "^2.0.2"
     graceful-fs "^4.1.2"
     neo-async "^2.5.0"
-
-webpack-sources@^1.0.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.3.0.tgz#2a28dcb9f1f45fe960d8f1493252b5ee6530fa85"
-  integrity sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==
+  optionalDependencies:
+    chokidar "^3.4.1"
+    watchpack-chokidar2 "^2.0.0"
+
+webpack-cli@^3.3.12:
+  version "3.3.12"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.12.tgz#94e9ada081453cd0aa609c99e500012fd3ad2d4a"
+  integrity sha512-NVWBaz9k839ZH/sinurM+HcDvJOTXwSjYp1ku+5XKeOC03z8v5QitnK/x+lAxGXFyhdayoIf/GOpv85z3/xPag==
+  dependencies:
+    chalk "^2.4.2"
+    cross-spawn "^6.0.5"
+    enhanced-resolve "^4.1.1"
+    findup-sync "^3.0.0"
+    global-modules "^2.0.0"
+    import-local "^2.0.0"
+    interpret "^1.4.0"
+    loader-utils "^1.4.0"
+    supports-color "^6.1.0"
+    v8-compile-cache "^2.1.1"
+    yargs "^13.3.2"
+
+webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933"
+  integrity sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==
   dependencies:
     source-list-map "^2.0.0"
     source-map "~0.6.1"
 
-webpack@^3.10.0:
-  version "3.12.0"
-  resolved "https://registry.yarnpkg.com/webpack/-/webpack-3.12.0.tgz#3f9e34360370602fcf639e97939db486f4ec0d74"
-  integrity sha512-Sw7MdIIOv/nkzPzee4o0EdvCuPmxT98+vVpIvwtcwcF1Q4SDSNp92vwcKc4REe7NItH9f1S4ra9FuQ7yuYZ8bQ==
-  dependencies:
-    acorn "^5.0.0"
-    acorn-dynamic-import "^2.0.0"
-    ajv "^6.1.0"
-    ajv-keywords "^3.1.0"
-    async "^2.1.2"
-    enhanced-resolve "^3.4.0"
-    escope "^3.6.0"
-    interpret "^1.0.0"
-    json-loader "^0.5.4"
-    json5 "^0.5.1"
-    loader-runner "^2.3.0"
-    loader-utils "^1.1.0"
-    memory-fs "~0.4.1"
-    mkdirp "~0.5.0"
-    node-libs-browser "^2.0.0"
-    source-map "^0.5.3"
-    supports-color "^4.2.1"
-    tapable "^0.2.7"
-    uglifyjs-webpack-plugin "^0.4.6"
-    watchpack "^1.4.0"
-    webpack-sources "^1.0.1"
-    yargs "^8.0.2"
-
-whet.extend@~0.9.9:
-  version "0.9.9"
-  resolved "https://registry.yarnpkg.com/whet.extend/-/whet.extend-0.9.9.tgz#f877d5bf648c97e5aa542fadc16d6a259b9c11a1"
-  integrity sha1-+HfVv2SMl+WqVC+twW1qJZucEaE=
-
-which-module@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"
-  integrity sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=
+webpack@^4.44.2:
+  version "4.44.2"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-4.44.2.tgz#6bfe2b0af055c8b2d1e90ed2cd9363f841266b72"
+  integrity sha512-6KJVGlCxYdISyurpQ0IPTklv+DULv05rs2hseIXer6D7KrUicRDLFb4IUM1S6LUAKypPM/nSiVSuv8jHu1m3/Q==
+  dependencies:
+    "@webassemblyjs/ast" "1.9.0"
+    "@webassemblyjs/helper-module-context" "1.9.0"
+    "@webassemblyjs/wasm-edit" "1.9.0"
+    "@webassemblyjs/wasm-parser" "1.9.0"
+    acorn "^6.4.1"
+    ajv "^6.10.2"
+    ajv-keywords "^3.4.1"
+    chrome-trace-event "^1.0.2"
+    enhanced-resolve "^4.3.0"
+    eslint-scope "^4.0.3"
+    json-parse-better-errors "^1.0.2"
+    loader-runner "^2.4.0"
+    loader-utils "^1.2.3"
+    memory-fs "^0.4.1"
+    micromatch "^3.1.10"
+    mkdirp "^0.5.3"
+    neo-async "^2.6.1"
+    node-libs-browser "^2.2.1"
+    schema-utils "^1.0.0"
+    tapable "^1.1.3"
+    terser-webpack-plugin "^1.4.3"
+    watchpack "^1.7.4"
+    webpack-sources "^1.4.1"
 
 which-module@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
   integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
 
-which@1, which@^1.2.9:
+which@^1.2.14, which@^1.2.9, which@^1.3.1:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
   dependencies:
     isexe "^2.0.0"
 
-wide-align@^1.1.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
-  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+which@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
   dependencies:
-    string-width "^1.0.2 || 2"
-
-window-size@0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.0.tgz#5438cd2ea93b202efa3a19fe8887aee7c94f9c9d"
-  integrity sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=
+    isexe "^2.0.0"
 
-wordwrap@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.2.tgz#b79669bb42ecb409f83d583cad52ca17eaa1643f"
-  integrity sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=
+word-wrap@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
+  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-wordwrap@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb"
-  integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=
+worker-farm@^1.7.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"
+  integrity sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==
+  dependencies:
+    errno "~0.1.7"
 
-wrap-ansi@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"
-  integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=
+wrap-ansi@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
+  integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
   dependencies:
-    string-width "^1.0.1"
-    strip-ansi "^3.0.1"
+    ansi-styles "^3.2.0"
+    string-width "^3.0.0"
+    strip-ansi "^5.0.0"
 
 wrappy@1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-write@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757"
-  integrity sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=
+write-file-atomic@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
+  integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
+  dependencies:
+    imurmurhash "^0.1.4"
+    is-typedarray "^1.0.0"
+    signal-exit "^3.0.2"
+    typedarray-to-buffer "^3.1.5"
+
+write@1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3"
+  integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig==
   dependencies:
     mkdirp "^0.5.1"
 
-xtend@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
-  integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68=
+xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
+  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
 
-y18n@^3.2.1:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
-  integrity sha1-bRX7qITAhnnA136I53WegR4H+kE=
+y18n@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
+  integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
 
-yallist@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+yallist@^3.0.2:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
 
-yallist@^3.0.0, yallist@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9"
-  integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
-yargs-parser@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-5.0.0.tgz#275ecf0d7ffe05c77e64e7c86e4cd94bf0e1228a"
-  integrity sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=
+yaml@^1.10.0:
+  version "1.10.0"
+  resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e"
+  integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==
+
+yargs-parser@^13.1.2:
+  version "13.1.2"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
+  integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
   dependencies:
-    camelcase "^3.0.0"
+    camelcase "^5.0.0"
+    decamelize "^1.2.0"
 
-yargs-parser@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9"
-  integrity sha1-jQrELxbqVd69MyyvTEA4s+P139k=
-  dependencies:
-    camelcase "^4.1.0"
-
-yargs@^7.0.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-7.1.0.tgz#6ba318eb16961727f5d284f8ea003e8d6154d0c8"
-  integrity sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=
-  dependencies:
-    camelcase "^3.0.0"
-    cliui "^3.2.0"
-    decamelize "^1.1.1"
-    get-caller-file "^1.0.1"
-    os-locale "^1.4.0"
-    read-pkg-up "^1.0.1"
-    require-directory "^2.1.1"
-    require-main-filename "^1.0.1"
-    set-blocking "^2.0.0"
-    string-width "^1.0.2"
-    which-module "^1.0.0"
-    y18n "^3.2.1"
-    yargs-parser "^5.0.0"
-
-yargs@^8.0.2:
-  version "8.0.2"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360"
-  integrity sha1-YpmpBVsc78lp/355wdkY3Osiw2A=
-  dependencies:
-    camelcase "^4.1.0"
-    cliui "^3.2.0"
-    decamelize "^1.1.1"
-    get-caller-file "^1.0.1"
-    os-locale "^2.0.0"
-    read-pkg-up "^2.0.0"
+yargs-parser@^18.1.3:
+  version "18.1.3"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
+  integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==
+  dependencies:
+    camelcase "^5.0.0"
+    decamelize "^1.2.0"
+
+yargs@^13.3.2:
+  version "13.3.2"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
+  integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
+  dependencies:
+    cliui "^5.0.0"
+    find-up "^3.0.0"
+    get-caller-file "^2.0.1"
     require-directory "^2.1.1"
-    require-main-filename "^1.0.1"
+    require-main-filename "^2.0.0"
     set-blocking "^2.0.0"
-    string-width "^2.0.0"
+    string-width "^3.0.0"
     which-module "^2.0.0"
-    y18n "^3.2.1"
-    yargs-parser "^7.0.0"
-
-yargs@~3.10.0:
-  version "3.10.0"
-  resolved "https://registry.yarnpkg.com/yargs/-/yargs-3.10.0.tgz#f7ee7bd857dd7c1d2d38c0e74efbd681d1431fd1"
-  integrity sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=
-  dependencies:
-    camelcase "^1.0.2"
-    cliui "^2.1.0"
-    decamelize "^1.0.0"
-    window-size "0.1.0"
+    y18n "^4.0.0"
+    yargs-parser "^13.1.2"