]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #1610 from ArthurHoaro/fix/wallabag
authorArthurHoaro <arthur@hoa.ro>
Tue, 3 Nov 2020 10:46:54 +0000 (11:46 +0100)
committerGitHub <noreply@github.com>
Tue, 3 Nov 2020 10:46:54 +0000 (11:46 +0100)
Plugin wallabag: minor improvements

65 files changed:
Dockerfile
application/History.php
application/Utils.php
application/api/controllers/Links.php
application/bookmark/BookmarkFileService.php
application/bookmark/BookmarkServiceInterface.php
application/config/ConfigJson.php
application/front/controller/admin/ManageShaareController.php [deleted file]
application/front/controller/admin/ServerController.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/visitor/BookmarkListController.php
application/front/controller/visitor/DailyController.php
application/front/controller/visitor/InstallController.php
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/legacy/LegacyLinkDB.php
application/legacy/LegacyUpdater.php
application/render/PageBuilder.php
application/render/TemplatePage.php
application/security/BanManager.php
application/security/SessionManager.php
assets/common/js/metadata.js
assets/common/js/shaare-batch.js [new file with mode: 0644]
assets/default/js/base.js
assets/default/scss/shaarli.scss
composer.json
inc/languages/fr/LC_MESSAGES/shaarli.po
index.php
init.php
tests/api/controllers/links/PostLinkTest.php
tests/bookmark/BookmarkFileServiceTest.php
tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php [deleted file]
tests/front/controller/admin/ServerControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareAddControllerTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php with 98% similarity]
tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php with 98% similarity]
tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php with 95% similarity]
tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php [new file with mode: 0644]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php with 98% similarity]
tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php with 95% similarity]
tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php [moved from tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php with 98% similarity]
tests/front/controller/visitor/BookmarkListControllerTest.php
tests/front/controller/visitor/DailyControllerTest.php
tests/front/controller/visitor/InstallControllerTest.php
tests/helper/ApplicationUtilsTest.php [moved from tests/ApplicationUtilsTest.php with 81% similarity]
tests/helper/DailyPageHelperTest.php [new file with mode: 0644]
tests/helper/FileUtilsTest.php [moved from tests/FileUtilsTest.php with 53% similarity]
tests/security/BanManagerTest.php
tests/utils/FakeApplicationUtils.php
tests/utils/ReferenceHistory.php
tpl/default/addlink.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/install.html
tpl/default/linklist.html
tpl/default/server.html [new file with mode: 0644]
tpl/default/server.requirements.html [new file with mode: 0644]
tpl/default/tools.html
webpack.config.js

index e2ff71fde5971c7803b6b8d60d498765b4680116..f6120b71f2b1d4507cd46dcaf7ebf09934d98139 100644 (file)
@@ -44,6 +44,7 @@ RUN apk --update --no-cache add \
         php7-openssl \
         php7-session \
         php7-xml \
+        php7-simplexml \
         php7-zlib \
         s6
 
index 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 bc1c9f5d6133b67eacc321b3d41da57f60b0f38d..db046893166aaa5a773a815822007541d5d92824 100644 (file)
@@ -326,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.
  *
@@ -454,16 +471,20 @@ 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)
 {
-    return dn__($domain, $text, $nText, $nb);
+    $postFunction = $fixCase ? 'ucfirst' : function ($input) { return $input; };
+
+    return $postFunction(dn__($domain, $text, $nText, $nb, $variables));
 }
 
 /**
index 73a1b84e1727e1a4567e53ec9db6fe1299ce2b39..6bf529e4a570a3ff5f789f2de34045946205cd67 100644 (file)
@@ -131,7 +131,7 @@ class Links extends ApiController
 
         $this->bookmarkService->add($bookmark);
         $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment']));
-        $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]);
+        $redirect = $this->ci->router->pathFor('getLink', ['id' => $bookmark->getId()]);
         return $response->withAddedHeader('Location', $redirect)
                         ->withJson($out, 201, $this->jsonStyle);
     }
index eb7899bf7edc24b85ed4462fbb0f24dd50dedd6c..3ea98a45d6bf24c1ce501826d7dc84455bdae5ee 100644 (file)
@@ -97,13 +97,16 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function findByHash(string $hash): Bookmark
+    public function findByHash(string $hash, string $privateKey = null): Bookmark
     {
         $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
         // PHP 7.3 introduced array_key_first() to avoid this hack
         $first = reset($bookmark);
-        if (! $this->isLoggedIn && $first->isPrivate()) {
-            throw new Exception('Not authorized');
+        if (!$this->isLoggedIn
+            && $first->isPrivate()
+            && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
+        ) {
+            throw new BookmarkNotFoundException();
         }
 
         return $first;
@@ -340,26 +343,42 @@ class BookmarkFileService implements BookmarkServiceInterface
     /**
      * @inheritDoc
      */
-    public function days(): array
-    {
-        $bookmarkDays = [];
-        foreach ($this->search() as $bookmark) {
-            $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0;
+    public function findByDate(
+        \DateTimeInterface $from,
+        \DateTimeInterface $to,
+        ?\DateTimeInterface &$previous,
+        ?\DateTimeInterface &$next
+    ): array {
+        $out = [];
+        $previous = null;
+        $next = null;
+
+        foreach ($this->search([], null, false, false, true) as $bookmark) {
+            if ($to < $bookmark->getCreated()) {
+                $next = $bookmark->getCreated();
+            } 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 array_map('strval', $bookmarkDays);
+        return $out;
     }
 
     /**
      * @inheritDoc
      */
-    public function filterDay(string $request)
+    public function getLatest(): ?Bookmark
     {
-        $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
+        foreach ($this->search([], null, false, false, true) as $bookmark) {
+            return $bookmark;
+        }
 
-        return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility);
+        return null;
     }
 
     /**
index 37a54d03ece93be2642974ee53a8b01cf699a217..08cdbb4ed4055cc3f6ef2672b991f9c3b1cfeda7 100644 (file)
@@ -20,13 +20,14 @@ interface BookmarkServiceInterface
     /**
      * Find a bookmark by hash
      *
-     * @param string $hash
+     * @param string      $hash       Bookmark's hash
+     * @param string|null $privateKey Optional key used to access private links while logged out
      *
      * @return Bookmark
      *
      * @throws \Exception
      */
-    public function findByHash(string $hash): Bookmark;
+    public function findByHash(string $hash, string $privateKey = null);
 
     /**
      * @param $url
@@ -155,22 +156,29 @@ interface BookmarkServiceInterface
     public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
 
     /**
-     * Returns the list of days containing articles (oldest first)
+     * Return a list of bookmark matching provided period of time.
+     * It also update directly previous and next date outside of given period found in the datastore.
      *
-     * @return array containing days (in format YYYYMMDD).
+     * @param \DateTimeInterface      $from     Starting date.
+     * @param \DateTimeInterface      $to       Ending date.
+     * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
+     * @param \DateTimeInterface|null $next     (by reference) updated with first created date found after $to.
+     *
+     * @return array List of bookmarks matching provided period of time.
      */
-    public function days(): array;
+    public function findByDate(
+        \DateTimeInterface $from,
+        \DateTimeInterface $to,
+        ?\DateTimeInterface &$previous,
+        ?\DateTimeInterface &$next
+    ): array;
 
     /**
-     * Returns the list of articles for a given day.
-     *
-     * @param string $request day to filter. Format: YYYYMMDD.
+     * Returns the latest bookmark by creation date.
      *
-     * @return Bookmark[] list of shaare found.
-     *
-     * @throws BookmarkNotFoundException
+     * @return Bookmark|null Found Bookmark or null if the datastore is empty.
      */
-    public function filterDay(string $request);
+    public function getLatest(): ?Bookmark;
 
     /**
      * Creates the default database after a fresh install.
index c0c0dab9ab9df4ce10f0a8217ae28a88dc011483..23b22269540d46f3a03770ea50865738e31c1209 100644 (file)
@@ -19,7 +19,7 @@ class ConfigJson implements ConfigIO
         $data = file_get_contents($filepath);
         $data = str_replace(self::getPhpHeaders(), '', $data);
         $data = str_replace(self::getPhpSuffix(), '', $data);
-        $data = json_decode($data, true);
+        $data = json_decode(trim($data), true);
         if ($data === null) {
             $errorCode = json_last_error();
             $error  = sprintf(
@@ -73,7 +73,7 @@ class ConfigJson implements ConfigIO
      */
     public static function getPhpHeaders()
     {
-        return '<?php /*'. PHP_EOL;
+        return '<?php /*';
     }
 
     /**
@@ -85,6 +85,6 @@ class ConfigJson implements ConfigIO
      */
     public static function getPhpSuffix()
     {
-        return PHP_EOL . '*/ ?>';
+        return '*/ ?>';
     }
 }
diff --git a/application/front/controller/admin/ManageShaareController.php b/application/front/controller/admin/ManageShaareController.php
deleted file mode 100644 (file)
index 908ebae..0000000
+++ /dev/null
@@ -1,360 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller\Admin;
-
-use Shaarli\Bookmark\Bookmark;
-use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
-use Shaarli\Formatter\BookmarkMarkdownFormatter;
-use Shaarli\Render\TemplatePage;
-use Shaarli\Thumbnailer;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-/**
- * Class PostBookmarkController
- *
- * Slim controller used to handle Shaarli create or edit bookmarks.
- */
-class ManageShaareController extends ShaarliAdminController
-{
-    /**
-     * GET /admin/add-shaare - Displays the form used to create a new bookmark from an URL
-     */
-    public function addShaare(Request $request, Response $response): Response
-    {
-        $this->assignView(
-            'pagetitle',
-            t('Shaare a new link') .' - '. $this->container->conf->get('general.title', 'Shaarli')
-        );
-
-        return $response->write($this->render(TemplatePage::ADDLINK));
-    }
-
-    /**
-     * GET /admin/shaare - Displays the bookmark form for creation.
-     *                     Note that if the URL is found in existing bookmarks, then it will be in edit mode.
-     */
-    public function displayCreateForm(Request $request, Response $response): Response
-    {
-        $url = cleanup_url($request->getParam('post'));
-
-        $linkIsNew = false;
-        // Check if URL is not already in database (in this case, we will edit the existing link)
-        $bookmark = $this->container->bookmarkService->findByUrl($url);
-        if (null === $bookmark) {
-            $linkIsNew = true;
-            // Get shaare data if it was provided in URL (e.g.: by the bookmarklet).
-            $title = $request->getParam('title');
-            $description = $request->getParam('description');
-            $tags = $request->getParam('tags');
-            $private = filter_var($request->getParam('private'), FILTER_VALIDATE_BOOLEAN);
-
-            // If this is an HTTP(S) link, we try go get the page to extract
-            // the title (otherwise we will to straight to the edit form.)
-            if (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: '));
-            }
-
-            $link = [
-                'title' => $title ?? $metadata['title'] ?? '',
-                'url' => $url ?? '',
-                'description' => $description ?? $metadata['description'] ?? '',
-                'tags' => $tags ?? $metadata['tags'] ?? '',
-                'private' => $private,
-            ];
-        } else {
-            $formatter = $this->container->formatterFactory->getFormatter('raw');
-            $link = $formatter->format($bookmark);
-        }
-
-        return $this->displayForm($link, $linkIsNew, $request, $response);
-    }
-
-    /**
-     * GET /admin/shaare/{id} - Displays the bookmark form in edition mode.
-     */
-    public function displayEditForm(Request $request, Response $response, array $args): Response
-    {
-        $id = $args['id'] ?? '';
-        try {
-            if (false === ctype_digit($id)) {
-                throw new BookmarkNotFoundException();
-            }
-            $bookmark = $this->container->bookmarkService->get((int) $id);  // Read database
-        } catch (BookmarkNotFoundException $e) {
-            $this->saveErrorMessage(sprintf(
-                t('Bookmark with identifier %s could not be found.'),
-                $id
-            ));
-
-            return $this->redirect($response, '/');
-        }
-
-        $formatter = $this->container->formatterFactory->getFormatter('raw');
-        $link = $formatter->format($bookmark);
-
-        return $this->displayForm($link, false, $request, $response);
-    }
-
-    /**
-     * POST /admin/shaare
-     */
-    public function save(Request $request, Response $response): Response
-    {
-        $this->checkToken($request);
-
-        // lf_id should only be present if the link exists.
-        $id = $request->getParam('lf_id') !== null ? intval(escape($request->getParam('lf_id'))) : null;
-        if (null !== $id && true === $this->container->bookmarkService->exists($id)) {
-            // Edit
-            $bookmark = $this->container->bookmarkService->get($id);
-        } else {
-            // New link
-            $bookmark = new Bookmark();
-        }
-
-        $bookmark->setTitle($request->getParam('lf_title'));
-        $bookmark->setDescription($request->getParam('lf_description'));
-        $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
-        $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
-        $bookmark->setTagsString($request->getParam('lf_tags'));
-
-        if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
-            && 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->container->formatterFactory->getFormatter('raw');
-        $data = $formatter->format($bookmark);
-        $this->executePageHooks('save_link', $data);
-
-        $bookmark->fromArray($data);
-        $this->container->bookmarkService->set($bookmark);
-
-        // If we are called from the bookmarklet, we must close the popup:
-        if ($request->getParam('source') === 'bookmarklet') {
-            return $response->write('<script>self.close();</script>');
-        }
-
-        if (!empty($request->getParam('returnurl'))) {
-            $this->container->environment['HTTP_REFERER'] = escape($request->getParam('returnurl'));
-        }
-
-        return $this->redirectFromReferer(
-            $request,
-            $response,
-            ['/admin/add-shaare', '/admin/shaare'], ['addlink', 'post', 'edit_link'],
-            $bookmark->getShortUrl()
-        );
-    }
-
-    /**
-     * GET /admin/shaare/delete - Delete one or multiple bookmarks (depending on `id` query parameter).
-     */
-    public function deleteBookmark(Request $request, Response $response): Response
-    {
-        $this->checkToken($request);
-
-        $ids = escape(trim($request->getParam('id') ?? ''));
-        if (empty($ids) || strpos($ids, ' ') !== false) {
-            // multiple, space-separated ids provided
-            $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
-        } else {
-            $ids = [$ids];
-        }
-
-        // assert at least one id is given
-        if (0 === count($ids)) {
-            $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
-
-            return $this->redirectFromReferer($request, $response, [], ['delete-shaare']);
-        }
-
-        $formatter = $this->container->formatterFactory->getFormatter('raw');
-        $count = 0;
-        foreach ($ids as $id) {
-            try {
-                $bookmark = $this->container->bookmarkService->get((int) $id);
-            } catch (BookmarkNotFoundException $e) {
-                $this->saveErrorMessage(sprintf(
-                    t('Bookmark with identifier %s could not be found.'),
-                    $id
-                ));
-
-                continue;
-            }
-
-            $data = $formatter->format($bookmark);
-            $this->executePageHooks('delete_link', $data);
-            $this->container->bookmarkService->remove($bookmark, false);
-            ++ $count;
-        }
-
-        if ($count > 0) {
-            $this->container->bookmarkService->save();
-        }
-
-        // If we are called from the bookmarklet, we must close the popup:
-        if ($request->getParam('source') === 'bookmarklet') {
-            return $response->write('<script>self.close();</script>');
-        }
-
-        // Don't redirect to where we were previously because the datastore has changed.
-        return $this->redirect($response, '/');
-    }
-
-    /**
-     * GET /admin/shaare/visibility
-     *
-     * Change visibility (public/private) of one or multiple bookmarks (depending on `id` query parameter).
-     */
-    public function changeVisibility(Request $request, Response $response): Response
-    {
-        $this->checkToken($request);
-
-        $ids = trim(escape($request->getParam('id') ?? ''));
-        if (empty($ids) || strpos($ids, ' ') !== false) {
-            // multiple, space-separated ids provided
-            $ids = array_values(array_filter(preg_split('/\s+/', $ids), 'ctype_digit'));
-        } else {
-            // only a single id provided
-            $ids = [$ids];
-        }
-
-        // assert at least one id is given
-        if (0 === count($ids)) {
-            $this->saveErrorMessage(t('Invalid bookmark ID provided.'));
-
-            return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
-        }
-
-        // assert that the visibility is valid
-        $visibility = $request->getParam('newVisibility');
-        if (null === $visibility || false === in_array($visibility, ['public', 'private'], true)) {
-            $this->saveErrorMessage(t('Invalid visibility provided.'));
-
-            return $this->redirectFromReferer($request, $response, [], ['change_visibility']);
-        } else {
-            $isPrivate = $visibility === 'private';
-        }
-
-        $formatter = $this->container->formatterFactory->getFormatter('raw');
-        $count = 0;
-
-        foreach ($ids as $id) {
-            try {
-                $bookmark = $this->container->bookmarkService->get((int) $id);
-            } catch (BookmarkNotFoundException $e) {
-                $this->saveErrorMessage(sprintf(
-                    t('Bookmark with identifier %s could not be found.'),
-                    $id
-                ));
-
-                continue;
-            }
-
-            $bookmark->setPrivate($isPrivate);
-
-            // To preserve backward compatibility with 3rd parties, plugins still use arrays
-            $data = $formatter->format($bookmark);
-            $this->executePageHooks('save_link', $data);
-            $bookmark->fromArray($data);
-
-            $this->container->bookmarkService->set($bookmark, false);
-            ++$count;
-        }
-
-        if ($count > 0) {
-            $this->container->bookmarkService->save();
-        }
-
-        return $this->redirectFromReferer($request, $response, ['/visibility'], ['change_visibility']);
-    }
-
-    /**
-     * GET /admin/shaare/{id}/pin - Pin or unpin a bookmark.
-     */
-    public function pinBookmark(Request $request, Response $response, array $args): Response
-    {
-        $this->checkToken($request);
-
-        $id = $args['id'] ?? '';
-        try {
-            if (false === ctype_digit($id)) {
-                throw new BookmarkNotFoundException();
-            }
-            $bookmark = $this->container->bookmarkService->get((int) $id);  // Read database
-        } catch (BookmarkNotFoundException $e) {
-            $this->saveErrorMessage(sprintf(
-                t('Bookmark with identifier %s could not be found.'),
-                $id
-            ));
-
-            return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
-        }
-
-        $formatter = $this->container->formatterFactory->getFormatter('raw');
-
-        $bookmark->setSticky(!$bookmark->isSticky());
-
-        // To preserve backward compatibility with 3rd parties, plugins still use arrays
-        $data = $formatter->format($bookmark);
-        $this->executePageHooks('save_link', $data);
-        $bookmark->fromArray($data);
-
-        $this->container->bookmarkService->set($bookmark);
-
-        return $this->redirectFromReferer($request, $response, ['/pin'], ['pin']);
-    }
-
-    /**
-     * Helper function used to display the shaare form whether it's a new or existing bookmark.
-     *
-     * @param array $link data used in template, either from parameters or from the data store
-     */
-    protected function displayForm(array $link, bool $isNew, Request $request, Response $response): Response
-    {
-        $tags = $this->container->bookmarkService->bookmarksCountPerTag();
-        if ($this->container->conf->get('formatter') === 'markdown') {
-            $tags[BookmarkMarkdownFormatter::NO_MD_TAG] = 1;
-        }
-
-        $data = escape([
-            'link' => $link,
-            'link_is_new' => $isNew,
-            'http_referer' => $this->container->environment['HTTP_REFERER'] ?? '',
-            'source' => $request->getParam('source') ?? '',
-            'tags' => $tags,
-            'default_private_links' => $this->container->conf->get('privacy.default_private_links', false),
-            'async_metadata' => $this->container->conf->get('general.enable_async_metadata', true),
-            'retrieve_description' => $this->container->conf->get('general.retrieve_description', false),
-        ]);
-
-        $this->executePageHooks('render_editlink', $data, TemplatePage::EDIT_LINK);
-
-        foreach ($data as $key => $value) {
-            $this->assignView($key, $value);
-        }
-
-        $editLabel = false === $isNew ? t('Edit') .' ' : '';
-        $this->assignView(
-            'pagetitle',
-            $editLabel . t('Shaare') .' - '. $this->container->conf->get('general.title', 'Shaarli')
-        );
-
-        return $response->write($this->render(TemplatePage::EDIT_LINK));
-    }
-}
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/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;
+    }
+}
index a8019ead33865e99365eba4c9239b1e9caac5a9f..78c474c9095fd20e7377e3414c01c976ec8a4f97 100644 (file)
@@ -137,8 +137,10 @@ class BookmarkListController extends ShaarliVisitorController
      */
     public function permalink(Request $request, Response $response, array $args): Response
     {
+        $privateKey = $request->getParam('key');
+
         try {
-            $bookmark = $this->container->bookmarkService->findByHash($args['hash']);
+            $bookmark = $this->container->bookmarkService->findByHash($args['hash'], $privateKey);
         } catch (BookmarkNotFoundException $e) {
             $this->assignView('error_message', $e->getMessage());
 
@@ -169,16 +171,24 @@ class BookmarkListController extends ShaarliVisitorController
      */
     protected function updateThumbnail(Bookmark $bookmark, bool $writeDatastore = true): bool
     {
-        // Logged in, not async retrieval, thumbnails enabled, and thumbnail should be updated
-        if ($this->container->loginManager->isLoggedIn()
-            && true !== $this->container->conf->get('general.enable_async_metadata', true)
-            && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
-            && $bookmark->shouldUpdateThumbnail()
-        ) {
-            $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
-            $this->container->bookmarkService->set($bookmark, $writeDatastore);
-
-            return true;
+        if (false === $this->container->loginManager->isLoggedIn()) {
+            return false;
+        }
+
+        // If thumbnail should be updated, we reset it to null
+        if ($bookmark->shouldUpdateThumbnail()) {
+            $bookmark->setThumbnail(null);
+
+            // Requires an update, not async retrieval, thumbnails enabled
+            if ($bookmark->shouldUpdateThumbnail()
+                && true !== $this->container->conf->get('general.enable_async_metadata', true)
+                && $this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
+            ) {
+                $bookmark->setThumbnail($this->container->thumbnailer->get($bookmark->getUrl()));
+                $this->container->bookmarkService->set($bookmark, $writeDatastore);
+
+                return true;
+            }
         }
 
         return false;
index 07617cf11fdfe49b047c24d0641477ad6b66e8d4..728bc2d8e81e4107d32efed827351255dea06b58 100644 (file)
@@ -5,8 +5,8 @@ declare(strict_types=1);
 namespace Shaarli\Front\Controller\Visitor;
 
 use DateTime;
-use DateTimeImmutable;
 use Shaarli\Bookmark\Bookmark;
+use Shaarli\Helper\DailyPageHelper;
 use Shaarli\Render\TemplatePage;
 use Slim\Http\Request;
 use Slim\Http\Response;
@@ -26,32 +26,20 @@ class DailyController extends ShaarliVisitorController
      */
     public function index(Request $request, Response $response): Response
     {
-        $day = $request->getQueryParam('day') ?? date('Ymd');
-
-        $availableDates = $this->container->bookmarkService->days();
-        $nbAvailableDates = count($availableDates);
-        $index = array_search($day, $availableDates);
-
-        if ($index === false) {
-            // no bookmarks for day, but at least one day with bookmarks
-            $day = $availableDates[$nbAvailableDates - 1] ?? $day;
-            $previousDay = $availableDates[$nbAvailableDates - 2] ?? '';
-        } else {
-            $previousDay = $availableDates[$index - 1] ?? '';
-            $nextDay = $availableDates[$index + 1] ?? '';
-        }
-
-        if ($day === date('Ymd')) {
-            $this->assignView('dayDesc', t('Today'));
-        } elseif ($day === date('Ymd', strtotime('-1 days'))) {
-            $this->assignView('dayDesc', t('Yesterday'));
-        }
-
-        try {
-            $linksToDisplay = $this->container->bookmarkService->filterDay($day);
-        } catch (\Exception $exc) {
-            $linksToDisplay = [];
-        }
+        $type = DailyPageHelper::extractRequestedType($request);
+        $format = DailyPageHelper::getFormatByType($type);
+        $latestBookmark = $this->container->bookmarkService->getLatest();
+        $dateTime = DailyPageHelper::extractRequestedDateTime($type, $request->getQueryParam($type), $latestBookmark);
+        $start = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
+        $end = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
+        $dailyDesc = DailyPageHelper::getDescriptionByType($type, $dateTime);
+
+        $linksToDisplay = $this->container->bookmarkService->findByDate(
+            $start,
+            $end,
+            $previousDay,
+            $nextDay
+        );
 
         $formatter = $this->container->formatterFactory->getFormatter();
         $formatter->addContextData('base_path', $this->container->basePath);
@@ -63,13 +51,15 @@ class DailyController extends ShaarliVisitorController
             $linksToDisplay[$key]['description'] = $bookmark->getDescription();
         }
 
-        $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
         $data = [
             'linksToDisplay' => $linksToDisplay,
-            'day' => $dayDate->getTimestamp(),
-            'dayDate' => $dayDate,
-            'previousday' => $previousDay ?? '',
-            'nextday' => $nextDay ?? '',
+            'dayDate' => $start,
+            'day' => $start->getTimestamp(),
+            'previousday' => $previousDay ? $previousDay->format($format) : '',
+            'nextday' => $nextDay ? $nextDay->format($format) : '',
+            'dayDesc' => $dailyDesc,
+            'type' => $type,
+            'localizedType' => $this->translateType($type),
         ];
 
         // Hooks are called before column construction so that plugins don't have to deal with columns.
@@ -82,7 +72,7 @@ class DailyController extends ShaarliVisitorController
         $mainTitle = $this->container->conf->get('general.title', 'Shaarli');
         $this->assignView(
             'pagetitle',
-            t('Daily') .' - '. format_date($dayDate, false) . ' - ' . $mainTitle
+            $data['localizedType'] . ' - ' . $data['dayDesc'] . ' - ' . $mainTitle
         );
 
         return $response->write($this->render(TemplatePage::DAILY));
@@ -106,11 +96,14 @@ class DailyController extends ShaarliVisitorController
         }
 
         $days = [];
+        $type = DailyPageHelper::extractRequestedType($request);
+        $format = DailyPageHelper::getFormatByType($type);
+        $length = DailyPageHelper::getRssLengthByType($type);
         foreach ($this->container->bookmarkService->search() as $bookmark) {
-            $day = $bookmark->getCreated()->format('Ymd');
+            $day = $bookmark->getCreated()->format($format);
 
             // Stop iterating after DAILY_RSS_NB_DAYS entries
-            if (count($days) === static::$DAILY_RSS_NB_DAYS && !isset($days[$day])) {
+            if (count($days) === $length && !isset($days[$day])) {
                 break;
             }
 
@@ -127,12 +120,19 @@ class DailyController extends ShaarliVisitorController
 
         /** @var Bookmark[] $bookmarks */
         foreach ($days as $day => $bookmarks) {
-            $dayDatetime = DateTimeImmutable::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000');
+            $dayDateTime = DailyPageHelper::extractRequestedDateTime($type, (string) $day);
+            $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dayDateTime);
+
+            // We only want the RSS entry to be published when the period is over.
+            if (new DateTime() < $endDateTime) {
+                continue;
+            }
+
             $dataPerDay[$day] = [
-                'date' => $dayDatetime,
-                'date_rss' => $dayDatetime->format(DateTime::RSS),
-                'date_human' => format_date($dayDatetime, false, true),
-                'absolute_url' => $indexUrl . 'daily?day=' . $day,
+                'date' => $endDateTime,
+                'date_rss' => $endDateTime->format(DateTime::RSS),
+                'date_human' => DailyPageHelper::getDescriptionByType($type, $dayDateTime),
+                'absolute_url' => $indexUrl . 'daily?'. $type .'=' . $day,
                 'links' => [],
             ];
 
@@ -141,16 +141,20 @@ class DailyController extends ShaarliVisitorController
 
                 // Make permalink URL absolute
                 if ($bookmark->isNote()) {
-                    $dataPerDay[$day]['links'][$key]['url'] = $indexUrl . $bookmark->getUrl();
+                    $dataPerDay[$day]['links'][$key]['url'] = rtrim($indexUrl, '/') . $bookmark->getUrl();
                 }
             }
         }
 
-        $this->assignView('title', $this->container->conf->get('general.title', 'Shaarli'));
-        $this->assignView('index_url', $indexUrl);
-        $this->assignView('page_url', $pageUrl);
-        $this->assignView('hide_timestamps', $this->container->conf->get('privacy.hide_timestamps', false));
-        $this->assignView('days', $dataPerDay);
+        $this->assignAllView([
+            'title' => $this->container->conf->get('general.title', 'Shaarli'),
+            'index_url' => $indexUrl,
+            'page_url' => $pageUrl,
+            'hide_timestamps' => $this->container->conf->get('privacy.hide_timestamps', false),
+            'days' => $dataPerDay,
+            'type' => $type,
+            'localizedType' => $this->translateType($type),
+        ]);
 
         $rssContent = $this->render(TemplatePage::DAILY_RSS);
 
@@ -189,4 +193,13 @@ class DailyController extends ShaarliVisitorController
 
         return $columns;
     }
+
+    protected function translateType($type): string
+    {
+        return [
+            t('day') => t('Daily'),
+            t('week') => t('Weekly'),
+            t('month') => t('Monthly'),
+        ][t($type)] ?? t('Daily');
+    }
 }
index 7cb3277794fbe8c9b2929766b68cc9bcfad21c5b..223292946657169ebd1f42d82afcc5c4971bd46e 100644 (file)
@@ -4,10 +4,10 @@ declare(strict_types=1);
 
 namespace Shaarli\Front\Controller\Visitor;
 
-use Shaarli\ApplicationUtils;
 use Shaarli\Container\ShaarliContainer;
 use Shaarli\Front\Exception\AlreadyInstalledException;
 use Shaarli\Front\Exception\ResourcePermissionException;
+use Shaarli\Helper\ApplicationUtils;
 use Shaarli\Languages;
 use Shaarli\Security\SessionManager;
 use Slim\Http\Request;
@@ -53,6 +53,16 @@ class InstallController extends ShaarliVisitorController
         $this->assignView('cities', $cities);
         $this->assignView('languages', Languages::getAvailableLanguages());
 
+        $phpEol = new \DateTimeImmutable(ApplicationUtils::getPhpEol(PHP_VERSION));
+
+        $this->assignView('php_version', PHP_VERSION);
+        $this->assignView('php_eol', format_date($phpEol, false));
+        $this->assignView('php_has_reached_eol', $phpEol < new \DateTimeImmutable());
+        $this->assignView('php_extensions', ApplicationUtils::getPhpExtensionsRequirement());
+        $this->assignView('permissions', ApplicationUtils::checkResourcePermissions($this->container->conf));
+
+        $this->assignView('pagetitle', t('Install Shaarli'));
+
         return $response->write($this->render('install'));
     }
 
@@ -150,7 +160,7 @@ class InstallController extends ShaarliVisitorController
     protected function checkPermissions(): bool
     {
         // Ensure Shaarli has proper access to its resources
-        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf);
+        $errors = ApplicationUtils::checkResourcePermissions($this->container->conf, true);
         if (empty($errors)) {
             return true;
         }
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;
+    }
 }
index 7bf76fd471087fe0477b935b4cb1bf771ae1ab46..5c02a21b48a69222bee6a3543f36f918b95b402e 100644 (file)
@@ -8,7 +8,7 @@ use DateTime;
 use Iterator;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Exceptions\IOException;
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
 use Shaarli\Render\PageCacheManager;
 
 /**
index 0ab3a55bd572898b07e51099fc8bd7ae23f5d7fe..fe1a286fdb02bbcc0d559210b867f7b9602a3d0a 100644 (file)
@@ -7,7 +7,6 @@ use RainTPL;
 use ReflectionClass;
 use ReflectionException;
 use ReflectionMethod;
-use Shaarli\ApplicationUtils;
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkArray;
 use Shaarli\Bookmark\BookmarkFilter;
@@ -17,6 +16,7 @@ use Shaarli\Config\ConfigJson;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Config\ConfigPhp;
 use Shaarli\Exceptions\IOException;
+use Shaarli\Helper\ApplicationUtils;
 use Shaarli\Thumbnailer;
 use Shaarli\Updater\Exception\UpdaterException;
 
index 512bb79e3555cc4833566af10822cea5be67b649..c2fae7052f71116579b136433f7eeb110989db55 100644 (file)
@@ -5,9 +5,9 @@ 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;
 
@@ -160,7 +160,7 @@ class PageBuilder
 
         $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
 
-        $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE']);
+        $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
 
         // To be removed with a proper theme configuration.
         $this->tpl->assign('conf', $this->conf);
index 8af8228a01b42182575581d2de69a98f342f6e7f..03b424f3c5fc0d272c7e6da111759c392582fe6d 100644 (file)
@@ -14,6 +14,7 @@ interface TemplatePage
     public const DAILY = 'daily';
     public const DAILY_RSS = 'dailyrss';
     public const EDIT_LINK = 'editlink';
+    public const EDIT_LINK_BATCH = 'editlink.batch';
     public const ERROR = 'error';
     public const EXPORT = 'export';
     public const NETSCAPE_EXPORT_BOOKMARKS = 'export.bookmarks';
index f72c8b7b8a1643c87c2f92e8ffcad8fef4385b37..288cbde05fd2e77009b6622b8669729d02903ba6 100644 (file)
@@ -4,7 +4,7 @@
 namespace Shaarli\Security;
 
 use Psr\Log\LoggerInterface;
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
 
 /**
  * Class BanManager
index 36df8c1c9bc823b369f7422a76c63e1f3dd6676b..96bf193c1040debe1e14003c1f3eaab7deba71bf 100644 (file)
@@ -293,9 +293,12 @@ class SessionManager
         return session_start();
     }
 
-    public function cookieParameters(int $lifeTime, string $path, string $domain): bool
+    /**
+     * Be careful, return type of session_set_cookie_params() changed between PHP 7.1 and 7.2.
+     */
+    public function cookieParameters(int $lifeTime, string $path, string $domain): void
     {
-        return session_set_cookie_params($lifeTime, $path, $domain);
+        session_set_cookie_params($lifeTime, $path, $domain);
     }
 
     public function regenerateId(bool $deleteOldSession = false): bool
index 2b013364c006fdd1b040bdee8256b4a5c0ccdc88..d5a28a35e7a0c50245466f72e1075a6eb43d453a 100644 (file)
@@ -56,37 +56,41 @@ function updateThumb(basePath, divElement, id) {
 
 (() => {
   const basePath = document.querySelector('input[name="js_base_path"]').value;
-  const loaders = document.querySelectorAll('.loading-input');
 
   /*
    * METADATA FOR EDIT BOOKMARK PAGE
    */
-  const inputTitle = document.querySelector('input[name="lf_title"]');
-  if (inputTitle != null) {
-    if (inputTitle.value.length > 0) {
-      clearLoaders(loaders);
-      return;
-    }
+  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 = document.querySelector('input[name="lf_url"]').value;
+      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 = document.querySelector(`input[name="lf_${key}"], textarea[name="lf_${key}"]`);
-          if (element != null && element.value.length === 0) {
-            element.value = he.decode(result[key]);
+      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);
-    };
+        });
+        clearLoaders(loaders);
+      };
 
-    xhr.send();
+      xhr.send();
+    });
   }
 
   /*
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 7f6b9637256787741e8a2bb63e306a4f6a697a8d..4163577d0da527d3d1d7b91c3c1eb830bfda4cea 100644 (file)
@@ -634,4 +634,33 @@ function init(description) {
       });
     });
   }
+
+  const bulkCreationButton = document.querySelector('.addlink-batch-show-more-block');
+  if (bulkCreationButton != null) {
+    const toggleBulkCreationVisibility = (showMoreBlockElement, formElement) => {
+      if (bulkCreationButton.classList.contains('pure-u-0')) {
+        showMoreBlockElement.classList.remove('pure-u-0');
+        formElement.classList.add('pure-u-0');
+      } else {
+        showMoreBlockElement.classList.add('pure-u-0');
+        formElement.classList.remove('pure-u-0');
+      }
+    };
+
+    const bulkCreationForm = document.querySelector('.addlink-batch-form-block');
+
+    toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    bulkCreationButton.querySelector('a').addEventListener('click', (e) => {
+      e.preventDefault();
+      toggleBulkCreationVisibility(bulkCreationButton, bulkCreationForm);
+    });
+
+    // Force to send falsy value if the checkbox is not checked.
+    const privateButton = bulkCreationForm.querySelector('input[type="checkbox"][name="private"]');
+    const privateHiddenButton = bulkCreationForm.querySelector('input[type="hidden"][name="private"]');
+    privateButton.addEventListener('click', () => {
+      privateHiddenButton.disabled = !privateHiddenButton.disabled;
+    });
+    privateHiddenButton.disabled = privateButton.checked;
+  }
 })();
index 286ac83b32b487a3d51837b54dd81b1282d6d76f..a7f091e95d938f8ce9c6d10202f2288867fb7390 100644 (file)
@@ -1023,6 +1023,10 @@ body,
     &.button-red {
       background: $red;
     }
+
+    &.button-grey {
+      background: $light-grey;
+    }
   }
 
   .submit-buttons {
@@ -1047,7 +1051,7 @@ body,
   }
 
   table {
-    margin: auto;
+    margin: 10px auto 25px auto;
     width: 90%;
 
     .order {
@@ -1083,6 +1087,11 @@ body,
           position: absolute;
           right: 5%;
         }
+
+        &.button-grey {
+          position: absolute;
+          left: 5%;
+        }
       }
     }
   }
@@ -1696,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 64f0025ed6d540510d210f511eaa58824269ca90..9449258668054f396bf86816282880b1fd83b302 100644 (file)
@@ -59,6 +59,7 @@
             "Shaarli\\Front\\Controller\\Admin\\": "application/front/controller/admin",
             "Shaarli\\Front\\Controller\\Visitor\\": "application/front/controller/visitor",
             "Shaarli\\Front\\Exception\\": "application/front/exceptions",
+            "Shaarli\\Helper\\": "application/helper",
             "Shaarli\\Http\\": "application/http",
             "Shaarli\\Legacy\\": "application/legacy",
             "Shaarli\\Netscape\\": "application/netscape",
index f7baedfb4c8cfac01728e8ed357eed95f9028253..60ea7a970660161050c1df4cbd1faa2c11446112 100644 (file)
@@ -1,8 +1,8 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: Shaarli\n"
-"POT-Creation-Date: 2020-10-16 20:01+0200\n"
-"PO-Revision-Date: 2020-10-16 20:02+0200\n"
+"POT-Creation-Date: 2020-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"
@@ -20,38 +20,11 @@ msgstr ""
 "X-Poedit-SearchPath-3: init.php\n"
 "X-Poedit-SearchPath-4: plugins\n"
 
-#: application/ApplicationUtils.php:161
-#, php-format
-msgid ""
-"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
-"cannot run. Your PHP version has known security vulnerabilities and should "
-"be updated as soon as possible."
-msgstr ""
-"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
-"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
-"connues et devrait Ãªtre mise Ã  jour au plus tôt."
-
-#: application/ApplicationUtils.php:192 application/ApplicationUtils.php:204
-msgid "directory is not readable"
-msgstr "le répertoire n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:207
-msgid "directory is not writable"
-msgstr "le répertoire n'est pas accessible en Ã©criture"
-
-#: application/ApplicationUtils.php:225
-msgid "file is not readable"
-msgstr "le fichier n'est pas accessible en lecture"
-
-#: application/ApplicationUtils.php:228
-msgid "file is not writable"
-msgstr "le fichier n'est pas accessible en Ã©criture"
-
-#: application/History.php:179
+#: application/History.php:180
 msgid "History file isn't readable or writable"
 msgstr "Le fichier d'historique n'est pas accessible en lecture ou en Ã©criture"
 
-#: application/History.php:190
+#: application/History.php:191
 msgid "Could not parse history file"
 msgstr "Format incorrect pour le fichier d'historique"
 
@@ -83,40 +56,40 @@ msgstr ""
 "l'extension php-gd doit Ãªtre chargée pour utiliser les miniatures. Les "
 "miniatures sont désormais désactivées. Rechargez la page."
 
-#: application/Utils.php:383
+#: application/Utils.php:402
 msgid "Setting not set"
 msgstr "Paramètre non défini"
 
-#: application/Utils.php:390
+#: application/Utils.php:409
 msgid "Unlimited"
 msgstr "Illimité"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "B"
 msgstr "o"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "kiB"
 msgstr "ko"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "MiB"
 msgstr "Mo"
 
-#: application/Utils.php:393
+#: application/Utils.php:412
 msgid "GiB"
 msgstr "Go"
 
-#: application/bookmark/BookmarkFileService.php:180
-#: application/bookmark/BookmarkFileService.php:202
-#: application/bookmark/BookmarkFileService.php:224
-#: application/bookmark/BookmarkFileService.php:238
+#: application/bookmark/BookmarkFileService.php:183
+#: application/bookmark/BookmarkFileService.php:205
+#: application/bookmark/BookmarkFileService.php:227
+#: application/bookmark/BookmarkFileService.php:241
 msgid "You're not authorized to alter the datastore"
 msgstr "Vous n'êtes pas autorisé Ã  modifier les données"
 
-#: application/bookmark/BookmarkFileService.php:205
+#: application/bookmark/BookmarkFileService.php:208
 msgid "This bookmarks already exists"
-msgstr "Ce marque-page existe déjà."
+msgstr "Ce marque-page existe déjà"
 
 #: application/bookmark/BookmarkInitializer.php:39
 msgid "(private bookmark with thumbnail demo)"
@@ -314,7 +287,8 @@ msgid "Direct link"
 msgstr "Liens directs"
 
 #: application/feed/FeedBuilder.php:181
-#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:96
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:103
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:179
 msgid "Permalink"
 msgstr "Permalien"
@@ -330,12 +304,13 @@ msgid "You have enabled or changed thumbnails mode."
 msgstr "Vous avez activé ou changé le mode de miniatures."
 
 #: application/front/controller/admin/ConfigureController.php:103
+#: application/front/controller/admin/ServerController.php:68
 #: application/legacy/LegacyUpdater.php:538
 msgid "Please synchronize them."
 msgstr "Merci de les synchroniser."
 
 #: application/front/controller/admin/ConfigureController.php:113
-#: application/front/controller/visitor/InstallController.php:136
+#: application/front/controller/visitor/InstallController.php:146
 msgid "Error while writing config file after configuration update."
 msgstr ""
 "Une erreur s'est produite lors de la sauvegarde du fichier de configuration."
@@ -372,46 +347,19 @@ msgstr ""
 "le serveur web peut accepter (%s). Merci de l'envoyer en parties plus "
 "légères."
 
-#: application/front/controller/admin/ManageShaareController.php:29
-#: tmp/addlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-msgid "Shaare a new link"
-msgstr "Partager un nouveau lien"
-
-#: application/front/controller/admin/ManageShaareController.php:78
-msgid "Note: "
-msgstr "Note : "
-
-#: application/front/controller/admin/ManageShaareController.php:109
-#: application/front/controller/admin/ManageShaareController.php:206
-#: application/front/controller/admin/ManageShaareController.php:275
-#: application/front/controller/admin/ManageShaareController.php:315
-#, php-format
-msgid "Bookmark with identifier %s could not be found."
-msgstr "Le lien avec l'identifiant %s n'a pas pu Ãªtre trouvé."
-
-#: application/front/controller/admin/ManageShaareController.php:194
-#: application/front/controller/admin/ManageShaareController.php:252
-msgid "Invalid bookmark ID provided."
-msgstr "ID du lien non valide."
-
-#: application/front/controller/admin/ManageShaareController.php:260
-msgid "Invalid visibility provided."
-msgstr "Visibilité du lien non valide."
-
-#: application/front/controller/admin/ManageShaareController.php:363
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
-msgid "Edit"
-msgstr "Modifier"
-
-#: application/front/controller/admin/ManageShaareController.php:366
-#: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
-#: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:28
-msgid "Shaare"
-msgstr "Shaare"
-
+#: application/front/controller/admin/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:36
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 msgid "Manage tags"
 msgstr "Gérer les tags"
 
@@ -435,7 +383,7 @@ msgstr[1] "Le tag a Ã©té renommé dans %d liens."
 
 #: application/front/controller/admin/PasswordController.php:28
 #: tmp/changepassword.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
 msgid "Change password"
 msgstr "Modifier le mot de passe"
 
@@ -467,6 +415,43 @@ msgstr ""
 "Une erreur s'est produite lors de la sauvegarde de la configuration des "
 "plugins : "
 
+#: 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/front/controller/admin/ServerController.php:67
+msgid "Thumbnails cache has been cleared."
+msgstr "Le cache des miniatures a Ã©té vidé."
+
+#: 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é !"
+
+#, php-format
+msgid "Bookmark with identifier %s could not be found."
+msgstr "Le lien avec l'identifiant %s n'a pas pu Ãªtre trouvé."
+
+#: application/front/controller/admin/ShaareManageController.php:101
+msgid "Invalid visibility provided."
+msgstr "Visibilité du lien non valide."
+
+#: application/front/controller/admin/ShaarePublishController.php:154
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:171
+msgid "Edit"
+msgstr "Modifier"
+
+#: 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"
+
+#: 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"
@@ -482,29 +467,50 @@ msgstr "Outils"
 msgid "Search: "
 msgstr "Recherche : "
 
-#: application/front/controller/visitor/DailyController.php:45
-msgid "Today"
-msgstr "Aujourd'hui"
-
-#: application/front/controller/visitor/DailyController.php:47
-msgid "Yesterday"
-msgstr "Hier"
+#: application/front/controller/visitor/DailyController.php:200
+msgid "day"
+msgstr "jour"
 
-#: application/front/controller/visitor/DailyController.php:85
+#: application/front/controller/visitor/DailyController.php:200
+#: application/front/controller/visitor/DailyController.php:203
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:13
 #: tmp/page.header.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 #: tmp/page.header.cedf684561d925457130839629000a81.rtpl.php:48
 msgid "Daily"
 msgstr "Quotidien"
 
-#: application/front/controller/visitor/ErrorController.php:36
+#: application/front/controller/visitor/DailyController.php:201
+msgid "week"
+msgstr "semaine"
+
+#: application/front/controller/visitor/DailyController.php:201
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
+msgid "Weekly"
+msgstr "Hebdomadaire"
+
+#: application/front/controller/visitor/DailyController.php:202
+msgid "month"
+msgstr "mois"
+
+#: application/front/controller/visitor/DailyController.php:202
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:15
+msgid "Monthly"
+msgstr "Mensuel"
+
+#: application/front/controller/visitor/ErrorController.php: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 ""
+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:73
+#: 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 "
@@ -523,14 +529,14 @@ msgstr ""
 "des cookies. Nous vous recommandons d'accéder Ã  votre serveur depuis son "
 "adresse IP ou un <em>Fully Qualified Domain Name</em>.<br>"
 
-#: application/front/controller/visitor/InstallController.php:144
+#: application/front/controller/visitor/InstallController.php:154
 msgid ""
 "Shaarli is now configured. Please login and start shaaring your bookmarks!"
 msgstr ""
 "Shaarli est maintenant configuré. Vous pouvez vous connecter et commencez Ã  "
 "shaare vos liens !"
 
-#: application/front/controller/visitor/InstallController.php:158
+#: application/front/controller/visitor/InstallController.php:168
 msgid "Insufficient permissions:"
 msgstr "Permissions insuffisantes :"
 
@@ -544,7 +550,7 @@ msgstr "Permissions insuffisantes :"
 msgid "Login"
 msgstr "Connexion"
 
-#: application/front/controller/visitor/LoginController.php:78
+#: application/front/controller/visitor/LoginController.php:77
 msgid "Wrong login/password."
 msgstr "Nom d'utilisateur ou mot de passe incorrect(s)."
 
@@ -556,7 +562,7 @@ msgstr "Mur d'images"
 
 #: application/front/controller/visitor/TagCloudController.php:88
 msgid "Tag "
-msgstr "Tag"
+msgstr "Tag "
 
 #: application/front/exceptions/AlreadyInstalledException.php:11
 msgid "Shaarli has already been installed. Login to edit the configuration."
@@ -584,6 +590,86 @@ msgstr ""
 msgid "Wrong token."
 msgstr "Jeton invalide."
 
+#: application/helper/ApplicationUtils.php:162
+#, php-format
+msgid ""
+"Your PHP version is obsolete! Shaarli requires at least PHP %s, and thus "
+"cannot run. Your PHP version has known security vulnerabilities and should "
+"be updated as soon as possible."
+msgstr ""
+"Votre version de PHP est obsolète ! Shaarli nécessite au moins PHP %s, et ne "
+"peut donc pas fonctionner. Votre version de PHP a des failles de sécurités "
+"connues et devrait Ãªtre mise Ã  jour au plus tôt."
+
+#: application/helper/ApplicationUtils.php:195
+#: application/helper/ApplicationUtils.php:215
+msgid "directory is not readable"
+msgstr "le répertoire n'est pas accessible en lecture"
+
+#: application/helper/ApplicationUtils.php:218
+msgid "directory is not writable"
+msgstr "le répertoire n'est pas accessible en Ã©criture"
+
+#: application/helper/ApplicationUtils.php:240
+msgid "file is not readable"
+msgstr "le fichier n'est pas accessible en lecture"
+
+#: application/helper/ApplicationUtils.php:243
+msgid "file is not writable"
+msgstr "le fichier n'est pas accessible en Ã©criture"
+
+#: application/helper/ApplicationUtils.php:277
+msgid "Configuration parsing"
+msgstr "Chargement de la configuration"
+
+#: application/helper/ApplicationUtils.php:278
+msgid "Slim Framework (routing, etc.)"
+msgstr "Slim Framwork (routage, etc.)"
+
+#: application/helper/ApplicationUtils.php:279
+msgid "Multibyte (Unicode) string support"
+msgstr "Support des chaînes de caractère multibytes (Unicode)"
+
+#: application/helper/ApplicationUtils.php:280
+msgid "Required to use thumbnails"
+msgstr "Obligatoire pour utiliser les miniatures"
+
+#: application/helper/ApplicationUtils.php:281
+msgid "Localized text sorting (e.g. e->è->f)"
+msgstr "Tri des textes traduits (ex : e->è->f)"
+
+#: application/helper/ApplicationUtils.php:282
+msgid "Better retrieval of bookmark metadata and thumbnail"
+msgstr "Meilleure récupération des meta-données des marque-pages et minatures"
+
+#: application/helper/ApplicationUtils.php:283
+msgid "Use the translation system in gettext mode"
+msgstr "Utiliser le système de traduction en mode gettext"
+
+#: application/helper/ApplicationUtils.php:284
+msgid "Login using LDAP server"
+msgstr "Authentification via un serveur LDAP"
+
+#: application/helper/DailyPageHelper.php:172
+msgid "Week"
+msgstr "Semaine"
+
+#: application/helper/DailyPageHelper.php:176
+msgid "Today"
+msgstr "Aujourd'hui"
+
+#: application/helper/DailyPageHelper.php:178
+msgid "Yesterday"
+msgstr "Hier"
+
+#: application/helper/FileUtils.php:100
+msgid "Provided path is not a directory."
+msgstr "Le chemin fourni n'est pas un dossier."
+
+#: application/helper/FileUtils.php:104
+msgid "Trying to delete a folder outside of Shaarli path."
+msgstr "Tentative de supprimer un dossier en dehors du chemin de Shaarli."
+
 #: application/legacy/LegacyLinkDB.php:131
 msgid "You are not authorized to add a link."
 msgstr "Vous n'êtes pas autorisé Ã  ajouter un lien."
@@ -678,7 +764,7 @@ msgstr "Impossible de purger %s : le répertoire n'existe pas"
 msgid "An error occurred while running the update "
 msgstr "Une erreur s'est produite lors de l'exécution de la mise Ã  jour "
 
-#: index.php:65
+#: index.php:80
 msgid "Shared bookmarks on "
 msgstr "Liens partagés sur "
 
@@ -851,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"
@@ -1016,71 +1144,79 @@ msgstr ""
 "miniatures."
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:328
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:122
 msgid "Synchronize thumbnails"
 msgstr "Synchroniser les miniatures"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:339
 #: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:30
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
 msgid "All"
 msgstr "Tous"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:343
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:106
 msgid "Only common media hosts"
 msgstr "Seulement les hébergeurs de média connus"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:347
+#: tmp/server.b91ef64efc3688266305ea9b42e5017e.rtpl.php:110
 msgid "None"
 msgstr "Aucune"
 
 #: tmp/configure.b91ef64efc3688266305ea9b42e5017e.rtpl.php:355
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
+#: tmp/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:18
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+msgid "1 RSS entry per :type"
+msgid_plural ""
+msgstr[0] "1 entrée RSS par :type"
+msgstr[1] ""
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
+msgid "Previous :type"
+msgid_plural ""
+msgstr[0] ":type précédent"
+msgstr[1] "Jour précédent"
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:56
+#: tmp/dailyrss.b91ef64efc3688266305ea9b42e5017e.rtpl.php:7
+msgid "All links of one :type in a single page."
+msgid_plural ""
+msgstr[0] "Tous les liens d'un :type sur une page."
+msgstr[1] "Tous les liens d'un jour sur une page."
+
+#: tmp/daily.b91ef64efc3688266305ea9b42e5017e.rtpl.php:63
+msgid "Next :type"
+msgid_plural ""
+msgstr[0] ":type suivant"
+msgstr[1] ""
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
 msgid "Edit Shaare"
 msgstr "Modifier le Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:18
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:21
 msgid "New Shaare"
 msgstr "Nouveau Shaare"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:26
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
 msgid "Created:"
 msgstr "Création :"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:29
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:32
 msgid "URL"
 msgstr "URL"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:38
 msgid "Title"
 msgstr "Titre"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:49
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:75
 #: tmp/pluginsadmin.b91ef64efc3688266305ea9b42e5017e.rtpl.php:99
@@ -1088,33 +1224,34 @@ msgstr "Titre"
 msgid "Description"
 msgstr "Description"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
-msgid "Tags"
-msgstr "Tags"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:60
-#: tmp/export.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
-#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:169
-msgid "Private"
-msgstr "Privé"
-
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:66
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php: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:68
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:82
 msgid "Markdown syntax documentation"
 msgstr "Documentation sur la syntaxe Markdown"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:69
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:83
 msgid "Markdown syntax"
 msgstr "la syntaxe Markdown"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:88
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:102
+msgid "Cancel"
+msgstr "Annuler"
+
+#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:121
 msgid "Apply Changes"
 msgstr "Appliquer les changements"
 
-#: tmp/editlink.b91ef64efc3688266305ea9b42e5017e.rtpl.php:93
+#: tmp/editlink.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
@@ -1179,10 +1316,6 @@ msgstr "Les doublons s'appuient sur les URL"
 msgid "Add default tags"
 msgstr "Ajouter des tags par défaut"
 
-#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:22
-msgid "Install Shaarli"
-msgstr "Installation de Shaarli"
-
 #: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:25
 msgid "It looks like it's the first time you run Shaarli. Please configure it."
 msgstr ""
@@ -1215,6 +1348,10 @@ msgstr "Mes liens"
 msgid "Install"
 msgstr "Installer"
 
+#: tmp/install.b91ef64efc3688266305ea9b42e5017e.rtpl.php:190
+msgid "Server requirements"
+msgstr "Pré-requis serveur"
+
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:14
 #: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:79
 msgid "shaare"
@@ -1313,6 +1450,10 @@ msgstr "Changer statut Ã©pinglé"
 msgid "Sticky"
 msgstr "Épinglé"
 
+#: tmp/linklist.b91ef64efc3688266305ea9b42e5017e.rtpl.php:189
+msgid "Share a private link"
+msgstr "Partager un lien privé"
+
 #: tmp/linklist.paging.b91ef64efc3688266305ea9b42e5017e.rtpl.php:5
 #: tmp/linklist.paging.cedf684561d925457130839629000a81.rtpl.php:5
 msgid "Filters"
@@ -1511,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"
@@ -1561,15 +1796,19 @@ msgstr "Configurer Shaarli"
 msgid "Enable, disable and configure plugins"
 msgstr "Activer, désactiver et configurer les extensions"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:28
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:27
+msgid "Check instance's server configuration"
+msgstr "Vérifier la configuration serveur de l'instance"
+
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:34
 msgid "Change your password"
 msgstr "Modifier le mot de passe"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:35
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
 msgid "Rename or delete a tag in all links"
 msgstr "Renommer ou supprimer un tag dans tous les liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:41
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
 msgid ""
 "Import Netscape HTML bookmarks (as exported from Firefox, Chrome, Opera, "
 "delicious...)"
@@ -1577,11 +1816,11 @@ msgstr ""
 "Importer des marques pages au format Netscape HTML (comme exportés depuis "
 "Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:42
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
 msgid "Import links"
 msgstr "Importer des liens"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:47
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:53
 msgid ""
 "Export Netscape HTML bookmarks (which can be imported in Firefox, Chrome, "
 "Opera, delicious...)"
@@ -1589,15 +1828,11 @@ msgstr ""
 "Exporter les marques pages au format Netscape HTML (comme exportés depuis "
 "Firefox, Chrome, Opera, delicious...)"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:48
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:54
 msgid "Export database"
 msgstr "Exporter les données"
 
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:55
-msgid "Synchronize all link thumbnails"
-msgstr "Synchroniser toutes les miniatures"
-
-#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:81
+#: tmp/tools.b91ef64efc3688266305ea9b42e5017e.rtpl.php:77
 msgid ""
 "Drag one of these button to your bookmarks toolbar or right-click it and "
 "\"Bookmark This Link\""
@@ -1605,13 +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"
@@ -1619,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"
@@ -1660,11 +1895,11 @@ msgstr ""
 "Glisser ce lien dans votre barre de favoris ou cliquer droit dessus et Â« "
 "Ajouter aux favoris Â»"
 
-#~ msgid "Provided data is invalid"
-#~ msgstr "Les informations fournies ne sont pas valides"
+#~ msgid "Display:"
+#~ msgstr "Afficher :"
 
-#~ msgid "Rename"
-#~ msgstr "Renommer"
+#~ msgid "The Daily Shaarli"
+#~ msgstr "Le Quotidien Shaarli"
 
 #, fuzzy
 #~| msgid "Selection"
index 1b10ee41c8299f196b510bde155d6c09e8bd045b..4b5602ac8b364c10a73890a9f342a8165197290a 100644 (file)
--- a/index.php
+++ b/index.php
@@ -125,13 +125,15 @@ $app->group('/admin', function () {
     $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save');
     $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index');
     $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save');
-    $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:addShaare');
-    $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayCreateForm');
-    $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ManageShaareController:displayEditForm');
-    $this->post('/shaare', '\Shaarli\Front\Controller\Admin\ManageShaareController:save');
-    $this->get('/shaare/delete', '\Shaarli\Front\Controller\Admin\ManageShaareController:deleteBookmark');
-    $this->get('/shaare/visibility', '\Shaarli\Front\Controller\Admin\ManageShaareController:changeVisibility');
-    $this->get('/shaare/{id:[0-9]+}/pin', '\Shaarli\Front\Controller\Admin\ManageShaareController:pinBookmark');
+    $this->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'
@@ -143,6 +145,8 @@ $app->group('/admin', function () {
     $this->get('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:index');
     $this->post('/plugins', '\Shaarli\Front\Controller\Admin\PluginsController:save');
     $this->get('/token', '\Shaarli\Front\Controller\Admin\TokenController:getToken');
+    $this->get('/server', '\Shaarli\Front\Controller\Admin\ServerController:index');
+    $this->get('/clear-cache', '\Shaarli\Front\Controller\Admin\ServerController:clearCache');
     $this->get('/thumbnails', '\Shaarli\Front\Controller\Admin\ThumbnailsController:index');
     $this->get('/metadata', '\Shaarli\Front\Controller\Admin\MetadataController:ajaxRetrieveTitle');
     $this->get('/visibility/{visibility}', '\Shaarli\Front\Controller\Admin\SessionFilterController:visibility');
index ab0e4ea743648684298d048a1e2e01ea7f9c9d02..d84627129516969bff1e1baed69540d415aea369 100644 (file)
--- a/init.php
+++ b/init.php
@@ -2,7 +2,7 @@
 
 require_once __DIR__ . '/vendor/autoload.php';
 
-use Shaarli\ApplicationUtils;
+use Shaarli\Helper\ApplicationUtils;
 use Shaarli\Security\SessionManager;
 
 // Set 'UTC' as the default timezone if it is not defined in php.ini
index 7ff92f5c96968c6e12c75098dc164526c0a3224d..e12f803be3ce99004294b926e4e14dd7b98f0ba1 100644 (file)
@@ -92,8 +92,8 @@ class PostLinkTest extends TestCase
 
         $mock = $this->createMock(Router::class);
         $mock->expects($this->any())
-             ->method('relativePathFor')
-             ->willReturn('api/v1/bookmarks/1');
+             ->method('pathFor')
+             ->willReturn('/api/v1/bookmarks/1');
 
         // affect @property-read... seems to work
         $this->controller->getCi()->router = $mock;
@@ -128,7 +128,7 @@ class PostLinkTest extends TestCase
 
         $response = $this->controller->postLink($request, new Response());
         $this->assertEquals(201, $response->getStatusCode());
-        $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]);
+        $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
         $data = json_decode((string) $response->getBody(), true);
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
@@ -175,7 +175,7 @@ class PostLinkTest extends TestCase
         $response = $this->controller->postLink($request, new Response());
 
         $this->assertEquals(201, $response->getStatusCode());
-        $this->assertEquals('api/v1/bookmarks/1', $response->getHeader('Location')[0]);
+        $this->assertEquals('/api/v1/bookmarks/1', $response->getHeader('Location')[0]);
         $data = json_decode((string) $response->getBody(), true);
         $this->assertEquals(self::NB_FIELDS_LINK, count($data));
         $this->assertEquals(43, $data['id']);
index daafd2503369169500923895a1f5c6d625e5875a..f619aff3f7865d7aebddcd4fb06622b52521b2e0 100644 (file)
@@ -685,22 +685,6 @@ class BookmarkFileServiceTest extends TestCase
         $this->assertEquals(0, $linkDB->count());
     }
 
-    /**
-     * List the days for which bookmarks have been posted
-     */
-    public function testDays()
-    {
-        $this->assertSame(
-            ['20100309', '20100310', '20121206', '20121207', '20130614', '20150310'],
-            $this->publicLinkDB->days()
-        );
-
-        $this->assertSame(
-            ['20100309', '20100310', '20121206', '20121207', '20130614', '20141125', '20150310'],
-            $this->privateLinkDB->days()
-        );
-    }
-
     /**
      * The URL corresponds to an existing entry in the DB
      */
@@ -897,6 +881,37 @@ class BookmarkFileServiceTest extends TestCase
         $this->publicLinkDB->findByHash('');
     }
 
+    /**
+     * Test filterHash() on a private bookmark while logged out.
+     */
+    public function testFilterHashPrivateWhileLoggedOut()
+    {
+        $this->expectException(BookmarkNotFoundException::class);
+        $this->expectExceptionMessage('The link you are trying to reach does not exist or has been deleted');
+
+        $hash = smallHash('20141125_084734' . 6);
+
+        $this->publicLinkDB->findByHash($hash);
+    }
+
+    /**
+     * Test filterHash() with private key.
+     */
+    public function testFilterHashWithPrivateKey()
+    {
+        $hash = smallHash('20141125_084734' . 6);
+        $privateKey = 'this is usually auto generated';
+
+        $bookmark = $this->privateLinkDB->findByHash($hash);
+        $bookmark->addAdditionalContentEntry('private_key', $privateKey);
+        $this->privateLinkDB->save();
+
+        $this->privateLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+        $bookmark = $this->privateLinkDB->findByHash($hash, $privateKey);
+
+        static::assertSame(6, $bookmark->getId());
+    }
+
     /**
      * Test linksCountPerTag all tags without filter.
      * Equal occurrences should be sorted alphabetically.
@@ -1043,33 +1058,105 @@ class BookmarkFileServiceTest extends TestCase
     }
 
     /**
-     * Test filterDay while logged in
+     * Test find by dates in the middle of the datastore (sorted by dates) with a single bookmark as a result.
      */
-    public function testFilterDayLoggedIn(): void
+    public function testFilterByDateMidTimePeriodSingleBookmark(): void
     {
-        $bookmarks = $this->privateLinkDB->filterDay('20121206');
-        $expectedIds = [4, 9, 1, 0];
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_160000'),
+            $before,
+            $after
+        );
 
-        static::assertCount(4, $bookmarks);
-        foreach ($bookmarks as $bookmark) {
-            $i = ($i ?? -1) + 1;
-            static::assertSame($expectedIds[$i], $bookmark->getId());
-        }
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(9, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_172539'), $after);
     }
 
     /**
-     * Test filterDay while logged out
+     * Test find by dates in the middle of the datastore (sorted by dates) with a multiple bookmarks as a result.
      */
-    public function testFilterDayLoggedOut(): void
+    public function testFilterByDateMidTimePeriodMultipleBookmarks(): void
     {
-        $bookmarks = $this->publicLinkDB->filterDay('20121206');
-        $expectedIds = [4, 9, 1];
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20121206_150000'),
+            DateTime::createFromFormat('Ymd_His', '20121206_180000'),
+            $before,
+            $after
+        );
 
-        static::assertCount(3, $bookmarks);
-        foreach ($bookmarks as $bookmark) {
-            $i = ($i ?? -1) + 1;
-            static::assertSame($expectedIds[$i], $bookmark->getId());
-        }
+        static::assertCount(2, $bookmarks);
+
+        static::assertSame(1, $bookmarks[0]->getId());
+        static::assertSame(9, $bookmarks[1]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_142300'), $before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20121206_182539'), $after);
+    }
+
+    /**
+     * Test find by dates at the end of the datastore (sorted by dates).
+     */
+    public function testFilterByDateLastTimePeriod(): void
+    {
+        $after = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20150310_114640'),
+            DateTime::createFromFormat('Ymd_His', '20450101_010101'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(41, $bookmarks[0]->getId());
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20150310_114633'), $before);
+        static::assertNull($after);
+    }
+
+    /**
+     * Test find by dates at the beginning of the datastore (sorted by dates).
+     */
+    public function testFilterByDateFirstTimePeriod(): void
+    {
+        $before = new DateTime();
+        $bookmarks = $this->privateLinkDB->findByDate(
+            DateTime::createFromFormat('Ymd_His', '20000101_101010'),
+            DateTime::createFromFormat('Ymd_His', '20100309_110000'),
+            $before,
+            $after
+        );
+
+        static::assertCount(1, $bookmarks);
+
+        static::assertSame(11, $bookmarks[0]->getId());
+        static::assertNull($before);
+        static::assertEquals(DateTime::createFromFormat('Ymd_His', '20100310_101010'), $after);
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestWithSticky(): void
+    {
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertSame(41, $bookmark->getId());
+    }
+
+    /**
+     * Test getLatest with a sticky bookmark: it should be ignored and return the latest by creation date instead.
+     */
+    public function testGetLatestEmptyDatastore(): void
+    {
+        unlink($this->conf->get('resource.datastore'));
+        $this->publicLinkDB = new BookmarkFileService($this->conf, $this->history, $this->mutex, false);
+
+        $bookmark = $this->publicLinkDB->getLatest();
+
+        static::assertNull($bookmark);
     }
 
     /**
diff --git a/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php b/tests/front/controller/admin/ManageShaareControllerTest/AddShaareTest.php
deleted file mode 100644 (file)
index 0f27ec2..0000000
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
-
-use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
-use Shaarli\Http\HttpAccess;
-use Shaarli\TestCase;
-use Slim\Http\Request;
-use Slim\Http\Response;
-
-class AddShaareTest extends TestCase
-{
-    use FrontAdminControllerMockHelper;
-
-    /** @var ManageShaareController */
-    protected $controller;
-
-    public function setUp(): void
-    {
-        $this->createContainer();
-
-        $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
-    }
-
-    /**
-     * Test displaying add link page
-     */
-    public function testAddShaare(): void
-    {
-        $assignedVariables = [];
-        $this->assignTemplateVars($assignedVariables);
-
-        $request = $this->createMock(Request::class);
-        $response = new Response();
-
-        $result = $this->controller->addShaare($request, $response);
-
-        static::assertSame(200, $result->getStatusCode());
-        static::assertSame('addlink', (string) $result->getBody());
-
-        static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']);
-    }
-}
diff --git a/tests/front/controller/admin/ServerControllerTest.php b/tests/front/controller/admin/ServerControllerTest.php
new file mode 100644 (file)
index 0000000..355cce7
--- /dev/null
@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Security\SessionManager;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Test Server administration controller.
+ */
+class ServerControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ServerController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->controller = new ServerController($this->container);
+
+        // initialize dummy cache
+        @mkdir('sandbox/');
+        foreach (['pagecache', 'tmp', 'cache'] as $folder) {
+            @mkdir('sandbox/' . $folder);
+            @touch('sandbox/' . $folder . '/.htaccess');
+            @touch('sandbox/' . $folder . '/1');
+            @touch('sandbox/' . $folder . '/2');
+        }
+    }
+
+    public function tearDown(): void
+    {
+        foreach (['pagecache', 'tmp', 'cache'] as $folder) {
+            @unlink('sandbox/' . $folder . '/.htaccess');
+            @unlink('sandbox/' . $folder . '/1');
+            @unlink('sandbox/' . $folder . '/2');
+            @rmdir('sandbox/' . $folder);
+        }
+    }
+
+    /**
+     * Test default display of server administration page.
+     */
+    public function testIndex(): void
+    {
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+       // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('server', (string) $result->getBody());
+
+        static::assertSame(PHP_VERSION, $assignedVariables['php_version']);
+        static::assertArrayHasKey('php_has_reached_eol', $assignedVariables);
+        static::assertArrayHasKey('php_eol', $assignedVariables);
+        static::assertArrayHasKey('php_extensions', $assignedVariables);
+        static::assertArrayHasKey('permissions', $assignedVariables);
+        static::assertEmpty($assignedVariables['permissions']);
+
+        static::assertRegExp(
+            '#https://github\.com/shaarli/Shaarli/releases/tag/v\d+\.\d+\.\d+#',
+            $assignedVariables['release_url']
+        );
+        static::assertRegExp('#v\d+\.\d+\.\d+#', $assignedVariables['latest_version']);
+        static::assertRegExp('#(v\d+\.\d+\.\d+|dev)#', $assignedVariables['current_version']);
+        static::assertArrayHasKey('index_url', $assignedVariables);
+        static::assertArrayHasKey('client_ip', $assignedVariables);
+        static::assertArrayHasKey('trusted_proxies', $assignedVariables);
+
+        static::assertSame('Server administration - Shaarli', $assignedVariables['pagetitle']);
+    }
+
+    /**
+     * Test clearing the main cache
+     */
+    public function testClearMainCache(): void
+    {
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'resource.page_cache') {
+                return 'sandbox/pagecache';
+            } elseif ($key === 'resource.raintpl_tmp') {
+                return 'sandbox/tmp';
+            } elseif ($key === 'resource.thumbnails_cache') {
+                return 'sandbox/cache';
+            } else {
+                return $default;
+            }
+        });
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->with(SessionManager::KEY_SUCCESS_MESSAGES, ['Shaarli\'s cache folder has been cleared!'])
+        ;
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('type')->willReturn('main');
+        $response = new Response();
+
+        $result = $this->controller->clearCache($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location'));
+
+        static::assertFileNotExists('sandbox/pagecache/1');
+        static::assertFileNotExists('sandbox/pagecache/2');
+        static::assertFileNotExists('sandbox/tmp/1');
+        static::assertFileNotExists('sandbox/tmp/2');
+
+        static::assertFileExists('sandbox/pagecache/.htaccess');
+        static::assertFileExists('sandbox/tmp/.htaccess');
+        static::assertFileExists('sandbox/cache');
+        static::assertFileExists('sandbox/cache/.htaccess');
+        static::assertFileExists('sandbox/cache/1');
+        static::assertFileExists('sandbox/cache/2');
+    }
+
+    /**
+     * Test clearing thumbnails cache
+     */
+    public function testClearThumbnailsCache(): void
+    {
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            if ($key === 'resource.page_cache') {
+                return 'sandbox/pagecache';
+            } elseif ($key === 'resource.raintpl_tmp') {
+                return 'sandbox/tmp';
+            } elseif ($key === 'resource.thumbnails_cache') {
+                return 'sandbox/cache';
+            } else {
+                return $default;
+            }
+        });
+
+        $this->container->sessionManager
+            ->expects(static::once())
+            ->method('setSessionParameter')
+            ->willReturnCallback(function (string $key, array $value): SessionManager {
+                static::assertSame(SessionManager::KEY_WARNING_MESSAGES, $key);
+                static::assertCount(1, $value);
+                static::assertStringStartsWith('Thumbnails cache has been cleared.', $value[0]);
+
+                return $this->container->sessionManager;
+            });
+        ;
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->with('type')->willReturn('thumbnails');
+        $response = new Response();
+
+        $result = $this->controller->clearCache($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/admin/server', (string) $result->getHeaderLine('Location'));
+
+        static::assertFileNotExists('sandbox/cache/1');
+        static::assertFileNotExists('sandbox/cache/2');
+
+        static::assertFileExists('sandbox/cache/.htaccess');
+        static::assertFileExists('sandbox/pagecache');
+        static::assertFileExists('sandbox/pagecache/.htaccess');
+        static::assertFileExists('sandbox/pagecache/1');
+        static::assertFileExists('sandbox/pagecache/2');
+        static::assertFileExists('sandbox/tmp');
+        static::assertFileExists('sandbox/tmp/.htaccess');
+        static::assertFileExists('sandbox/tmp/1');
+        static::assertFileExists('sandbox/tmp/2');
+    }
+}
diff --git a/tests/front/controller/admin/ShaareAddControllerTest.php b/tests/front/controller/admin/ShaareAddControllerTest.php
new file mode 100644 (file)
index 0000000..a27ebe6
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin;
+
+use Shaarli\Config\ConfigManager;
+use Shaarli\Formatter\BookmarkMarkdownFormatter;
+use Shaarli\Http\HttpAccess;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class ShaareAddControllerTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaareAddController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaareAddController($this->container);
+    }
+
+    /**
+     * Test displaying add link page
+     */
+    public function testAddShaare(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $expectedTags = [
+            'tag1' => 32,
+            'tag2' => 24,
+            'tag3' => 1,
+        ];
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($expectedTags)
+        ;
+        $expectedTags = array_merge($expectedTags, [BookmarkMarkdownFormatter::NO_MD_TAG => 1]);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key, $default) {
+            return $key === 'formatter' ? 'markdown' : $default;
+        });
+
+        $result = $this->controller->addShaare($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('addlink', (string) $result->getBody());
+
+        static::assertSame('Shaare a new link - Shaarli', $assignedVariables['pagetitle']);
+        static::assertFalse($assignedVariables['default_private_links']);
+        static::assertTrue($assignedVariables['async_metadata']);
+        static::assertSame($expectedTags, $assignedVariables['tags']);
+    }
+
+    /**
+     * Test displaying add link page
+     */
+    public function testAddShaareWithoutMd(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $expectedTags = [
+            'tag1' => 32,
+            'tag2' => 24,
+            'tag3' => 1,
+        ];
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('bookmarksCountPerTag')
+            ->willReturn($expectedTags)
+        ;
+
+        $result = $this->controller->addShaare($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('addlink', (string) $result->getBody());
+
+        static::assertSame($expectedTags, $assignedVariables['tags']);
+    }
+}
similarity index 98%
rename from tests/front/controller/admin/ManageShaareControllerTest/ChangeVisibilityBookmarkTest.php
rename to tests/front/controller/admin/ShaareManageControllerTest/ChangeVisibilityBookmarkTest.php
index 096d077435886686484b627a035ef769ea5d2d65..28b1c023192c780a5a8cc0d620c60e90d664a85a 100644 (file)
@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
@@ -10,7 +10,7 @@ use Shaarli\Formatter\BookmarkFormatter;
 use Shaarli\Formatter\BookmarkRawFormatter;
 use Shaarli\Formatter\FormatterFactory;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -21,7 +21,7 @@ class ChangeVisibilityBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaareManageController */
     protected $controller;
 
     public function setUp(): void
@@ -29,7 +29,7 @@ class ChangeVisibilityBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaareManageController($this->container);
     }
 
     /**
similarity index 98%
rename from tests/front/controller/admin/ManageShaareControllerTest/DeleteBookmarkTest.php
rename to tests/front/controller/admin/ShaareManageControllerTest/DeleteBookmarkTest.php
index 83bbee7c39909c8867028d3908e5e44df0ac92a3..770a16d7c543b70c1265ec90d85b2348e969661e 100644 (file)
@@ -2,14 +2,14 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Formatter\BookmarkFormatter;
 use Shaarli\Formatter\FormatterFactory;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -20,7 +20,7 @@ class DeleteBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaareManageController */
     protected $controller;
 
     public function setUp(): void
@@ -28,7 +28,7 @@ class DeleteBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaareManageController($this->container);
     }
 
     /**
similarity index 95%
rename from tests/front/controller/admin/ManageShaareControllerTest/PinBookmarkTest.php
rename to tests/front/controller/admin/ShaareManageControllerTest/PinBookmarkTest.php
index 50ce7df14fabe73c7005125d9970ccb43091284e..b89206ce19e077f6ec5e0bdcd0986df1b3a6aecf 100644 (file)
@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -18,7 +18,7 @@ class PinBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaareManageController */
     protected $controller;
 
     public function setUp(): void
@@ -26,7 +26,7 @@ class PinBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaareManageController($this->container);
     }
 
     /**
diff --git a/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php b/tests/front/controller/admin/ShaareManageControllerTest/SharePrivateTest.php
new file mode 100644 (file)
index 0000000..ae61dfb
--- /dev/null
@@ -0,0 +1,139 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaareManageControllerTest;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaareManageController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+/**
+ * Test GET /admin/shaare/private/{hash}
+ */
+class SharePrivateTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaareManageController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->controller = new ShaareManageController($this->container);
+    }
+
+    /**
+     * Test shaare private with a private bookmark which does not have a key yet.
+     */
+    public function testSharePrivateWithNewPrivateBookmark(): void
+    {
+        $hash = 'abcdcef';
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setPrivate(true)
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('set')
+            ->with($bookmark, true)
+            ->willReturnCallback(function (Bookmark $bookmark): Bookmark {
+                static::assertSame(32, strlen($bookmark->getAdditionalContentEntry('private_key')));
+
+                return $bookmark;
+            })
+        ;
+
+        $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertRegExp('#/subfolder/shaare/' . $hash . '\?key=\w{32}#', $result->getHeaderLine('Location'));
+    }
+
+    /**
+     * Test shaare private with a private bookmark which does already have a key.
+     */
+    public function testSharePrivateWithExistingPrivateBookmark(): void
+    {
+        $hash = 'abcdcef';
+        $existingKey = 'this is a private key';
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setPrivate(true)
+            ->addAdditionalContentEntry('private_key', $existingKey)
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::never())
+            ->method('set')
+        ;
+
+        $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/shaare/' . $hash . '?key=' . $existingKey, $result->getHeaderLine('Location'));
+    }
+
+    /**
+     * Test shaare private with a public bookmark.
+     */
+    public function testSharePrivateWithPublicBookmark(): void
+    {
+        $hash = 'abcdcef';
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $bookmark = (new Bookmark())
+            ->setId(123)
+            ->setUrl('http://domain.tld')
+            ->setTitle('Title 123')
+            ->setPrivate(false)
+        ;
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash)
+            ->willReturn($bookmark)
+        ;
+        $this->container->bookmarkService
+            ->expects(static::never())
+            ->method('set')
+        ;
+
+        $result = $this->controller->sharePrivate($request, $response, ['hash' => $hash]);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame('/subfolder/shaare/' . $hash, $result->getHeaderLine('Location'));
+    }
+}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateBatchFormTest.php
new file mode 100644 (file)
index 0000000..ce8e112
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
+
+use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
+use Shaarli\Http\HttpAccess;
+use Shaarli\Http\MetadataRetriever;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+use Slim\Http\Response;
+
+class DisplayCreateBatchFormTest extends TestCase
+{
+    use FrontAdminControllerMockHelper;
+
+    /** @var ShaarePublishController */
+    protected $controller;
+
+    public function setUp(): void
+    {
+        $this->createContainer();
+
+        $this->container->httpAccess = $this->createMock(HttpAccess::class);
+        $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
+        $this->controller = new ShaarePublishController($this->container);
+    }
+
+    /**
+     * TODO
+     */
+    public function testDisplayCreateFormBatch(): void
+    {
+        $urls = [
+            'https://domain1.tld/url1',
+            'https://domain2.tld/url2',
+            ' ',
+            'https://domain3.tld/url3',
+        ];
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key) use ($urls): ?string {
+            return $key === 'urls' ? implode(PHP_EOL, $urls) : null;
+        });
+        $response = new Response();
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->displayCreateBatchForms($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('editlink.batch', (string) $result->getBody());
+
+        static::assertTrue($assignedVariables['batch_mode']);
+        static::assertCount(3, $assignedVariables['links']);
+        static::assertSame($urls[0], $assignedVariables['links'][0]['link']['url']);
+        static::assertSame($urls[1], $assignedVariables['links'][1]['link']['url']);
+        static::assertSame($urls[3], $assignedVariables['links'][2]['link']['url']);
+    }
+}
similarity index 98%
rename from tests/front/controller/admin/ManageShaareControllerTest/DisplayCreateFormTest.php
rename to tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
index eafa54ebae8a32a6bf877f4f1ab98ea927a0307b..f20b1def5f1b36196078431d83bc218195fa9945 100644 (file)
@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Http\MetadataRetriever;
 use Shaarli\TestCase;
@@ -18,7 +18,7 @@ class DisplayCreateFormTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaarePublishController */
     protected $controller;
 
     public function setUp(): void
@@ -27,7 +27,7 @@ class DisplayCreateFormTest extends TestCase
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
         $this->container->metadataRetriever = $this->createMock(MetadataRetriever::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaarePublishController($this->container);
     }
 
     /**
similarity index 95%
rename from tests/front/controller/admin/ManageShaareControllerTest/DisplayEditFormTest.php
rename to tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
index 2dc3f41c65b303bbb658ee6cf57bfea94ed4f607..da393e4936ad8efa008ccf780aac5f2e2ef6ec1c 100644 (file)
@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -18,7 +18,7 @@ class DisplayEditFormTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaarePublishController */
     protected $controller;
 
     public function setUp(): void
@@ -26,7 +26,7 @@ class DisplayEditFormTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaarePublishController($this->container);
     }
 
     /**
similarity index 98%
rename from tests/front/controller/admin/ManageShaareControllerTest/SaveBookmarkTest.php
rename to tests/front/controller/admin/ShaarePublishControllerTest/SaveBookmarkTest.php
index 1adeef5a463ec14fd84f62e08fee99feb1c3623b..b6a861bc448c81b7ebcaa60ad0df3fdcd6495b00 100644 (file)
@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace Shaarli\Front\Controller\Admin\ManageShaareControllerTest;
+namespace Shaarli\Front\Controller\Admin\ShaarePublishControllerTest;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Config\ConfigManager;
 use Shaarli\Front\Controller\Admin\FrontAdminControllerMockHelper;
-use Shaarli\Front\Controller\Admin\ManageShaareController;
+use Shaarli\Front\Controller\Admin\ShaarePublishController;
 use Shaarli\Front\Exception\WrongTokenException;
 use Shaarli\Http\HttpAccess;
 use Shaarli\Security\SessionManager;
@@ -20,7 +20,7 @@ class SaveBookmarkTest extends TestCase
 {
     use FrontAdminControllerMockHelper;
 
-    /** @var ManageShaareController */
+    /** @var ShaarePublishController */
     protected $controller;
 
     public function setUp(): void
@@ -28,7 +28,7 @@ class SaveBookmarkTest extends TestCase
         $this->createContainer();
 
         $this->container->httpAccess = $this->createMock(HttpAccess::class);
-        $this->controller = new ManageShaareController($this->container);
+        $this->controller = new ShaarePublishController($this->container);
     }
 
     /**
index 5ca9250774164fb284402c317c3fe859dcf46214..5cbc8c732a76f8726564b3a85138cc9f2cd6473b 100644 (file)
@@ -291,6 +291,37 @@ class BookmarkListControllerTest extends TestCase
         );
     }
 
+    /**
+     * Test GET /shaare/{hash}?key={key} - Find a link by hash using a private link.
+     */
+    public function testPermalinkWithPrivateKey(): void
+    {
+        $hash = 'abcdef';
+        $privateKey = 'this is a private key';
+
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $request = $this->createMock(Request::class);
+        $request->method('getParam')->willReturnCallback(function (string $key, $default = null) use ($privateKey) {
+            return $key === 'key' ? $privateKey : $default;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByHash')
+            ->with($hash, $privateKey)
+            ->willReturn((new Bookmark())->setId(123)->setTitle('Title 1')->setUrl('http://url1.tld'))
+        ;
+
+        $result = $this->controller->permalink($request, $response, ['hash' => $hash]);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('linklist', (string) $result->getBody());
+        static::assertCount(1, $assignedVariables['links']);
+    }
+
     /**
      * Test getting link list with thumbnail updates.
      *   -> 2 thumbnails update, only 1 datastore write
index fc78bc13dc5020411198d2f710ccda1dc79fa016..70fbce5482d75ff9beef4c423dfb3d3e52de094e 100644 (file)
@@ -28,52 +28,49 @@ class DailyControllerTest extends TestCase
     public function testValidIndexControllerInvokeDefault(): void
     {
         $currentDay = new \DateTimeImmutable('2020-05-13');
+        $previousDate = new \DateTime('2 days ago 00:00:00');
+        $nextDate = new \DateTime('today 00:00:00');
 
         $request = $this->createMock(Request::class);
-        $request->method('getQueryParam')->willReturn($currentDay->format('Ymd'));
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'day' ? $currentDay->format('Ymd') : null;
+        });
         $response = new Response();
 
         // Save RainTPL assigned variables
         $assignedVariables = [];
         $this->assignTemplateVars($assignedVariables);
 
-        // Links dataset: 2 links with thumbnails
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('days')
-            ->willReturnCallback(function () use ($currentDay): array {
-               return [
-                   '20200510',
-                   $currentDay->format('Ymd'),
-                   '20200516',
-               ];
-            })
-        ;
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('filterDay')
-            ->willReturnCallback(function (): array {
-                return [
-                    (new Bookmark())
-                        ->setId(1)
-                        ->setUrl('http://url.tld')
-                        ->setTitle(static::generateString(50))
-                        ->setDescription(static::generateString(500))
-                    ,
-                    (new Bookmark())
-                        ->setId(2)
-                        ->setUrl('http://url2.tld')
-                        ->setTitle(static::generateString(50))
-                        ->setDescription(static::generateString(500))
-                    ,
-                    (new Bookmark())
-                        ->setId(3)
-                        ->setUrl('http://url3.tld')
-                        ->setTitle(static::generateString(50))
-                        ->setDescription(static::generateString(500))
-                    ,
-                ];
-            })
+            ->method('findByDate')
+            ->willReturnCallback(
+                function ($from, $to, &$previous, &$next) use ($currentDay, $previousDate, $nextDate): array {
+                    $previous = $previousDate;
+                    $next = $nextDate;
+
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(3)
+                            ->setUrl('http://url3.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
         ;
 
         // Make sure that PluginManager hook is triggered
@@ -81,20 +78,22 @@ class DailyControllerTest extends TestCase
             ->expects(static::atLeastOnce())
             ->method('executeHooks')
             ->withConsecutive(['render_daily'])
-            ->willReturnCallback(function (string $hook, array $data, array $param) use ($currentDay): array {
-                if ('render_daily' === $hook) {
-                    static::assertArrayHasKey('linksToDisplay', $data);
-                    static::assertCount(3, $data['linksToDisplay']);
-                    static::assertSame(1, $data['linksToDisplay'][0]['id']);
-                    static::assertSame($currentDay->getTimestamp(), $data['day']);
-                    static::assertSame('20200510', $data['previousday']);
-                    static::assertSame('20200516', $data['nextday']);
-
-                    static::assertArrayHasKey('loggedin', $param);
+            ->willReturnCallback(
+                function (string $hook, array $data, array $param) use ($currentDay, $previousDate, $nextDate): array {
+                    if ('render_daily' === $hook) {
+                        static::assertArrayHasKey('linksToDisplay', $data);
+                        static::assertCount(3, $data['linksToDisplay']);
+                        static::assertSame(1, $data['linksToDisplay'][0]['id']);
+                        static::assertSame($currentDay->getTimestamp(), $data['day']);
+                        static::assertSame($previousDate->format('Ymd'), $data['previousday']);
+                        static::assertSame($nextDate->format('Ymd'), $data['nextday']);
+
+                        static::assertArrayHasKey('loggedin', $param);
+                    }
+
+                    return $data;
                 }
-
-                return $data;
-            })
+            )
         ;
 
         $result = $this->controller->index($request, $response);
@@ -107,6 +106,11 @@ class DailyControllerTest extends TestCase
         );
         static::assertEquals($currentDay, $assignedVariables['dayDate']);
         static::assertEquals($currentDay->getTimestamp(), $assignedVariables['day']);
+        static::assertSame($previousDate->format('Ymd'), $assignedVariables['previousday']);
+        static::assertSame($nextDate->format('Ymd'), $assignedVariables['nextday']);
+        static::assertSame('day', $assignedVariables['type']);
+        static::assertSame('May 13, 2020', $assignedVariables['dayDesc']);
+        static::assertSame('Daily', $assignedVariables['localizedType']);
         static::assertCount(3, $assignedVariables['linksToDisplay']);
 
         $link = $assignedVariables['linksToDisplay'][0];
@@ -171,26 +175,19 @@ class DailyControllerTest extends TestCase
         $currentDay = new \DateTimeImmutable('2020-05-13');
 
         $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'day' ? $currentDay->format('Ymd') : null;
+        });
         $response = new Response();
 
         // Save RainTPL assigned variables
         $assignedVariables = [];
         $this->assignTemplateVars($assignedVariables);
 
-        // Links dataset: 2 links with thumbnails
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('days')
+            ->method('findByDate')
             ->willReturnCallback(function () use ($currentDay): array {
-                return [
-                    $currentDay->format($currentDay->format('Ymd')),
-                ];
-            })
-        ;
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('filterDay')
-            ->willReturnCallback(function (): array {
                 return [
                     (new Bookmark())
                         ->setId(1)
@@ -250,20 +247,10 @@ class DailyControllerTest extends TestCase
         $assignedVariables = [];
         $this->assignTemplateVars($assignedVariables);
 
-        // Links dataset: 2 links with thumbnails
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('days')
+            ->method('findByDate')
             ->willReturnCallback(function () use ($currentDay): array {
-                return [
-                    $currentDay->format($currentDay->format('Ymd')),
-                ];
-            })
-        ;
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('filterDay')
-            ->willReturnCallback(function (): array {
                 return [
                     (new Bookmark())->setId(1)->setUrl('http://url.tld')->setTitle('title'),
                     (new Bookmark())
@@ -320,14 +307,7 @@ class DailyControllerTest extends TestCase
         // Links dataset: 2 links with thumbnails
         $this->container->bookmarkService
             ->expects(static::once())
-            ->method('days')
-            ->willReturnCallback(function (): array {
-                return [];
-            })
-        ;
-        $this->container->bookmarkService
-            ->expects(static::once())
-            ->method('filterDay')
+            ->method('findByDate')
             ->willReturnCallback(function (): array {
                 return [];
             })
@@ -347,7 +327,7 @@ class DailyControllerTest extends TestCase
         static::assertSame(200, $result->getStatusCode());
         static::assertSame('daily', (string) $result->getBody());
         static::assertCount(0, $assignedVariables['linksToDisplay']);
-        static::assertSame('Today', $assignedVariables['dayDesc']);
+        static::assertSame('Today - ' . (new \DateTime())->format('F j, Y'), $assignedVariables['dayDesc']);
         static::assertEquals((new \DateTime())->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
         static::assertEquals((new \DateTime())->setTime(0, 0), $assignedVariables['dayDate']);
     }
@@ -361,6 +341,7 @@ class DailyControllerTest extends TestCase
             new \DateTimeImmutable('2020-05-17'),
             new \DateTimeImmutable('2020-05-15'),
             new \DateTimeImmutable('2020-05-13'),
+            new \DateTimeImmutable('+1 month'),
         ];
 
         $request = $this->createMock(Request::class);
@@ -371,6 +352,7 @@ class DailyControllerTest extends TestCase
             (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
             (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
             (new Bookmark())->setId(4)->setCreated($dates[2])->setUrl('http://domain.tld/4'),
+            (new Bookmark())->setId(5)->setCreated($dates[3])->setUrl('http://domain.tld/5'),
         ]);
 
         $this->container->pageCacheManager
@@ -397,13 +379,14 @@ class DailyControllerTest extends TestCase
         static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
         static::assertSame('http://shaarli/subfolder/daily-rss', $assignedVariables['page_url']);
         static::assertFalse($assignedVariables['hide_timestamps']);
-        static::assertCount(2, $assignedVariables['days']);
+        static::assertCount(3, $assignedVariables['days']);
 
         $day = $assignedVariables['days'][$dates[0]->format('Ymd')];
+        $date = $dates[0]->setTime(23, 59, 59);
 
-        static::assertEquals($dates[0], $day['date']);
-        static::assertSame($dates[0]->format(\DateTime::RSS), $day['date_rss']);
-        static::assertSame(format_date($dates[0], false), $day['date_human']);
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
         static::assertSame('http://shaarli/subfolder/daily?day='. $dates[0]->format('Ymd'), $day['absolute_url']);
         static::assertCount(1, $day['links']);
         static::assertSame(1, $day['links'][0]['id']);
@@ -411,10 +394,11 @@ class DailyControllerTest extends TestCase
         static::assertEquals($dates[0], $day['links'][0]['created']);
 
         $day = $assignedVariables['days'][$dates[1]->format('Ymd')];
+        $date = $dates[1]->setTime(23, 59, 59);
 
-        static::assertEquals($dates[1], $day['date']);
-        static::assertSame($dates[1]->format(\DateTime::RSS), $day['date_rss']);
-        static::assertSame(format_date($dates[1], false), $day['date_human']);
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
         static::assertSame('http://shaarli/subfolder/daily?day='. $dates[1]->format('Ymd'), $day['absolute_url']);
         static::assertCount(2, $day['links']);
 
@@ -424,6 +408,18 @@ class DailyControllerTest extends TestCase
         static::assertSame(3, $day['links'][1]['id']);
         static::assertSame('http://domain.tld/3', $day['links'][1]['url']);
         static::assertEquals($dates[1], $day['links'][1]['created']);
+
+        $day = $assignedVariables['days'][$dates[2]->format('Ymd')];
+        $date = $dates[2]->setTime(23, 59, 59);
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame(format_date($date, false), $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?day='. $dates[2]->format('Ymd'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+        static::assertSame(4, $day['links'][0]['id']);
+        static::assertSame('http://domain.tld/4', $day['links'][0]['url']);
+        static::assertEquals($dates[2], $day['links'][0]['created']);
     }
 
     /**
@@ -475,4 +471,246 @@ class DailyControllerTest extends TestCase
         static::assertFalse($assignedVariables['hide_timestamps']);
         static::assertCount(0, $assignedVariables['days']);
     }
+
+    /**
+     * Test simple display index with week parameter
+     */
+    public function testSimpleIndexWeekly(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+        $expectedDay = new \DateTimeImmutable('2020-05-11');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'week' ? $currentDay->format('YW') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(
+                function (): array {
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Weekly - Week 20 (May 11, 2020) - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+        static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
+        static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertSame('', $assignedVariables['previousday']);
+        static::assertSame('', $assignedVariables['nextday']);
+        static::assertSame('Week 20 (May 11, 2020)', $assignedVariables['dayDesc']);
+        static::assertSame('week', $assignedVariables['type']);
+        static::assertSame('Weekly', $assignedVariables['localizedType']);
+    }
+
+    /**
+     * Test simple display index with month parameter
+     */
+    public function testSimpleIndexMonthly(): void
+    {
+        $currentDay = new \DateTimeImmutable('2020-05-13');
+        $expectedDay = new \DateTimeImmutable('2020-05-01');
+
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key) use ($currentDay): ?string {
+            return $key === 'month' ? $currentDay->format('Ym') : null;
+        });
+        $response = new Response();
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->bookmarkService
+            ->expects(static::once())
+            ->method('findByDate')
+            ->willReturnCallback(
+                function (): array {
+                    return [
+                        (new Bookmark())
+                            ->setId(1)
+                            ->setUrl('http://url.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                        (new Bookmark())
+                            ->setId(2)
+                            ->setUrl('http://url2.tld')
+                            ->setTitle(static::generateString(50))
+                            ->setDescription(static::generateString(500))
+                        ,
+                    ];
+                }
+            )
+        ;
+
+        $result = $this->controller->index($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertSame('daily', (string) $result->getBody());
+        static::assertSame(
+            'Monthly - May, 2020 - Shaarli',
+            $assignedVariables['pagetitle']
+        );
+
+        static::assertCount(2, $assignedVariables['linksToDisplay']);
+        static::assertEquals($expectedDay->setTime(0, 0), $assignedVariables['dayDate']);
+        static::assertSame($expectedDay->setTime(0, 0)->getTimestamp(), $assignedVariables['day']);
+        static::assertSame('', $assignedVariables['previousday']);
+        static::assertSame('', $assignedVariables['nextday']);
+        static::assertSame('May, 2020', $assignedVariables['dayDesc']);
+        static::assertSame('month', $assignedVariables['type']);
+        static::assertSame('Monthly', $assignedVariables['localizedType']);
+    }
+
+    /**
+     * Test simple display RSS with week parameter
+     */
+    public function testSimpleRssWeekly(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-19'),
+            new \DateTimeImmutable('2020-05-13'),
+        ];
+        $expectedDates = [
+            new \DateTimeImmutable('2020-05-24 23:59:59'),
+            new \DateTimeImmutable('2020-05-17 23:59:59'),
+        ];
+
+        $this->container->environment['QUERY_STRING'] = 'week';
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
+            return $key === 'week' ? '' : null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+        ]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss?week', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(2, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('YW')];
+        $date = $expectedDates[0];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('Week 21 (May 18, 2020)', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?week='. $dates[0]->format('YW'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('YW')];
+        $date = $expectedDates[1];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('Week 20 (May 11, 2020)', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?week='. $dates[1]->format('YW'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+    }
+
+    /**
+     * Test simple display RSS with month parameter
+     */
+    public function testSimpleRssMonthly(): void
+    {
+        $dates = [
+            new \DateTimeImmutable('2020-05-19'),
+            new \DateTimeImmutable('2020-04-13'),
+        ];
+        $expectedDates = [
+            new \DateTimeImmutable('2020-05-31 23:59:59'),
+            new \DateTimeImmutable('2020-04-30 23:59:59'),
+        ];
+
+        $this->container->environment['QUERY_STRING'] = 'month';
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function (string $key): ?string {
+            return $key === 'month' ? '' : null;
+        });
+        $response = new Response();
+
+        $this->container->bookmarkService->expects(static::once())->method('search')->willReturn([
+            (new Bookmark())->setId(1)->setCreated($dates[0])->setUrl('http://domain.tld/1'),
+            (new Bookmark())->setId(2)->setCreated($dates[1])->setUrl('http://domain.tld/2'),
+            (new Bookmark())->setId(3)->setCreated($dates[1])->setUrl('http://domain.tld/3'),
+        ]);
+
+        // Save RainTPL assigned variables
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $result = $this->controller->rss($request, $response);
+
+        static::assertSame(200, $result->getStatusCode());
+        static::assertStringContainsString('application/rss', $result->getHeader('Content-Type')[0]);
+        static::assertSame('dailyrss', (string) $result->getBody());
+        static::assertSame('Shaarli', $assignedVariables['title']);
+        static::assertSame('http://shaarli/subfolder/', $assignedVariables['index_url']);
+        static::assertSame('http://shaarli/subfolder/daily-rss?month', $assignedVariables['page_url']);
+        static::assertFalse($assignedVariables['hide_timestamps']);
+        static::assertCount(2, $assignedVariables['days']);
+
+        $day = $assignedVariables['days'][$dates[0]->format('Ym')];
+        $date = $expectedDates[0];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('May, 2020', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?month='. $dates[0]->format('Ym'), $day['absolute_url']);
+        static::assertCount(1, $day['links']);
+
+        $day = $assignedVariables['days'][$dates[1]->format('Ym')];
+        $date = $expectedDates[1];
+
+        static::assertEquals($date, $day['date']);
+        static::assertSame($date->format(\DateTime::RSS), $day['date_rss']);
+        static::assertSame('April, 2020', $day['date_human']);
+        static::assertSame('http://shaarli/subfolder/daily?month='. $dates[1]->format('Ym'), $day['absolute_url']);
+        static::assertCount(2, $day['links']);
+    }
 }
index 345ad544b85a582ccfab88b9f1a9ad4e74375843..2105ed770cd48b908c4f366b9ad5cc7129ff2b39 100644 (file)
@@ -79,6 +79,15 @@ class InstallControllerTest extends TestCase
         static::assertIsArray($assignedVariables['languages']);
         static::assertSame('Automatic', $assignedVariables['languages']['auto']);
         static::assertSame('French', $assignedVariables['languages']['fr']);
+
+        static::assertSame(PHP_VERSION, $assignedVariables['php_version']);
+        static::assertArrayHasKey('php_has_reached_eol', $assignedVariables);
+        static::assertArrayHasKey('php_eol', $assignedVariables);
+        static::assertArrayHasKey('php_extensions', $assignedVariables);
+        static::assertArrayHasKey('permissions', $assignedVariables);
+        static::assertEmpty($assignedVariables['permissions']);
+
+        static::assertSame('Install Shaarli', $assignedVariables['pagetitle']);
     }
 
     /**
similarity index 81%
rename from tests/ApplicationUtilsTest.php
rename to tests/helper/ApplicationUtilsTest.php
index a232b351f4cfdae5c63c4d31553f055500a4111b..654857b944e7925cfd81b1cd915600495fea9f2a 100644 (file)
@@ -1,7 +1,8 @@
 <?php
-namespace Shaarli;
+namespace Shaarli\Helper;
 
 use Shaarli\Config\ConfigManager;
+use Shaarli\FakeApplicationUtils;
 
 require_once 'tests/utils/FakeApplicationUtils.php';
 
@@ -339,6 +340,35 @@ class ApplicationUtilsTest extends \Shaarli\TestCase
         );
     }
 
+    /**
+     * Checks resource permissions in minimal mode.
+     */
+    public function testCheckCurrentResourcePermissionsErrorsMinimalMode(): void
+    {
+        $conf = new ConfigManager('');
+        $conf->set('resource.thumbnails_cache', 'null/cache');
+        $conf->set('resource.config', 'null/data/config.php');
+        $conf->set('resource.data_dir', 'null/data');
+        $conf->set('resource.datastore', 'null/data/store.php');
+        $conf->set('resource.ban_file', 'null/data/ipbans.php');
+        $conf->set('resource.log', 'null/data/log.txt');
+        $conf->set('resource.page_cache', 'null/pagecache');
+        $conf->set('resource.raintpl_tmp', 'null/tmp');
+        $conf->set('resource.raintpl_tpl', 'null/tpl');
+        $conf->set('resource.raintpl_theme', 'null/tpl/default');
+        $conf->set('resource.update_check', 'null/data/lastupdatecheck.txt');
+
+        static::assertSame(
+            [
+                '"null/tpl" directory is not readable',
+                '"null/tpl/default" directory is not readable',
+                '"null/tmp" directory is not readable',
+                '"null/tmp" directory is not writable'
+            ],
+            ApplicationUtils::checkResourcePermissions($conf, true)
+        );
+    }
+
     /**
      * Check update with 'dev' as curent version (master branch).
      * It should always return false.
@@ -349,4 +379,37 @@ class ApplicationUtilsTest extends \Shaarli\TestCase
             ApplicationUtils::checkUpdate('dev', self::$testUpdateFile, 100, true, true)
         );
     }
+
+    /**
+     * Basic test of getPhpExtensionsRequirement()
+     */
+    public function testGetPhpExtensionsRequirementSimple(): void
+    {
+        static::assertCount(8, ApplicationUtils::getPhpExtensionsRequirement());
+        static::assertSame([
+            'name' => 'json',
+            'required' => true,
+            'desc' => 'Configuration parsing',
+            'loaded' => true,
+        ], ApplicationUtils::getPhpExtensionsRequirement()[0]);
+    }
+
+    /**
+     * Test getPhpEol with a known version: 7.4 -> 2022
+     */
+    public function testGetKnownPhpEol(): void
+    {
+        static::assertSame('2022-11-28', ApplicationUtils::getPhpEol('7.4.7'));
+    }
+
+    /**
+     * Test getPhpEol with an unknown version: 7.4 -> 2022
+     */
+    public function testGetUnknownPhpEol(): void
+    {
+        static::assertSame(
+            (((int) (new \DateTime())->format('Y')) + 2) . (new \DateTime())->format('-m-d'),
+            ApplicationUtils::getPhpEol('7.51.34')
+        );
+    }
 }
diff --git a/tests/helper/DailyPageHelperTest.php b/tests/helper/DailyPageHelperTest.php
new file mode 100644 (file)
index 0000000..5255b7b
--- /dev/null
@@ -0,0 +1,262 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shaarli\Helper;
+
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\TestCase;
+use Slim\Http\Request;
+
+class DailyPageHelperTest extends TestCase
+{
+    /**
+     * @dataProvider getRequestedTypes
+     */
+    public function testExtractRequestedType(array $queryParams, string $expectedType): void
+    {
+        $request = $this->createMock(Request::class);
+        $request->method('getQueryParam')->willReturnCallback(function ($key) use ($queryParams): ?string {
+            return $queryParams[$key] ?? null;
+        });
+
+        $type = DailyPageHelper::extractRequestedType($request);
+
+        static::assertSame($type, $expectedType);
+    }
+
+    /**
+     * @dataProvider getRequestedDateTimes
+     */
+    public function testExtractRequestedDateTime(
+        string $type,
+        string $input,
+        ?Bookmark $bookmark,
+        \DateTimeInterface $expectedDateTime,
+        string $compareFormat = 'Ymd'
+    ): void {
+        $dateTime = DailyPageHelper::extractRequestedDateTime($type, $input, $bookmark);
+
+        static::assertSame($dateTime->format($compareFormat), $expectedDateTime->format($compareFormat));
+    }
+
+    public function testExtractRequestedDateTimeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::extractRequestedDateTime('nope', null, null);
+    }
+
+    /**
+     * @dataProvider getFormatsByType
+     */
+    public function testGetFormatByType(string $type, string $expectedFormat): void
+    {
+        $format = DailyPageHelper::getFormatByType($type);
+
+        static::assertSame($expectedFormat, $format);
+    }
+
+    public function testGetFormatByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getFormatByType('nope');
+    }
+
+    /**
+     * @dataProvider getStartDatesByType
+     */
+    public function testGetStartDatesByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        \DateTimeInterface $expectedDateTime
+    ): void {
+        $startDateTime = DailyPageHelper::getStartDateTimeByType($type, $dateTime);
+
+        static::assertEquals($expectedDateTime, $startDateTime);
+    }
+
+    public function testGetStartDatesByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getStartDateTimeByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getEndDatesByType
+     */
+    public function testGetEndDatesByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        \DateTimeInterface $expectedDateTime
+    ): void {
+        $endDateTime = DailyPageHelper::getEndDateTimeByType($type, $dateTime);
+
+        static::assertEquals($expectedDateTime, $endDateTime);
+    }
+
+    public function testGetEndDatesByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getEndDateTimeByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getDescriptionsByType
+     */
+    public function testGeDescriptionsByType(
+        string $type,
+        \DateTimeImmutable $dateTime,
+        string $expectedDescription
+    ): void {
+        $description = DailyPageHelper::getDescriptionByType($type, $dateTime);
+
+        static::assertEquals($expectedDescription, $description);
+    }
+
+    public function getDescriptionByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getDescriptionByType('nope', new \DateTimeImmutable());
+    }
+
+    /**
+     * @dataProvider getRssLengthsByType
+     */
+    public function testGeRssLengthsByType(string $type): void {
+        $length = DailyPageHelper::getRssLengthByType($type);
+
+        static::assertIsInt($length);
+    }
+
+    public function testGeRssLengthsByTypeExceptionUnknownType(): void
+    {
+        $this->expectException(\Exception::class);
+        $this->expectExceptionMessage('Unsupported daily format type');
+
+        DailyPageHelper::getRssLengthByType('nope');
+    }
+
+    /**
+     * Data provider for testExtractRequestedType() test method.
+     */
+    public function getRequestedTypes(): array
+    {
+        return [
+            [['month' => null], DailyPageHelper::DAY],
+            [['month' => ''], DailyPageHelper::MONTH],
+            [['month' => 'content'], DailyPageHelper::MONTH],
+            [['week' => null], DailyPageHelper::DAY],
+            [['week' => ''], DailyPageHelper::WEEK],
+            [['week' => 'content'], DailyPageHelper::WEEK],
+            [['day' => null], DailyPageHelper::DAY],
+            [['day' => ''], DailyPageHelper::DAY],
+            [['day' => 'content'], DailyPageHelper::DAY],
+        ];
+    }
+
+    /**
+     * Data provider for testExtractRequestedDateTime() test method.
+     */
+    public function getRequestedDateTimes(): array
+    {
+        return [
+            [DailyPageHelper::DAY, '20201013', null, new \DateTime('2020-10-13')],
+            [
+                DailyPageHelper::DAY,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                $date,
+            ],
+            [DailyPageHelper::DAY, '', null, new \DateTime()],
+            [DailyPageHelper::WEEK, '202030', null, new \DateTime('2020-07-20')],
+            [
+                DailyPageHelper::WEEK,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                new \DateTime('2020-10-13'),
+            ],
+            [DailyPageHelper::WEEK, '', null, new \DateTime(), 'Ym'],
+            [DailyPageHelper::MONTH, '202008', null, new \DateTime('2020-08-01'), 'Ym'],
+            [
+                DailyPageHelper::MONTH,
+                '',
+                (new Bookmark())->setCreated($date = new \DateTime('2020-10-13 12:05:31')),
+                new \DateTime('2020-10-13'),
+                'Ym'
+            ],
+            [DailyPageHelper::MONTH, '', null, new \DateTime(), 'Ym'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetFormatByType() test method.
+     */
+    public function getFormatsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, 'Ymd'],
+            [DailyPageHelper::WEEK, 'YW'],
+            [DailyPageHelper::MONTH, 'Ym'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetStartDatesByType() test method.
+     */
+    public function getStartDatesByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 00:00:00')],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-05 00:00:00')],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-01 00:00:00')],
+        ];
+    }
+
+    /**
+     * Data provider for testGetEndDatesByType() test method.
+     */
+    public function getEndDatesByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-09 23:59:59')],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-11 23:59:59')],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), new \DateTime('2020-10-31 23:59:59')],
+        ];
+    }
+
+    /**
+     * Data provider for testGetDescriptionsByType() test method.
+     */
+    public function getDescriptionsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY, $date = new \DateTimeImmutable(), 'Today - ' . $date->format('F j, Y')],
+            [DailyPageHelper::DAY, $date = new \DateTimeImmutable('-1 day'), 'Yesterday - ' . $date->format('F j, Y')],
+            [DailyPageHelper::DAY, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October 9, 2020'],
+            [DailyPageHelper::WEEK, new \DateTimeImmutable('2020-10-09 04:05:06'), 'Week 41 (October 5, 2020)'],
+            [DailyPageHelper::MONTH, new \DateTimeImmutable('2020-10-09 04:05:06'), 'October, 2020'],
+        ];
+    }
+
+    /**
+     * Data provider for testGetDescriptionsByType() test method.
+     */
+    public function getRssLengthsByType(): array
+    {
+        return [
+            [DailyPageHelper::DAY],
+            [DailyPageHelper::WEEK],
+            [DailyPageHelper::MONTH],
+        ];
+    }
+}
similarity index 53%
rename from tests/FileUtilsTest.php
rename to tests/helper/FileUtilsTest.php
index 9163bdf1face0b250ad801b13de5cd96385507a5..8035f79cff3f94ca320c23bba6a756864618a455 100644 (file)
@@ -1,27 +1,51 @@
 <?php
 
-namespace Shaarli;
+namespace Shaarli\Helper;
 
 use Exception;
+use Shaarli\Exceptions\IOException;
+use Shaarli\TestCase;
 
 /**
  * Class FileUtilsTest
  *
  * Test file utility class.
  */
-class FileUtilsTest extends \Shaarli\TestCase
+class FileUtilsTest extends TestCase
 {
     /**
      * @var string Test file path.
      */
     protected static $file = 'sandbox/flat.db';
 
+    protected function setUp(): void
+    {
+        @mkdir('sandbox');
+        mkdir('sandbox/folder2');
+        touch('sandbox/file1');
+        touch('sandbox/file2');
+        mkdir('sandbox/folder1');
+        touch('sandbox/folder1/file1');
+        touch('sandbox/folder1/file2');
+        mkdir('sandbox/folder3');
+        mkdir('/tmp/shaarli-to-delete');
+    }
+
     /**
      * Delete test file after every test.
      */
     protected function tearDown(): void
     {
         @unlink(self::$file);
+
+        @unlink('sandbox/folder1/file1');
+        @unlink('sandbox/folder1/file2');
+        @rmdir('sandbox/folder1');
+        @unlink('sandbox/file1');
+        @unlink('sandbox/file2');
+        @rmdir('sandbox/folder2');
+        @rmdir('sandbox/folder3');
+        @rmdir('/tmp/shaarli-to-delete');
     }
 
     /**
@@ -107,4 +131,67 @@ class FileUtilsTest extends \Shaarli\TestCase
         $this->assertEquals(null, FileUtils::readFlatDB(self::$file));
         $this->assertEquals(['test'], FileUtils::readFlatDB(self::$file, ['test']));
     }
+
+    /**
+     * Test clearFolder with self delete and excluded files
+     */
+    public function testClearFolderSelfDeleteWithExclusion(): void
+    {
+        FileUtils::clearFolder('sandbox', true, ['file2']);
+
+        static::assertFileExists('sandbox/folder1/file2');
+        static::assertFileExists('sandbox/folder1');
+        static::assertFileExists('sandbox/file2');
+        static::assertFileExists('sandbox');
+
+        static::assertFileNotExists('sandbox/folder1/file1');
+        static::assertFileNotExists('sandbox/file1');
+        static::assertFileNotExists('sandbox/folder3');
+    }
+
+    /**
+     * Test clearFolder with self delete and excluded files
+     */
+    public function testClearFolderSelfDeleteWithoutExclusion(): void
+    {
+        FileUtils::clearFolder('sandbox', true);
+
+        static::assertFileNotExists('sandbox');
+    }
+
+    /**
+     * Test clearFolder with self delete and excluded files
+     */
+    public function testClearFolderNoSelfDeleteWithoutExclusion(): void
+    {
+        FileUtils::clearFolder('sandbox', false);
+
+        static::assertFileExists('sandbox');
+
+        // 2 because '.' and '..'
+        static::assertCount(2, new \DirectoryIterator('sandbox'));
+    }
+
+    /**
+     * Test clearFolder on a file instead of a folder
+     */
+    public function testClearFolderOnANonDirectory(): void
+    {
+        $this->expectException(IOException::class);
+        $this->expectExceptionMessage('Provided path is not a directory.');
+
+        FileUtils::clearFolder('sandbox/file1', false);
+    }
+
+    /**
+     * Test clearFolder on a file instead of a folder
+     */
+    public function testClearFolderOutsideOfShaarliDirectory(): void
+    {
+        $this->expectException(IOException::class);
+        $this->expectExceptionMessage('Trying to delete a folder outside of Shaarli path.');
+
+
+        FileUtils::clearFolder('/tmp/shaarli-to-delete', true);
+    }
 }
index 22aa86661ad8309edc6ecf245c9833b9c14898c8..29d2791b0198b1ec3b8787799b6f51f860e60381 100644 (file)
@@ -4,7 +4,7 @@
 namespace Shaarli\Security;
 
 use Psr\Log\LoggerInterface;
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
 use Shaarli\TestCase;
 
 /**
index de83d598575a9af23c335a0aa32ee770042c2399..d5289ede2c735afcb8aceb4a14d2c7a7e5e5f9a4 100644 (file)
@@ -2,6 +2,8 @@
 
 namespace Shaarli;
 
+use Shaarli\Helper\ApplicationUtils;
+
 /**
  * Fake ApplicationUtils class to avoid HTTP requests
  */
index 516c9f51ea22d195bf71f70bcc9b786c083beade..aed5d2cf1a8baa85f16d8c53dff653f8cf98345c 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-use Shaarli\FileUtils;
+use Shaarli\Helper\FileUtils;
 use Shaarli\History;
 
 /**
index 67d3ebd1c3f14e5d2dae92da82a2caf93d0178bb..4aac7ff1e69617df47b78f146ed739f030424c1c 100644 (file)
     </form>
   </div>
 </div>
+
+<div class="pure-g addlink-batch-show-more-block pure-u-0">
+  <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+  <div class="pure-u-lg-1-3 pure-u-22-24 addlink-batch-show-more">
+    <a href="#">{'BULK CREATION'|t}&nbsp;<i class="fa fa-plus-circle" aria-hidden="true"></i></a>
+  </div>
+</div>
+
+<div class="addlink-batch-form-block">
+  {if="empty($async_metadata)"}
+    <div class="pure-g pure-alert pure-alert-warning pure-alert-closable">
+      <div class="pure-u-2-24"></div>
+      <div class="pure-u-20-24">
+        <p>
+          {'Metadata asynchronous retrieval is disabled.'|t}
+          {'We recommend that you enable the setting <em>general > enable_async_metadata</em> in your configuration file to use bulk link creation.'|t}
+        </p>
+      </div>
+      <div class="pure-u-2-24">
+        <i class="fa fa-times pure-alert-close"></i>
+      </div>
+    </div>
+  {/if}
+
+  <div class="pure-g">
+    <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+    <div id="batch-addlink-form" class="page-form  page-form-light pure-u-lg-1-3 pure-u-22-24">
+      <h2 class="window-title">{"Shaare multiple new links"|t}</h2>
+      <form method="POST" action="{$base_path}/admin/shaare-batch" name="batch-addform" class="batch-addform">
+        <div>
+          <label for="urls">{'Add one URL per line to create multiple bookmarks.'|t}</label>
+          <textarea name="urls" id="urls"></textarea>
+
+          <div>
+            <label for="tags">{'Tags'|t}</label>
+          </div>
+          <div>
+            <input type="text" name="tags" id="tags" class="lf_input"
+                   data-list="{loop="$tags"}{$key}, {/loop}" data-multiple data-autofirst autocomplete="off">
+          </div>
+
+          <div>
+            <input type="hidden" name="private" value="0">
+            <input type="checkbox" name="private" {if="$default_private_links"} checked="checked"{/if}>
+          &nbsp; <label for="lf_private">{'Private'|t}</label>
+          </div>
+        </div>
+        <div>
+          <input type="hidden" name="token" value="{$token}">
+          <input type="submit" value="{'Add links'|t}">
+        </div>
+      </form>
+    </div>
+  </div>
+</div>
+
 {include="page.footer"}
 </body>
 </html>
index 3749bffb620a317c276c4b63a2e9aa42b031a67d..5e038c393822105287678394fa6e1b9ab4211d77 100644 (file)
@@ -6,12 +6,25 @@
 <body>
 {include="page.header"}
 
+<div class="pure-g">
+  <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
+    <a href="{$base_path}/daily?day">{'Daily'|t}</a>
+    <a href="{$base_path}/daily?week">{'Weekly'|t}</a>
+    <a href="{$base_path}/daily?month">{'Monthly'|t}</a>
+  </div>
+</div>
+
+
 <div class="pure-g">
   <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor" id="daily">
     <h2 class="window-title">
-      {'The Daily Shaarli'|t}
-      <a href="{$base_path}/daily-rss" title="{'1 RSS entry per day'|t}"><i class="fa fa-rss"></i></a>
+      {$localizedType} Shaarli
+      <a href="{$base_path}/daily-rss?{$type}"
+         title="{function="t('1 RSS entry per :type', '', 1, 'shaarli', [':type' => t($type)])"}"
+      >
+        <i class="fa fa-rss"></i>
+      </a>
     </h2>
 
     <div id="plugin_zone_start_daily" class="plugin_zone">
       <div class="pure-g">
         <div class="pure-u-lg-1-3 pure-u-1 center">
           {if="$previousday"}
-            <a href="{$base_path}/daily?day={$previousday}">
+            <a href="{$base_path}/daily?{$type}={$previousday}">
               <i class="fa fa-arrow-left"></i>
-              {'Previous day'|t}
+              {function="t('Previous :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
             </a>
           {/if}
         </div>
         <div class="daily-desc pure-u-lg-1-3 pure-u-1 center">
-          {'All links of one day in a single page.'|t}
+          {function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}
         </div>
         <div class="pure-u-lg-1-3 pure-u-1 center">
           {if="$nextday"}
-            <a href="{$base_path}/daily?day={$nextday}">
-              {'Next day'|t}
+            <a href="{$base_path}/daily?{$type}={$nextday}">
+              {function="t('Next :type', '', 1, 'shaarli', [':type' => t($type)], true)"}
               <i class="fa fa-arrow-right"></i>
             </a>
           {/if}
       </div>
       <div>
         <h3 class="window-subtitle">
-          {if="!empty($dayDesc)"}
-            {$dayDesc} -
-          {/if}
-          {function="format_date($dayDate, false)"}
+          {$dayDesc}
         </h3>
 
         <div id="plugin_zone_about_daily" class="plugin_zone">
index d40d94968ad6d75b2145b4116af5f449de7788f5..871a3ba7531abb0c2268ab424ef54ffb5b2abafd 100644 (file)
@@ -1,9 +1,9 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <rss version="2.0">
   <channel>
-    <title>Daily - {$title}</title>
+    <title>{$localizedType} - {$title}</title>
     <link>{$index_url}</link>
-    <description>Daily shaared bookmarks</description>
+    <description>{function="t('All links of one :type in a single page.', '', 1, 'shaarli', [':type' => t($type)])"}</description>
     <language>{$language}</language>
     <copyright>{$index_url}</copyright>
     <generator>Shaarli</generator>
           {loop="$value.links"}
             <h3><a href="{$value.url}">{$value.title}</a></h3>
             <small>
-              {if="!$hide_timestamps"}{$value.created|format_date} - {/if}{if="$value.tags"}{$value.tags}{/if}<br>
+              {if="!$hide_timestamps"}{$value.created|format_date} &#8212; {/if}
+              <a href="{$index_url}shaare/{$value.shorturl}">{'Permalink'|t}</a>
+              {if="$value.tags"} &#8212; {$value.tags}{/if}
+              <br>
               {$value.url}
             </small><br>
             {if="$value.thumbnail"}<img src="{$index_url}{$value.thumbnail}#" alt="thumbnail" />{/if}<br>
             {if="$value.description"}{$value.description}{/if}
-            <br><br><hr>
+            <br><hr>
           {/loop}
         ]]></description>
       </item>
diff --git a/tpl/default/editlink.batch.html b/tpl/default/editlink.batch.html
new file mode 100644 (file)
index 0000000..b1f8e5b
--- /dev/null
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html{if="$language !== 'auto'"} lang="{$language}"{/if}>
+<head>
+  {include="includes"}
+</head>
+<body>
+<div class="dark-layer">
+  <div class="screen-center">
+    <div><span class="progressbar-current"></span> / <span class="progressbar-max"></span></div>
+    <div class="progressbar">
+      <div></div>
+    </div>
+  </div>
+</div>
+
+{include="page.header"}
+
+<div class="center">
+  <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
+</div>
+
+{loop="$links"}
+  {include="editlink"}
+{/loop}
+
+<div class="center">
+  <input type="submit" name="save_edit_batch" class="pure-button-shaarli" value="{'Save all'|t}">
+</div>
+
+{include="page.footer"}
+{if="$async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
+<script src="{$asset_path}/js/shaare_batch.min.js?v={$version_hash}#"></script>
index 7ab7e1fe3b8d7f7c5023587f6d80611a6fae14e8..83e541fdf6b32aaf45a985a20645ab19c53bbc37 100644 (file)
@@ -1,3 +1,4 @@
+{if="empty($batch_mode)"}
 <!DOCTYPE html>
 <html{if="$language !== 'auto'"} lang="{$language}"{/if}>
 <head>
@@ -5,6 +6,10 @@
 </head>
 <body>
   {include="page.header"}
+{else}
+  {ignore}Lil hack: when included in a loop in batch mode, `$value` is assigned by RainTPL with template vars.{/ignore}
+  {function="extract($value) ? '' : ''"}
+{/if}
   <div id="editlinkform" class="edit-link-container" class="pure-g">
     <div class="pure-u-lg-1-5 pure-u-1-24"></div>
     <form method="post"
@@ -60,7 +65,7 @@
 
       <div>
         <input type="checkbox"  name="lf_private" id="lf_private"
-        {if="($link_is_new && $default_private_links || $link.private == true)"}
+        {if="$link.private === true"}
           checked="checked"
         {/if}>
         &nbsp;<label for="lf_private">{'Private'|t}</label>
 
 
       <div class="submit-buttons center">
+        {if="!empty($batch_mode)"}
+          <a href="#" class="button button-grey" name="cancel-batch-link"
+            title="{'Remove this bookmark from batch creation/modification.'}"
+          >
+            {'Cancel'|t}
+          </a>
+        {/if}
         <input type="submit" name="save_edit" class="" id="button-save-edit"
                value="{if="$link_is_new"}{'Save'|t}{else}{'Apply Changes'|t}{/if}">
         {if="!$link_is_new"}
       {/if}
     </form>
   </div>
+
+{if="empty($batch_mode)"}
   {include="page.footer"}
   {if="$link_is_new && $async_metadata"}<script src="{$asset_path}/js/metadata.min.js?v={$version_hash}#"></script>{/if}
 </body>
 </html>
+{/if}
index a506a2eb2543b76f7dbe5ee760de737938a0bce5..4f98d49dff9f066d7ff50b8a73633b31ff2613a7 100644 (file)
   </div>
 </div>
 </form>
+
+<div class="pure-g">
+  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+  <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-form-complete">
+    <h2 class="window-title">{'Server requirements'|t}</h2>
+
+    {include="server.requirements"}
+  </div>
+</div>
+
 {include="page.footer"}
 </body>
 </html>
index 48cd9aad9d0aa0a04143ac7de318d1aca6f46358..e1115d49b61469a74f7179b9dda3feccfb37baf9 100644 (file)
       {$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>
                   {$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;
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 2cb08e387b468e8f2b39942a46ae0699abc98088..2df73598173ae522306ae1007ba5188824dcfbd8 100644 (file)
         <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Plugin administration'|t}</span>
       </a>
     </div>
+    <div class="tools-item">
+      <a href="{$base_path}/admin/server"
+         title="{'Check instance\'s server configuration'|t}">
+        <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Server administration'|t}</span>
+      </a>
+    </div>
     {if="!$openshaarli"}
       <div class="tools-item">
         <a href="{$base_path}/admin/password" title="{'Change your password'|t}">
       </a>
     </div>
 
-    {if="$thumbnails_enabled"}
-      <div class="tools-item">
-        <a href="{$base_path}/admin/thumbnails" title="{'Synchronize all link thumbnails'|t}">
-          <span class="pure-button pure-u-lg-2-3 pure-u-3-4">{'Synchronize thumbnails'|t}</span>
-        </a>
-      </div>
-    {/if}
-
     {loop="$tools_plugin"}
       <div class="tools-item">
         {$value}
index 8e3d1470eaccdab4b62069b8a66e9460b69eca35..a4aa633eb6ace028380d7bfbbf27495adafbeb55 100644 (file)
@@ -18,6 +18,7 @@ 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',