]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Feature: support any tag separator
authorArthurHoaro <arthur@hoa.ro>
Thu, 22 Oct 2020 14:21:03 +0000 (16:21 +0200)
committerArthurHoaro <arthur@hoa.ro>
Thu, 5 Nov 2020 16:54:42 +0000 (17:54 +0100)
So it allows to have multiple words tags.

Breaking change: commas ',' are no longer a default separator.

Fixes #594

38 files changed:
application/bookmark/Bookmark.php
application/bookmark/BookmarkFileService.php
application/bookmark/BookmarkFilter.php
application/bookmark/LinkUtils.php
application/config/ConfigManager.php
application/formatter/BookmarkDefaultFormatter.php
application/formatter/BookmarkFormatter.php
application/front/controller/admin/ManageTagController.php
application/front/controller/admin/ShaareManageController.php
application/front/controller/admin/ShaarePublishController.php
application/front/controller/visitor/BookmarkListController.php
application/front/controller/visitor/TagCloudController.php
application/front/controller/visitor/TagController.php
application/http/HttpAccess.php
application/http/HttpUtils.php
application/http/MetadataRetriever.php
application/legacy/LegacyUpdater.php
application/netscape/NetscapeBookmarkUtils.php
application/render/PageBuilder.php
assets/default/js/base.js
assets/default/scss/shaarli.scss
doc/md/Shaarli-configuration.md
index.php
tests/bookmark/BookmarkFilterTest.php
tests/bookmark/BookmarkTest.php
tests/bookmark/LinkUtilsTest.php
tests/front/controller/admin/ManageTagControllerTest.php
tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
tests/front/controller/visitor/BookmarkListControllerTest.php
tests/front/controller/visitor/FrontControllerMockHelper.php
tests/front/controller/visitor/TagCloudControllerTest.php
tests/front/controller/visitor/TagControllerTest.php
tests/netscape/BookmarkImportTest.php
tpl/default/changetag.html
tpl/default/linklist.html
tpl/default/page.footer.html
tpl/default/tag.cloud.html

index 4810c5e63b5b97b831ba7eb77cc6053c0dbd9d9d..8aaeb9d87c504095405b78c7fdc6833d54e167d1 100644 (file)
@@ -60,11 +60,13 @@ class Bookmark
     /**
      * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
      *
-     * @param array $data
+     * @param array  $data
+     * @param string $tagsSeparator Tags separator loaded from the config file.
+     *                              This is a context data, and it should *never* be stored in the Bookmark object.
      *
      * @return $this
      */
-    public function fromArray(array $data): Bookmark
+    public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
     {
         $this->id = $data['id'] ?? null;
         $this->shortUrl = $data['shorturl'] ?? null;
@@ -77,7 +79,7 @@ class Bookmark
         if (is_array($data['tags'])) {
             $this->tags = $data['tags'];
         } else {
-            $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY);
+            $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
         }
         if (! empty($data['updated'])) {
             $this->updated = $data['updated'];
@@ -348,7 +350,12 @@ class Bookmark
      */
     public function setTags(?array $tags): Bookmark
     {
-        $this->setTagsString(implode(' ', $tags ?? []));
+        $this->tags = array_map(
+            function (string $tag): string {
+                return $tag[0] === '-' ? substr($tag, 1) : $tag;
+            },
+            tags_filter($tags, ' ')
+        );
 
         return $this;
     }
@@ -420,11 +427,13 @@ class Bookmark
     }
 
     /**
-     * @return string Bookmark's tags as a string, separated by a space
+     * @param string $separator Tags separator loaded from the config file.
+     *
+     * @return string Bookmark's tags as a string, separated by a separator
      */
-    public function getTagsString(): string
+    public function getTagsString(string $separator = ' '): string
     {
-        return implode(' ', $this->getTags());
+        return tags_array2str($this->getTags(), $separator);
     }
 
     /**
@@ -444,19 +453,13 @@ class Bookmark
      *   - trailing dash in tags will be removed
      *
      * @param string|null $tags
+     * @param string      $separator Tags separator loaded from the config file.
      *
      * @return $this
      */
-    public function setTagsString(?string $tags): Bookmark
+    public function setTagsString(?string $tags, string $separator = ' '): Bookmark
     {
-        // Remove first '-' char in tags.
-        $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
-        // Explode all tags separted by spaces or commas
-        $tags = preg_split('/[\s,]+/', $tags);
-        // Remove eventual empty values
-        $tags = array_values(array_filter($tags));
-
-        $this->tags = $tags;
+        $this->setTags(tags_str2array($tags, $separator));
 
         return $this;
     }
@@ -507,7 +510,7 @@ class Bookmark
      */
     public function renameTag(string $fromTag, string $toTag): void
     {
-        if (($pos = array_search($fromTag, $this->tags)) !== false) {
+        if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
             $this->tags[$pos] = trim($toTag);
         }
     }
@@ -519,7 +522,7 @@ class Bookmark
      */
     public function deleteTag(string $tag): void
     {
-        if (($pos = array_search($tag, $this->tags)) !== false) {
+        if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
             unset($this->tags[$pos]);
             $this->tags = array_values($this->tags);
         }
index 3ea98a45d6bf24c1ce501826d7dc84455bdae5ee..85efeea60097860bf96066e4c712a35c3d1d20d6 100644 (file)
@@ -91,7 +91,7 @@ class BookmarkFileService implements BookmarkServiceInterface
             }
         }
 
-        $this->bookmarkFilter = new BookmarkFilter($this->bookmarks);
+        $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
     }
 
     /**
index c79386ea7ba750db4d1d7d7974ea7564154e943a..5d8733dc6b5fa46dfbeafc6d152d49777eb8d287 100644 (file)
@@ -6,6 +6,7 @@ namespace Shaarli\Bookmark;
 
 use Exception;
 use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Config\ConfigManager;
 
 /**
  * Class LinkFilter.
@@ -58,12 +59,16 @@ class BookmarkFilter
      */
     private $bookmarks;
 
+    /** @var ConfigManager */
+    protected $conf;
+
     /**
      * @param Bookmark[] $bookmarks initialization.
      */
-    public function __construct($bookmarks)
+    public function __construct($bookmarks, ConfigManager $conf)
     {
         $this->bookmarks = $bookmarks;
+        $this->conf = $conf;
     }
 
     /**
@@ -107,10 +112,14 @@ class BookmarkFilter
                     $filtered = $this->bookmarks;
                 }
                 if (!empty($request[0])) {
-                    $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
+                    $filtered = (new BookmarkFilter($filtered, $this->conf))
+                        ->filterTags($request[0], $casesensitive, $visibility)
+                    ;
                 }
                 if (!empty($request[1])) {
-                    $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility);
+                    $filtered = (new BookmarkFilter($filtered, $this->conf))
+                        ->filterFulltext($request[1], $visibility)
+                    ;
                 }
                 return $filtered;
             case self::$FILTER_TEXT:
@@ -280,8 +289,9 @@ class BookmarkFilter
      *
      * @return string generated regex fragment
      */
-    private static function tag2regex(string $tag): string
+    protected function tag2regex(string $tag): string
     {
+        $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
         $len = strlen($tag);
         if (!$len || $tag === "-" || $tag === "*") {
             // nothing to search, return empty regex
@@ -295,12 +305,13 @@ class BookmarkFilter
             $i = 0; // start at first character
             $regex = '(?='; // use positive lookahead
         }
-        $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
+        // before tag may only be the separator or the beginning
+        $regex .= '.*(?:^|' . $tagsSeparator . ')';
         // iterate over string, separating it into placeholder and content
         for (; $i < $len; $i++) {
             if ($tag[$i] === '*') {
                 // placeholder found
-                $regex .= '[^ ]*?';
+                $regex .= '[^' . $tagsSeparator . ']*?';
             } else {
                 // regular characters
                 $offset = strpos($tag, '*', $i);
@@ -316,7 +327,8 @@ class BookmarkFilter
                 $i = $offset;
             }
         }
-        $regex .= '(?:$| ))'; // after the tag may only be a space or the end
+        // after the tag may only be the separator or the end
+        $regex .= '(?:$|' . $tagsSeparator . '))';
         return $regex;
     }
 
@@ -334,14 +346,15 @@ class BookmarkFilter
      */
     public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
     {
+        $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
         // get single tags (we may get passed an array, even though the docs say different)
         $inputTags = $tags;
         if (!is_array($tags)) {
             // we got an input string, split tags
-            $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
+            $inputTags = tags_str2array($inputTags, $tagsSeparator);
         }
 
-        if (!count($inputTags)) {
+        if (count($inputTags) === 0) {
             // no input tags
             return $this->noFilter($visibility);
         }
@@ -358,7 +371,7 @@ class BookmarkFilter
         }
 
         // build regex from all tags
-        $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
+        $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
         if (!$casesensitive) {
             // make regex case insensitive
             $re .= 'i';
@@ -378,7 +391,8 @@ class BookmarkFilter
                     continue;
                 }
             }
-            $search = $link->getTagsString(); // build search string, start with tags of current link
+            // build search string, start with tags of current link
+            $search = $link->getTagsString($tagsSeparator);
             if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
                 // description given and at least one possible tag found
                 $descTags = array();
@@ -390,9 +404,9 @@ class BookmarkFilter
                 );
                 if (count($descTags[1])) {
                     // there were some tags in the description, add them to the search string
-                    $search .= ' ' . implode(' ', $descTags[1]);
+                    $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
                 }
-            };
+            }
             // match regular expression with search string
             if (!preg_match($re, $search)) {
                 // this entry does _not_ match our regex
@@ -422,7 +436,7 @@ class BookmarkFilter
                 }
             }
 
-            if (empty(trim($link->getTagsString()))) {
+            if (empty($link->getTags())) {
                 $filtered[$key] = $link;
             }
         }
@@ -537,10 +551,11 @@ class BookmarkFilter
      */
     protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
     {
+        $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
         $content  = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
         $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
         $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
-        $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
+        $content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') .'\\';
 
         $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
         $nextField = $lengths['title']['end'] + 1;
@@ -548,7 +563,7 @@ class BookmarkFilter
         $nextField = $lengths['description']['end'] + 1;
         $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
         $nextField = $lengths['url']['end'] + 1;
-        $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())];
+        $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
 
         return $content;
     }
index 17c379796cd39b239be09459d78e3cad22fdfb0b..9493b0aa894e00de160684be54dc3f8ab4aa4bab 100644 (file)
@@ -176,3 +176,49 @@ function is_note($linkUrl)
 {
     return isset($linkUrl[0]) && $linkUrl[0] === '?';
 }
+
+/**
+ * Extract an array of tags from a given tag string, with provided separator.
+ *
+ * @param string|null $tags      String containing a list of tags separated by $separator.
+ * @param string      $separator Shaarli's default: ' ' (whitespace)
+ *
+ * @return array List of tags
+ */
+function tags_str2array(?string $tags, string $separator): array
+{
+    // For whitespaces, we use the special \s regex character
+    $separator = $separator === ' ' ? '\s' : $separator;
+
+    return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
+}
+
+/**
+ * Return a tag string with provided separator from a list of tags.
+ * Note that given array is clean up by tags_filter().
+ *
+ * @param array|null $tags      List of tags
+ * @param string     $separator
+ *
+ * @return string
+ */
+function tags_array2str(?array $tags, string $separator): string
+{
+    return implode($separator, tags_filter($tags, $separator));
+}
+
+/**
+ * Clean an array of tags: trim + remove empty entries
+ *
+ * @param array|null $tags List of tags
+ * @param string     $separator
+ *
+ * @return array
+ */
+function tags_filter(?array $tags, string $separator): array
+{
+    $trimDefault = " \t\n\r\0\x0B";
+    return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
+        return trim($entry, $trimDefault . $separator);
+    }, $tags ?? [])));
+}
index fb0850235fb78da19b7ea686ebcad0a07f082d49..a035baaee96ab53a22e82b774613dba9c36ba8de 100644 (file)
@@ -368,6 +368,7 @@ class ConfigManager
         $this->setEmpty('general.default_note_title', 'Note: ');
         $this->setEmpty('general.retrieve_description', true);
         $this->setEmpty('general.enable_async_metadata', true);
+        $this->setEmpty('general.tags_separator', ' ');
 
         $this->setEmpty('updates.check_updates', false);
         $this->setEmpty('updates.check_updates_branch', 'stable');
index 149a3eb9fc854fed6404859eb6b51083b995703a..51bea0f15082ee2ed168446f7fd15f207f7cdd54 100644 (file)
@@ -68,15 +68,16 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
      */
     protected function formatTagListHtml($bookmark)
     {
+        $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
         if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
             return $this->formatTagList($bookmark);
         }
 
         $tags = $this->tokenizeSearchHighlightField(
-            $bookmark->getTagsString(),
+            $bookmark->getTagsString($tagsSeparator),
             $bookmark->getAdditionalContentEntry('search_highlight')['tags']
         );
-        $tags = $this->filterTagList(explode(' ', $tags));
+        $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
         $tags = escape($tags);
         $tags = $this->replaceTokensArray($tags);
 
@@ -88,7 +89,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
      */
     protected function formatTagString($bookmark)
     {
-        return implode(' ', $this->formatTagList($bookmark));
+        return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
     }
 
     /**
index e1b7f705e29b0e87ee8841c9b2e298a807c4cc2c..124ce78bdc8224e07e034de60061a665fa886633 100644 (file)
@@ -267,7 +267,7 @@ abstract class BookmarkFormatter
      */
     protected function formatTagString($bookmark)
     {
-        return implode(' ', $this->formatTagList($bookmark));
+        return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
     }
 
     /**
@@ -351,6 +351,7 @@ abstract class BookmarkFormatter
 
     /**
      * Format tag list, e.g. remove private tags if the user is not logged in.
+     * TODO: this method is called multiple time to format tags, the result should be cached.
      *
      * @param array $tags
      *
index 2065c3e27cbdac21c43d68901c197aee05253805..22fb461c1411173c8cb89df4a7de2006d6ba8b4f 100644 (file)
@@ -24,6 +24,12 @@ class ManageTagController extends ShaarliAdminController
         $fromTag = $request->getParam('fromtag') ?? '';
 
         $this->assignView('fromtag', escape($fromTag));
+        $separator = escape($this->container->conf->get('general.tags_separator', ' '));
+        if ($separator === ' ') {
+            $separator = '&nbsp;';
+            $this->assignView('tags_separator_desc', t('whitespace'));
+        }
+        $this->assignView('tags_separator', $separator);
         $this->assignView(
             'pagetitle',
             t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli')
@@ -85,4 +91,31 @@ class ManageTagController extends ShaarliAdminController
 
         return $this->redirect($response, $redirect);
     }
+
+    /**
+     * POST /admin/tags/change-separator - Change tag separator
+     */
+    public function changeSeparator(Request $request, Response $response): Response
+    {
+        $this->checkToken($request);
+
+        $reservedCharacters = ['-', '.', '*'];
+        $newSeparator = $request->getParam('separator');
+        if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
+            $this->saveErrorMessage(t('Tags separator must be a single character.'));
+        } elseif (in_array($newSeparator, $reservedCharacters, true)) {
+            $reservedCharacters = implode(' ', array_map(function (string $character) {
+                return '<code>' . $character . '</code>';
+            }, $reservedCharacters));
+            $this->saveErrorMessage(
+                t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
+            );
+        } else {
+            $this->container->conf->set('general.tags_separator', $newSeparator, true, true);
+
+            $this->saveSuccessMessage('Your tags separator setting has been updated!');
+        }
+
+        return $this->redirect($response, '/admin/tags');
+    }
 }
index 2ed298f5e4cefb163371bf372df7eeb5c7241c7e..0b14317259bc2fb027f465c3157dc7fc83604f89 100644 (file)
@@ -125,7 +125,7 @@ class ShaareManageController extends ShaarliAdminController
             // To preserve backward compatibility with 3rd parties, plugins still use arrays
             $data = $formatter->format($bookmark);
             $this->executePageHooks('save_link', $data);
-            $bookmark->fromArray($data);
+            $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
 
             $this->container->bookmarkService->set($bookmark, false);
             ++$count;
@@ -167,7 +167,7 @@ class ShaareManageController extends ShaarliAdminController
         // To preserve backward compatibility with 3rd parties, plugins still use arrays
         $data = $formatter->format($bookmark);
         $this->executePageHooks('save_link', $data);
-        $bookmark->fromArray($data);
+        $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
 
         $this->container->bookmarkService->set($bookmark);
 
index 18afc2d1fb84e26ce281c35970948d86593b36b0..625a5680c3a63c838425345df27679b567dfcbe1 100644 (file)
@@ -113,7 +113,10 @@ class ShaarePublishController extends ShaarliAdminController
         $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'));
+        $bookmark->setTagsString(
+            $request->getParam('lf_tags'),
+            $this->container->conf->get('general.tags_separator', ' ')
+        );
 
         if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
             && true !== $this->container->conf->get('general.enable_async_metadata', true)
@@ -128,7 +131,7 @@ class ShaarePublishController extends ShaarliAdminController
         $data = $formatter->format($bookmark);
         $this->executePageHooks('save_link', $data);
 
-        $bookmark->fromArray($data);
+        $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
         $this->container->bookmarkService->set($bookmark);
 
         // If we are called from the bookmarklet, we must close the popup:
@@ -221,6 +224,11 @@ class ShaarePublishController extends ShaarliAdminController
 
     protected function buildFormData(array $link, bool $isNew, Request $request): array
     {
+        $link['tags'] = strlen($link['tags']) > 0
+            ? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
+            : $link['tags']
+        ;
+
         return escape([
             'link' => $link,
             'link_is_new' => $isNew,
index 78c474c9095fd20e7377e3414c01c976ec8a4f97..cc3837ced04b1b7da7356491ef974d37764697a2 100644 (file)
@@ -95,6 +95,10 @@ class BookmarkListController extends ShaarliVisitorController
             $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
         }
 
+        $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
+        $searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
+        $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
+
         // Fill all template fields.
         $data = array_merge(
             $this->initializeTemplateVars(),
@@ -106,7 +110,7 @@ class BookmarkListController extends ShaarliVisitorController
                 'result_count' => count($linksToDisplay),
                 'search_term' => escape($searchTerm),
                 'search_tags' => escape($searchTags),
-                'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)),
+                'search_tags_url' => $searchTagsUrlEncoded,
                 'visibility' => $visibility,
                 'links' => $linkDisp,
             ]
@@ -119,8 +123,9 @@ class BookmarkListController extends ShaarliVisitorController
                 return '[' . $tag . ']';
             };
             $data['pagetitle'] .= ! empty($searchTags)
-                ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' '
-                : '';
+                ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
+                : ''
+            ;
             $data['pagetitle'] .= '- ';
         }
 
index 76ed76900da0f1c75afa1b2dd942cd98a6f6ecda..560cad0808571a5c4c7e0eefc1c6304c057842c6 100644 (file)
@@ -47,13 +47,14 @@ class TagCloudController extends ShaarliVisitorController
      */
     protected function processRequest(string $type, Request $request, Response $response): Response
     {
+        $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
         if ($this->container->loginManager->isLoggedIn() === true) {
             $visibility = $this->container->sessionManager->getSessionParameter('visibility');
         }
 
         $sort = $request->getQueryParam('sort');
         $searchTags = $request->getQueryParam('searchtags');
-        $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : [];
+        $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
 
         $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
 
@@ -71,8 +72,9 @@ class TagCloudController extends ShaarliVisitorController
             $tagsUrl[escape($tag)] = urlencode((string) $tag);
         }
 
-        $searchTags = implode(' ', escape($filteringTags));
-        $searchTagsUrl = urlencode(implode(' ', $filteringTags));
+        $searchTags = tags_array2str($filteringTags, $tagsSeparator);
+        $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
+        $searchTagsUrl = urlencode($searchTags);
         $data = [
             'search_tags' => escape($searchTags),
             'search_tags_url' => $searchTagsUrl,
@@ -82,7 +84,7 @@ class TagCloudController extends ShaarliVisitorController
         $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
         $this->assignAllView($data);
 
-        $searchTags = !empty($searchTags) ? $searchTags .' - ' : '';
+        $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) .' - ' : '';
         $this->assignView(
             'pagetitle',
             $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli')
index de4e7ea28861daabb8c742aeeddd7725930cb95f..7a3377a718b3c7696b1577ace45aaf70ee3eae51 100644 (file)
@@ -45,9 +45,10 @@ class TagController extends ShaarliVisitorController
             unset($params['addtag']);
         }
 
+        $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
         // Check if this tag is already in the search query and ignore it if it is.
         // Each tag is always separated by a space
-        $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : [];
+        $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
 
         $addtag = true;
         foreach ($currentTags as $value) {
@@ -62,7 +63,7 @@ class TagController extends ShaarliVisitorController
             $currentTags[] = trim($newTag);
         }
 
-        $params['searchtags'] = trim(implode(' ', $currentTags));
+        $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
 
         // We also remove page (keeping the same page has no sense, since the results are different)
         unset($params['page']);
@@ -98,10 +99,11 @@ class TagController extends ShaarliVisitorController
         }
 
         if (isset($params['searchtags'])) {
-            $tags = explode(' ', $params['searchtags']);
+            $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
+            $tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
             // Remove value from array $tags.
             $tags = array_diff($tags, [$tagToRemove]);
-            $params['searchtags'] = implode(' ', $tags);
+            $params['searchtags'] = tags_array2str($tags, $tagsSeparator);
 
             if (empty($params['searchtags'])) {
                 unset($params['searchtags']);
index 646a526404c550fd6fdaacab6ac298c86ec6a585..e80e0c014be5450be0ca42f9fee53b0d070c6fac 100644 (file)
@@ -29,14 +29,16 @@ class HttpAccess
         &$title,
         &$description,
         &$keywords,
-        $retrieveDescription
+        $retrieveDescription,
+        $tagsSeparator
     ) {
         return get_curl_download_callback(
             $charset,
             $title,
             $description,
             $keywords,
-            $retrieveDescription
+            $retrieveDescription,
+            $tagsSeparator
         );
     }
 
index 28c129696b45b303c2cb21d29227f600551540d4..ed1002b04770b1e5628581c8f9046b96c5cc950c 100644 (file)
@@ -550,7 +550,8 @@ function get_curl_download_callback(
     &$title,
     &$description,
     &$keywords,
-    $retrieveDescription
+    $retrieveDescription,
+    $tagsSeparator
 ) {
     $currentChunk = 0;
     $foundChunk = null;
@@ -568,6 +569,7 @@ function get_curl_download_callback(
      */
     return function ($ch, $data) use (
         $retrieveDescription,
+        $tagsSeparator,
         &$charset,
         &$title,
         &$description,
@@ -598,10 +600,10 @@ function get_curl_download_callback(
             if (! empty($keywords)) {
                 $foundChunk = $currentChunk;
                 // Keywords use the format tag1, tag2 multiple words, tag
-                // So we format them to match Shaarli's separator and glue multiple words with '-'
-                $keywords = implode(' ', array_map(function($keyword) {
-                    return implode('-', preg_split('/\s+/', trim($keyword)));
-                }, explode(',', $keywords)));
+                // So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
+                $keywords = tags_array2str(array_map(function(string $keyword) use ($tagsSeparator): string {
+                    return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
+                }, tags_str2array($keywords, ',')), $tagsSeparator);
             }
         }
 
index ba9bd40ce283d4b789f194d13ac7e02883361c6f..2e1401eca74f0c9857d7d31c34934a14b6811066 100644 (file)
@@ -38,7 +38,6 @@ class MetadataRetriever
         $title = null;
         $description = null;
         $tags = null;
-        $retrieveDescription = $this->conf->get('general.retrieve_description');
 
         // Short timeout to keep the application responsive
         // The callback will fill $charset and $title with data from the downloaded page.
@@ -52,7 +51,8 @@ class MetadataRetriever
                 $title,
                 $description,
                 $tags,
-                $retrieveDescription
+                $this->conf->get('general.retrieve_description'),
+                $this->conf->get('general.tags_separator', ' ')
             )
         );
 
index fe1a286fdb02bbcc0d559210b867f7b9602a3d0a..ed949b1e5ae89b788f38f80c4ffc5d71360c947f 100644 (file)
@@ -585,7 +585,7 @@ class LegacyUpdater
 
         $linksArray = new BookmarkArray();
         foreach ($this->linkDB as $key => $link) {
-            $linksArray[$key] = (new Bookmark())->fromArray($link);
+            $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' '));
         }
         $linksIo = new BookmarkIO($this->conf);
         $linksIo->write($linksArray);
index b83f16f8eb8e49895bddaac648d1046c25a083c7..6ca728b79de783eed9ca8ba0cb37fdfac7774227 100644 (file)
@@ -101,11 +101,11 @@ class NetscapeBookmarkUtils
 
         // Add tags to all imported bookmarks?
         if (empty($post['default_tags'])) {
-            $defaultTags = array();
+            $defaultTags = [];
         } else {
-            $defaultTags = preg_split(
-                '/[\s,]+/',
-                escape($post['default_tags'])
+            $defaultTags = tags_str2array(
+                escape($post['default_tags']),
+                $this->conf->get('general.tags_separator', ' ')
             );
         }
 
@@ -171,7 +171,7 @@ class NetscapeBookmarkUtils
             $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
             $link->setDescription($bkm['note']);
             $link->setPrivate($private);
-            $link->setTagsString($bkm['tags']);
+            $link->setTags($bkm['tags']);
 
             $this->bookmarkService->addOrSet($link, false);
             $importCount++;
index c2fae7052f71116579b136433f7eeb110989db55..bf0ae3263db50f2ebcb39c228441f058521e0334 100644 (file)
@@ -161,6 +161,7 @@ class PageBuilder
         $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
 
         $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
+        $this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' '));
 
         // To be removed with a proper theme configuration.
         $this->tpl->assign('conf', $this->conf);
index 66badfb265eeacbe3acfcadf292b63701506a407..e7bf4909aa9c9cdcfcacd97e986944a000501808 100644 (file)
@@ -42,19 +42,20 @@ function refreshToken(basePath, callback) {
   xhr.send();
 }
 
-function createAwesompleteInstance(element, tags = []) {
+function createAwesompleteInstance(element, separator, tags = []) {
   const awesome = new Awesomplete(Awesomplete.$(element));
-  // Tags are separated by a space
-  awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]);
+
+  // Tags are separated by separator
+  awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
   // Insert new selected tag in the input
   awesome.replace = (text) => {
-    const before = awesome.input.value.match(/^.+ \s*|/)[0];
-    awesome.input.value = `${before}${text} `;
+    const before = awesome.input.value.match(new RegExp(`^.+${separator}+|`))[0];
+    awesome.input.value = `${before}${text}${separator}`;
   };
   // Highlight found items
-  awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]);
+  awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
   // Don't display already selected items
-  const reg = /(\w+) /g;
+  const reg = new RegExp(`/(\w+)${separator}/g`);
   let match;
   awesome.data = (item, input) => {
     while ((match = reg.exec(input))) {
@@ -78,13 +79,14 @@ function createAwesompleteInstance(element, tags = []) {
  * @param selector  CSS selector
  * @param tags      Array of tags
  * @param instances List of existing awesomplete instances
+ * @param separator Tags separator character
  */
-function updateAwesompleteList(selector, tags, instances) {
+function updateAwesompleteList(selector, tags, instances, separator) {
   if (instances.length === 0) {
     // First load: create Awesomplete instances
     const elements = document.querySelectorAll(selector);
     [...elements].forEach((element) => {
-      instances.push(createAwesompleteInstance(element, tags));
+      instances.push(createAwesompleteInstance(element, separator, tags));
     });
   } else {
     // Update awesomplete tag list
@@ -214,6 +216,8 @@ function init(description) {
 
 (() => {
   const basePath = document.querySelector('input[name="js_base_path"]').value;
+  const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
+  const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || '\s' : '\s';
 
   /**
    * Handle responsive menu.
@@ -575,7 +579,7 @@ function init(description) {
 
           // Refresh awesomplete values
           existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
-          awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+          awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
         }
       };
       xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
@@ -615,14 +619,14 @@ function init(description) {
         refreshToken(basePath);
 
         existingTags = existingTags.filter((tagItem) => tagItem !== tag);
-        awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+        awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
       }
     });
   });
 
   const autocompleteFields = document.querySelectorAll('input[data-multiple]');
   [...autocompleteFields].forEach((autocompleteField) => {
-    awesomepletes.push(createAwesompleteInstance(autocompleteField));
+    awesomepletes.push(createAwesompleteInstance(autocompleteField, tagsSeparator));
   });
 
   const exportForm = document.querySelector('#exportform');
index a7f091e95d938f8ce9c6d10202f2288867fb7390..ed774a9d6e63cacb9564155c1dc3523c14e85623 100644 (file)
@@ -139,6 +139,16 @@ body,
   }
 }
 
+.page-form,
+.pure-alert {
+  code {
+    display: inline-block;
+    padding: 0 2px;
+    color: $dark-grey;
+    background-color: var(--background-color);
+  }
+}
+
 // Make pure-extras alert closable.
 .pure-alert-closable {
   .fa-times {
index 9908472804d889d3e375aac1ed02ba0355f3e223..b1326ccee9b8bf1c84d27332829aeadaa8943f6a 100644 (file)
@@ -74,6 +74,7 @@ Some settings can be configured directly from a web browser by accesing the `Too
         "timezone": "Europe\/Paris",
         "title": "My Shaarli",
         "header_link": "?"
+        "tags_separator": " "
     },
     "dev": {
         "debug": false,
@@ -153,6 +154,7 @@ _These settings should not be edited_
 - **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
 - **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags.
 - **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
+- **tags_separator**: Defines your tags separator (default: whitespace).
 
 ### Security
 
index 4b5602ac8b364c10a73890a9f342a8165197290a..8fe862361330fd59274eeceddbd8c1518255ba0e 100644 (file)
--- a/index.php
+++ b/index.php
@@ -125,6 +125,7 @@ $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->post('/tags/change-separator', '\Shaarli\Front\Controller\Admin\ManageTagController:changeSeparator');
     $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare');
     $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm');
     $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm');
index 574d8e3f270e5a4f80638dfc78db4779860eb39d..835674f2d6df2900efeba0c4d8a1022fae2a95b9 100644 (file)
@@ -44,7 +44,7 @@ class BookmarkFilterTest extends TestCase
         self::$refDB->write(self::$testDatastore);
         $history = new History('sandbox/history.php');
         self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true);
-        self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks());
+        self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks(), $conf);
     }
 
     /**
index 4c1ae25dce6cfb8b329906e9840211ef3dbd3b83..cb91b26ba775227792b7cad2ec661b07eda45780 100644 (file)
@@ -78,6 +78,23 @@ class BookmarkTest extends TestCase
         $this->assertTrue($bookmark->isNote());
     }
 
+    /**
+     * Test fromArray() with a link with a custom tags separator
+     */
+    public function testFromArrayCustomTagsSeparator()
+    {
+        $data = [
+            'id' => 1,
+            'tags' => ['tag1', 'tag2', 'chair'],
+        ];
+
+        $bookmark = (new Bookmark())->fromArray($data, '@');
+        $this->assertEquals($data['id'], $bookmark->getId());
+        $this->assertEquals($data['tags'], $bookmark->getTags());
+        $this->assertEquals('tag1@tag2@chair', $bookmark->getTagsString('@'));
+    }
+
+
     /**
      * Test validate() with a valid minimal bookmark
      */
@@ -252,7 +269,7 @@ class BookmarkTest extends TestCase
     {
         $bookmark = new Bookmark();
 
-        $str = 'tag1    tag2 tag3.tag3-2, tag4   ,  -tag5   ';
+        $str = 'tag1    tag2 tag3.tag3-2 tag4     -tag5   ';
         $bookmark->setTagsString($str);
         $this->assertEquals(
             [
@@ -276,9 +293,9 @@ class BookmarkTest extends TestCase
         $array = [
             'tag1    ',
             '     tag2',
-            'tag3.tag3-2,',
-            ',  tag4',
-            ',  ',
+            'tag3.tag3-2',
+            '  tag4',
+            '  ',
             '-tag5   ',
         ];
         $bookmark->setTags($array);
index 3321242fae07f018c91b98b2f40067fa2d6a9e22..c2f9f9305716829b91ba6ffe36cfc9a8a29cb80e 100644 (file)
@@ -247,7 +247,8 @@ class LinkUtilsTest extends TestCase
             $title,
             $desc,
             $keywords,
-            false
+            false,
+            ' '
         );
 
         $data = [
@@ -297,7 +298,8 @@ class LinkUtilsTest extends TestCase
             $title,
             $desc,
             $keywords,
-            false
+            false,
+            ' '
         );
 
         $data = [
@@ -330,7 +332,8 @@ class LinkUtilsTest extends TestCase
             $title,
             $desc,
             $keywords,
-            false
+            false,
+            ' '
         );
 
         $data = [
@@ -363,7 +366,8 @@ class LinkUtilsTest extends TestCase
             $title,
             $desc,
             $keywords,
-            false
+            false,
+            ' '
         );
 
         $data = [
@@ -428,7 +432,8 @@ class LinkUtilsTest extends TestCase
             $title,
             $desc,
             $keywords,
-            true
+            true,
+            ' '
         );
         $data = [
             'th=device-width">'
@@ -574,6 +579,115 @@ class LinkUtilsTest extends TestCase
         $this->assertFalse(is_note('https://github.com/shaarli/Shaarli/?hi'));
     }
 
+    /**
+     * Test tags_str2array with whitespace separator.
+     */
+    public function testTagsStr2ArrayWithSpaceSeparator(): void
+    {
+        $separator = ' ';
+
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1 tag2 tag3', $separator));
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1  tag2     tag3', $separator));
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('   tag1  tag2     tag3   ', $separator));
+        static::assertSame(['tag1@', 'tag2,', '.tag3'], tags_str2array('   tag1@  tag2,     .tag3   ', $separator));
+        static::assertSame([], tags_str2array('', $separator));
+        static::assertSame([], tags_str2array('   ', $separator));
+        static::assertSame([], tags_str2array(null, $separator));
+    }
+
+    /**
+     * Test tags_str2array with @ separator.
+     */
+    public function testTagsStr2ArrayWithCharSeparator(): void
+    {
+        $separator = '@';
+
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@tag2@tag3', $separator));
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@@@@tag2@@@@tag3', $separator));
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('@@@tag1@@@tag2@@@@tag3@@', $separator));
+        static::assertSame(
+            ['tag1#', 'tag2, and other', '.tag3'],
+            tags_str2array('@@@   tag1#     @@@ tag2, and other @@@@.tag3@@', $separator)
+        );
+        static::assertSame([], tags_str2array('', $separator));
+        static::assertSame([], tags_str2array('   ', $separator));
+        static::assertSame([], tags_str2array(null, $separator));
+    }
+
+    /**
+     * Test tags_array2str with ' ' separator.
+     */
+    public function testTagsArray2StrWithSpaceSeparator(): void
+    {
+        $separator = ' ';
+
+        static::assertSame('tag1 tag2 tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
+        static::assertSame('tag1, tag2@ tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
+        static::assertSame('tag1 tag2 tag3', tags_array2str(['   tag1   ', 'tag2', 'tag3   '], $separator));
+        static::assertSame('tag1 tag2 tag3', tags_array2str(['   tag1   ', ' ', 'tag2', '   ', 'tag3   '], $separator));
+        static::assertSame('tag1', tags_array2str(['   tag1   '], $separator));
+        static::assertSame('', tags_array2str(['  '], $separator));
+        static::assertSame('', tags_array2str([], $separator));
+        static::assertSame('', tags_array2str(null, $separator));
+    }
+
+    /**
+     * Test tags_array2str with @ separator.
+     */
+    public function testTagsArray2StrWithCharSeparator(): void
+    {
+        $separator = '@';
+
+        static::assertSame('tag1@tag2@tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
+        static::assertSame('tag1,@tag2@tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
+        static::assertSame(
+            'tag1@tag2, and other@tag3',
+            tags_array2str(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
+        );
+        static::assertSame('tag1@tag2@tag3', tags_array2str(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
+        static::assertSame('tag1', tags_array2str(['@@@@tag1@@@@'], $separator));
+        static::assertSame('', tags_array2str(['@@@'], $separator));
+        static::assertSame('', tags_array2str([], $separator));
+        static::assertSame('', tags_array2str(null, $separator));
+    }
+
+    /**
+     * Test tags_array2str with @ separator.
+     */
+    public function testTagsFilterWithSpaceSeparator(): void
+    {
+        $separator = ' ';
+
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
+        static::assertSame(['tag1,', 'tag2@', 'tag3'], tags_filter(['tag1,', 'tag2@', 'tag3'], $separator));
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['   tag1   ', 'tag2', 'tag3   '], $separator));
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['   tag1   ', ' ', 'tag2', '   ', 'tag3   '], $separator));
+        static::assertSame(['tag1'], tags_filter(['   tag1   '], $separator));
+        static::assertSame([], tags_filter(['  '], $separator));
+        static::assertSame([], tags_filter([], $separator));
+        static::assertSame([], tags_filter(null, $separator));
+    }
+
+    /**
+     * Test tags_array2str with @ separator.
+     */
+    public function testTagsArrayFilterWithSpaceSeparator(): void
+    {
+        $separator = '@';
+
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
+        static::assertSame(['tag1,', 'tag2#', 'tag3'], tags_filter(['tag1,', 'tag2#', 'tag3'], $separator));
+        static::assertSame(
+            ['tag1', 'tag2, and other', 'tag3'],
+            tags_filter(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
+        );
+        static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
+        static::assertSame(['tag1'], tags_filter(['@@@@tag1@@@@'], $separator));
+        static::assertSame([], tags_filter(['@@@'], $separator));
+        static::assertSame([], tags_filter([], $separator));
+        static::assertSame([], tags_filter(null, $separator));
+    }
+
     /**
      * Util function to build an hashtag link.
      *
index 8a0ff7a96ead9956bd9429a746a2be64d47b2fa7..af6f273f899db98758c420efcd2d9a9922e83373 100644 (file)
@@ -6,6 +6,7 @@ namespace Shaarli\Front\Controller\Admin;
 
 use Shaarli\Bookmark\Bookmark;
 use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Config\ConfigManager;
 use Shaarli\Front\Exception\WrongTokenException;
 use Shaarli\Security\SessionManager;
 use Shaarli\TestCase;
@@ -44,9 +45,32 @@ class ManageTagControllerTest extends TestCase
         static::assertSame('changetag', (string) $result->getBody());
 
         static::assertSame('fromtag', $assignedVariables['fromtag']);
+        static::assertSame('@', $assignedVariables['tags_separator']);
         static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']);
     }
 
+    /**
+     * Test displaying manage tag page
+     */
+    public function testIndexWhitespaceSeparator(): void
+    {
+        $assignedVariables = [];
+        $this->assignTemplateVars($assignedVariables);
+
+        $this->container->conf = $this->createMock(ConfigManager::class);
+        $this->container->conf->method('get')->willReturnCallback(function (string $key) {
+            return $key === 'general.tags_separator' ? ' ' : $key;
+        });
+
+        $request = $this->createMock(Request::class);
+        $response = new Response();
+
+        $this->controller->index($request, $response);
+
+        static::assertSame('&nbsp;', $assignedVariables['tags_separator']);
+        static::assertSame('whitespace', $assignedVariables['tags_separator_desc']);
+    }
+
     /**
      * Test posting a tag update - rename tag - valid info provided.
      */
@@ -269,4 +293,116 @@ class ManageTagControllerTest extends TestCase
         static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
         static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
     }
+
+    /**
+     * Test changeSeparator to '#': redirection + success message.
+     */
+    public function testChangeSeparatorValid(): void
+    {
+        $toSeparator = '#';
+
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
+                return $key === 'separator' ? $toSeparator : $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf
+            ->expects(static::once())
+            ->method('set')
+            ->with('general.tags_separator', $toSeparator, true, true)
+        ;
+
+        $result = $this->controller->changeSeparator($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertSame(
+            ['Your tags separator setting has been updated!'],
+            $session[SessionManager::KEY_SUCCESS_MESSAGES]
+        );
+    }
+
+    /**
+     * Test changeSeparator to '#@' (too long): redirection + error message.
+     */
+    public function testChangeSeparatorInvalidTooLong(): void
+    {
+        $toSeparator = '#@';
+
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
+                return $key === 'separator' ? $toSeparator : $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf->expects(static::never())->method('set');
+
+        $result = $this->controller->changeSeparator($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertSame(
+            ['Tags separator must be a single character.'],
+            $session[SessionManager::KEY_ERROR_MESSAGES]
+        );
+    }
+
+    /**
+     * Test changeSeparator to '#@' (too long): redirection + error message.
+     */
+    public function testChangeSeparatorInvalidReservedCharacter(): void
+    {
+        $toSeparator = '*';
+
+        $session = [];
+        $this->assignSessionVars($session);
+
+        $request = $this->createMock(Request::class);
+        $request
+            ->expects(static::atLeastOnce())
+            ->method('getParam')
+            ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
+                return $key === 'separator' ? $toSeparator : $key;
+            })
+        ;
+        $response = new Response();
+
+        $this->container->conf->expects(static::never())->method('set');
+
+        $result = $this->controller->changeSeparator($request, $response);
+
+        static::assertSame(302, $result->getStatusCode());
+        static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
+
+        static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
+        static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
+        static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
+        static::assertStringStartsWith(
+            'These characters are reserved and can\'t be used as tags separator',
+            $session[SessionManager::KEY_ERROR_MESSAGES][0]
+        );
+    }
 }
index f20b1def5f1b36196078431d83bc218195fa9945..964773da1e15e9f63b9a0f12dbe1387348cec732 100644 (file)
@@ -101,7 +101,7 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($expectedUrl, $assignedVariables['link']['url']);
         static::assertSame($remoteTitle, $assignedVariables['link']['title']);
         static::assertSame($remoteDesc, $assignedVariables['link']['description']);
-        static::assertSame($remoteTags, $assignedVariables['link']['tags']);
+        static::assertSame($remoteTags . ' ', $assignedVariables['link']['tags']);
         static::assertFalse($assignedVariables['link']['private']);
 
         static::assertTrue($assignedVariables['link_is_new']);
@@ -192,7 +192,7 @@ class DisplayCreateFormTest extends TestCase
             'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash',
             'title' => 'Provided Title',
             'description' => 'Provided description.',
-            'tags' => 'abc def',
+            'tags' => 'abc@def',
             'private' => '1',
             'source' => 'apps',
         ];
@@ -216,7 +216,7 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($expectedUrl, $assignedVariables['link']['url']);
         static::assertSame($parameters['title'], $assignedVariables['link']['title']);
         static::assertSame($parameters['description'], $assignedVariables['link']['description']);
-        static::assertSame($parameters['tags'], $assignedVariables['link']['tags']);
+        static::assertSame($parameters['tags'] . '@', $assignedVariables['link']['tags']);
         static::assertTrue($assignedVariables['link']['private']);
         static::assertTrue($assignedVariables['link_is_new']);
         static::assertSame($parameters['source'], $assignedVariables['source']);
@@ -360,7 +360,7 @@ class DisplayCreateFormTest extends TestCase
         static::assertSame($expectedUrl, $assignedVariables['link']['url']);
         static::assertSame($title, $assignedVariables['link']['title']);
         static::assertSame($description, $assignedVariables['link']['description']);
-        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
         static::assertTrue($assignedVariables['link']['private']);
         static::assertSame($createdAt, $assignedVariables['link']['created']);
     }
index da393e4936ad8efa008ccf780aac5f2e2ef6ec1c..738cea1230a9841b9715fefb3c4cb25165a2e244 100644 (file)
@@ -74,7 +74,7 @@ class DisplayEditFormTest extends TestCase
         static::assertSame($url, $assignedVariables['link']['url']);
         static::assertSame($title, $assignedVariables['link']['title']);
         static::assertSame($description, $assignedVariables['link']['description']);
-        static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']);
+        static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
         static::assertTrue($assignedVariables['link']['private']);
         static::assertSame($createdAt, $assignedVariables['link']['created']);
     }
index 5cbc8c732a76f8726564b3a85138cc9f2cd6473b..dec938f209516d65ff479021c2d6177c295a3d71 100644 (file)
@@ -173,7 +173,7 @@ class BookmarkListControllerTest extends TestCase
         $request = $this->createMock(Request::class);
         $request->method('getParam')->willReturnCallback(function (string $key) {
             if ('searchtags' === $key) {
-                return 'abc def';
+                return 'abc@def';
             }
             if ('searchterm' === $key) {
                 return 'ghi jkl';
@@ -204,7 +204,7 @@ class BookmarkListControllerTest extends TestCase
             ->expects(static::once())
             ->method('search')
             ->with(
-                ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'],
+                ['searchtags' => 'abc@def', 'searchterm' => 'ghi jkl'],
                 'private',
                 false,
                 true
@@ -222,7 +222,7 @@ class BookmarkListControllerTest extends TestCase
         static::assertSame('linklist', (string) $result->getBody());
 
         static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']);
-        static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc+def', $assignedVariables['previous_page_url']);
+        static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc%40def', $assignedVariables['previous_page_url']);
     }
 
     /**
index fc0bb7d1a3cc123424674c40070601ec241bb0d6..02229f68026dca2a8934612de915d9d3020dded6 100644 (file)
@@ -41,6 +41,10 @@ trait FrontControllerMockHelper
         // Config
         $this->container->conf = $this->createMock(ConfigManager::class);
         $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
+            if ($parameter === 'general.tags_separator') {
+                return '@';
+            }
+
             return $default === null ? $parameter : $default;
         });
 
index 9305612ed484dbbef775567d39c46a3b8fce7c5b..4915573d11cf40f5e35f437a9cda47f0e00bf681 100644 (file)
@@ -100,7 +100,7 @@ class TagCloudControllerTest extends TestCase
             ->with()
             ->willReturnCallback(function (string $key): ?string {
                 if ('searchtags' === $key) {
-                    return 'ghi def';
+                    return 'ghi@def';
                 }
 
                 return null;
@@ -131,7 +131,7 @@ class TagCloudControllerTest extends TestCase
             ->withConsecutive(['render_tagcloud'])
             ->willReturnCallback(function (string $hook, array $data, array $param): array {
                if ('render_tagcloud' === $hook) {
-                   static::assertSame('ghi def', $data['search_tags']);
+                   static::assertSame('ghi@def@', $data['search_tags']);
                    static::assertCount(1, $data['tags']);
 
                    static::assertArrayHasKey('loggedin', $param);
@@ -147,7 +147,7 @@ class TagCloudControllerTest extends TestCase
         static::assertSame('tag.cloud', (string) $result->getBody());
         static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']);
 
-        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertSame('ghi@def@', $assignedVariables['search_tags']);
         static::assertCount(1, $assignedVariables['tags']);
 
         static::assertArrayHasKey('abc', $assignedVariables['tags']);
@@ -277,7 +277,7 @@ class TagCloudControllerTest extends TestCase
             ->with()
             ->willReturnCallback(function (string $key): ?string {
                 if ('searchtags' === $key) {
-                    return 'ghi def';
+                    return 'ghi@def';
                 } elseif ('sort' === $key) {
                     return 'alpha';
                 }
@@ -310,7 +310,7 @@ class TagCloudControllerTest extends TestCase
             ->withConsecutive(['render_taglist'])
             ->willReturnCallback(function (string $hook, array $data, array $param): array {
                 if ('render_taglist' === $hook) {
-                    static::assertSame('ghi def', $data['search_tags']);
+                    static::assertSame('ghi@def@', $data['search_tags']);
                     static::assertCount(1, $data['tags']);
 
                     static::assertArrayHasKey('loggedin', $param);
@@ -326,7 +326,7 @@ class TagCloudControllerTest extends TestCase
         static::assertSame('tag.list', (string) $result->getBody());
         static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']);
 
-        static::assertSame('ghi def', $assignedVariables['search_tags']);
+        static::assertSame('ghi@def@', $assignedVariables['search_tags']);
         static::assertCount(1, $assignedVariables['tags']);
         static::assertSame(3, $assignedVariables['tags']['abc']);
     }
index 750ea02d85c47aea9f5f566089c1b8a9aaf94543..5a556c6def37631a170b0b5d955aa819c2284ca7 100644 (file)
@@ -50,7 +50,7 @@ class TagControllerTest extends TestCase
 
         static::assertInstanceOf(Response::class, $result);
         static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+        static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
     }
 
     public function testAddTagWithoutRefererAndExistingSearch(): void
@@ -80,7 +80,7 @@ class TagControllerTest extends TestCase
 
         static::assertInstanceOf(Response::class, $result);
         static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+        static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
     }
 
     public function testAddTagResetPagination(): void
@@ -96,7 +96,7 @@ class TagControllerTest extends TestCase
 
         static::assertInstanceOf(Response::class, $result);
         static::assertSame(302, $result->getStatusCode());
-        static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location'));
+        static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
     }
 
     public function testAddTagWithRefererAndEmptySearch(): void
index c526d5c8382699c27a57ffbc12622e153c42c4cf..6856ebcafebdecc18070638daa6430c6c71386da 100644 (file)
@@ -531,7 +531,7 @@ class BookmarkImportTest extends TestCase
     {
         $post = array(
             'privacy' => 'public',
-            'default_tags' => 'tag1,tag2 tag3'
+            'default_tags' => 'tag1 tag2 tag3'
         );
         $files = file2array('netscape_basic.htm');
         $this->assertStringMatchesFormat(
@@ -552,7 +552,7 @@ class BookmarkImportTest extends TestCase
     {
         $post = array(
             'privacy' => 'public',
-            'default_tags' => 'tag1&,tag2 "tag3"'
+            'default_tags' => 'tag1& tag2 "tag3"'
         );
         $files = file2array('netscape_basic.htm');
         $this->assertStringMatchesFormat(
@@ -572,6 +572,43 @@ class BookmarkImportTest extends TestCase
         );
     }
 
+    /**
+     * Add user-specified tags to all imported bookmarks
+     */
+    public function testSetDefaultTagsWithCustomSeparator()
+    {
+        $separator = '@';
+        $this->conf->set('general.tags_separator', $separator);
+        $post = [
+            'privacy' => 'public',
+            'default_tags' => 'tag1@tag2@tag3@multiple words tag'
+        ];
+        $files = file2array('netscape_basic.htm');
+        $this->assertStringMatchesFormat(
+            'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
+            .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
+            $this->netscapeBookmarkUtils->import($post, $files)
+        );
+        $this->assertEquals(2, $this->bookmarkService->count());
+        $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
+        $this->assertEquals(
+            'tag1@tag2@tag3@multiple words tag@private@secret',
+            $this->bookmarkService->get(0)->getTagsString($separator)
+        );
+        $this->assertEquals(
+            ['tag1', 'tag2', 'tag3', 'multiple words tag', 'private', 'secret'],
+            $this->bookmarkService->get(0)->getTags()
+        );
+        $this->assertEquals(
+            'tag1@tag2@tag3@multiple words tag@public@hello@world',
+            $this->bookmarkService->get(1)->getTagsString($separator)
+        );
+        $this->assertEquals(
+            ['tag1', 'tag2', 'tag3', 'multiple words tag', 'public', 'hello', 'world'],
+            $this->bookmarkService->get(1)->getTags()
+        );
+    }
+
     /**
      * Ensure each imported bookmark has a unique id
      *
index a5fbd31e4952ff723900b837dd81d0c2978c8020..13b7f24a61b83188c8d899ad1d78daf3878b096b 100644 (file)
     <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p>
   </div>
 </div>
+
+<div class="pure-g">
+  <div class="pure-u-lg-1-3 pure-u-1-24"></div>
+  <div class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
+    <h2 class="window-title">{"Change tags separator"|t}</h2>
+    <form method="POST" action="{$base_path}/admin/tags/change-separator" name="changeseparator" id="changeseparator">
+      <p>
+        {'Your current tag separator is'|t} <code>{$tags_separator}</code>{if="!empty($tags_separator_desc)"} ({$tags_separator_desc}){/if}.
+      </p>
+      <div>
+        <input type="text" name="separator" placeholder="{'New separator'|t}"
+               id="separator">
+      </div>
+      <input type="hidden" name="token" value="{$token}">
+      <div>
+        <input type="submit" value="{'Save'|t}" name="saveseparator">
+      </div>
+      <p>
+        {'Note that hashtags won\'t fully work with a non-whitespace separator.'|t}
+      </p>
+    </form>
+  </div>
+</div>
 {include="page.footer"}
 </body>
 </html>
index e1115d49b61469a74f7179b9dda3feccfb37baf9..7208a3b6050ea87c909632ccddca1a88cc4c0561 100644 (file)
@@ -90,7 +90,7 @@
           {'for'|t} <em><strong>{$search_term}</strong></em>
         {/if}
         {if="!empty($search_tags)"}
-          {$exploded_tags=explode(' ', $search_tags)}
+          {$exploded_tags=tags_str2array($search_tags, $tags_separator)}
           {'tagged'|t}
           {loop="$exploded_tags"}
               <span class="label label-tag" title="{'Remove tag'|t}">
index 964ffff110b210f12a77fe588ddddd05b4bf5588..58ca18c5726da2b3519ff37cd3c47e2834322b5c 100644 (file)
@@ -18,8 +18,6 @@
   <div class="pure-u-2-24"></div>
 </div>
 
-<input type="hidden" name="token" value="{$token}" id="token" />
-
 {loop="$plugins_footer.endofpage"}
     {$value}
 {/loop}
@@ -41,4 +39,7 @@
 </div>
 
 <input type="hidden" name="js_base_path" value="{$base_path}" />
+<input type="hidden" name="token" value="{$token}" id="token" />
+<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
+
 <script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script>
index c067e1d459ed76dc232cc8e5f706e1f9a897306f..01b50b0217501ec38fa77d4f0facc57a9b4545a3 100644 (file)
@@ -48,7 +48,7 @@
 
     <div id="cloudtag" class="cloudtag-container">
       {loop="tags"}
-        <a href="{$base_path}/?searchtags={$tags_url.$key1} {$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a
+        <a href="{$base_path}/?searchtags={$tags_url.$key1}{$tags_separator|urlencode}{$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a
         ><a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
         {loop="$value.tag_plugin"}
           {$value}