aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2020-11-08 14:07:33 +0100
committerGitHub <noreply@github.com>2020-11-08 14:07:33 +0100
commitd9d71b10c3bc70a0881d630b37dc4e918c9e812f (patch)
treed8f772a9106fbd5072ebee1b9fa536babe90f7b1
parentc51d65238be43d61b7e6a6f9940948afea0c13fa (diff)
parent8a1ce1da15fdbae99b24700b06f2008c7a657603 (diff)
downloadShaarli-d9d71b10c3bc70a0881d630b37dc4e918c9e812f.tar.gz
Shaarli-d9d71b10c3bc70a0881d630b37dc4e918c9e812f.tar.zst
Shaarli-d9d71b10c3bc70a0881d630b37dc4e918c9e812f.zip
Merge pull request #1621 from ArthurHoaro/feature/tag-separators
-rw-r--r--application/bookmark/Bookmark.php39
-rw-r--r--application/bookmark/BookmarkFileService.php2
-rw-r--r--application/bookmark/BookmarkFilter.php47
-rw-r--r--application/bookmark/LinkUtils.php46
-rw-r--r--application/config/ConfigManager.php1
-rw-r--r--application/formatter/BookmarkDefaultFormatter.php7
-rw-r--r--application/formatter/BookmarkFormatter.php3
-rw-r--r--application/front/controller/admin/ManageTagController.php33
-rw-r--r--application/front/controller/admin/ShaareManageController.php4
-rw-r--r--application/front/controller/admin/ShaarePublishController.php12
-rw-r--r--application/front/controller/visitor/BookmarkListController.php11
-rw-r--r--application/front/controller/visitor/TagCloudController.php10
-rw-r--r--application/front/controller/visitor/TagController.php10
-rw-r--r--application/http/HttpAccess.php6
-rw-r--r--application/http/HttpUtils.php12
-rw-r--r--application/http/MetadataRetriever.php4
-rw-r--r--application/legacy/LegacyUpdater.php2
-rw-r--r--application/netscape/NetscapeBookmarkUtils.php10
-rw-r--r--application/render/PageBuilder.php1
-rw-r--r--assets/default/js/base.js29
-rw-r--r--assets/default/scss/shaarli.scss10
-rw-r--r--assets/vintage/js/base.js45
-rw-r--r--composer.json2
-rw-r--r--composer.lock65
-rw-r--r--doc/md/Shaarli-configuration.md2
-rw-r--r--index.php1
-rw-r--r--tests/bookmark/BookmarkFilterTest.php2
-rw-r--r--tests/bookmark/BookmarkTest.php25
-rw-r--r--tests/bookmark/LinkUtilsTest.php124
-rw-r--r--tests/front/controller/admin/ManageTagControllerTest.php136
-rw-r--r--tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php8
-rw-r--r--tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php2
-rw-r--r--tests/front/controller/visitor/BookmarkListControllerTest.php6
-rw-r--r--tests/front/controller/visitor/FrontControllerMockHelper.php4
-rw-r--r--tests/front/controller/visitor/TagCloudControllerTest.php12
-rw-r--r--tests/front/controller/visitor/TagControllerTest.php6
-rw-r--r--tests/netscape/BookmarkImportTest.php41
-rw-r--r--tpl/default/changetag.html23
-rw-r--r--tpl/default/linklist.html2
-rw-r--r--tpl/default/page.footer.html5
-rw-r--r--tpl/default/tag.cloud.html2
-rw-r--r--tpl/vintage/includes.html4
-rw-r--r--tpl/vintage/linklist.html2
-rw-r--r--tpl/vintage/page.footer.html6
44 files changed, 655 insertions, 169 deletions
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
index 4810c5e6..8aaeb9d8 100644
--- a/application/bookmark/Bookmark.php
+++ b/application/bookmark/Bookmark.php
@@ -60,11 +60,13 @@ class Bookmark
60 /** 60 /**
61 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format. 61 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
62 * 62 *
63 * @param array $data 63 * @param array $data
64 * @param string $tagsSeparator Tags separator loaded from the config file.
65 * This is a context data, and it should *never* be stored in the Bookmark object.
64 * 66 *
65 * @return $this 67 * @return $this
66 */ 68 */
67 public function fromArray(array $data): Bookmark 69 public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
68 { 70 {
69 $this->id = $data['id'] ?? null; 71 $this->id = $data['id'] ?? null;
70 $this->shortUrl = $data['shorturl'] ?? null; 72 $this->shortUrl = $data['shorturl'] ?? null;
@@ -77,7 +79,7 @@ class Bookmark
77 if (is_array($data['tags'])) { 79 if (is_array($data['tags'])) {
78 $this->tags = $data['tags']; 80 $this->tags = $data['tags'];
79 } else { 81 } else {
80 $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY); 82 $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
81 } 83 }
82 if (! empty($data['updated'])) { 84 if (! empty($data['updated'])) {
83 $this->updated = $data['updated']; 85 $this->updated = $data['updated'];
@@ -348,7 +350,12 @@ class Bookmark
348 */ 350 */
349 public function setTags(?array $tags): Bookmark 351 public function setTags(?array $tags): Bookmark
350 { 352 {
351 $this->setTagsString(implode(' ', $tags ?? [])); 353 $this->tags = array_map(
354 function (string $tag): string {
355 return $tag[0] === '-' ? substr($tag, 1) : $tag;
356 },
357 tags_filter($tags, ' ')
358 );
352 359
353 return $this; 360 return $this;
354 } 361 }
@@ -420,11 +427,13 @@ class Bookmark
420 } 427 }
421 428
422 /** 429 /**
423 * @return string Bookmark's tags as a string, separated by a space 430 * @param string $separator Tags separator loaded from the config file.
431 *
432 * @return string Bookmark's tags as a string, separated by a separator
424 */ 433 */
425 public function getTagsString(): string 434 public function getTagsString(string $separator = ' '): string
426 { 435 {
427 return implode(' ', $this->getTags()); 436 return tags_array2str($this->getTags(), $separator);
428 } 437 }
429 438
430 /** 439 /**
@@ -444,19 +453,13 @@ class Bookmark
444 * - trailing dash in tags will be removed 453 * - trailing dash in tags will be removed
445 * 454 *
446 * @param string|null $tags 455 * @param string|null $tags
456 * @param string $separator Tags separator loaded from the config file.
447 * 457 *
448 * @return $this 458 * @return $this
449 */ 459 */
450 public function setTagsString(?string $tags): Bookmark 460 public function setTagsString(?string $tags, string $separator = ' '): Bookmark
451 { 461 {
452 // Remove first '-' char in tags. 462 $this->setTags(tags_str2array($tags, $separator));
453 $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
454 // Explode all tags separted by spaces or commas
455 $tags = preg_split('/[\s,]+/', $tags);
456 // Remove eventual empty values
457 $tags = array_values(array_filter($tags));
458
459 $this->tags = $tags;
460 463
461 return $this; 464 return $this;
462 } 465 }
@@ -507,7 +510,7 @@ class Bookmark
507 */ 510 */
508 public function renameTag(string $fromTag, string $toTag): void 511 public function renameTag(string $fromTag, string $toTag): void
509 { 512 {
510 if (($pos = array_search($fromTag, $this->tags)) !== false) { 513 if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
511 $this->tags[$pos] = trim($toTag); 514 $this->tags[$pos] = trim($toTag);
512 } 515 }
513 } 516 }
@@ -519,7 +522,7 @@ class Bookmark
519 */ 522 */
520 public function deleteTag(string $tag): void 523 public function deleteTag(string $tag): void
521 { 524 {
522 if (($pos = array_search($tag, $this->tags)) !== false) { 525 if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
523 unset($this->tags[$pos]); 526 unset($this->tags[$pos]);
524 $this->tags = array_values($this->tags); 527 $this->tags = array_values($this->tags);
525 } 528 }
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index 3ea98a45..85efeea6 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -91,7 +91,7 @@ class BookmarkFileService implements BookmarkServiceInterface
91 } 91 }
92 } 92 }
93 93
94 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); 94 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
95 } 95 }
96 96
97 /** 97 /**
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index c79386ea..5d8733dc 100644
--- a/application/bookmark/BookmarkFilter.php
+++ b/application/bookmark/BookmarkFilter.php
@@ -6,6 +6,7 @@ namespace Shaarli\Bookmark;
6 6
7use Exception; 7use Exception;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Config\ConfigManager;
9 10
10/** 11/**
11 * Class LinkFilter. 12 * Class LinkFilter.
@@ -58,12 +59,16 @@ class BookmarkFilter
58 */ 59 */
59 private $bookmarks; 60 private $bookmarks;
60 61
62 /** @var ConfigManager */
63 protected $conf;
64
61 /** 65 /**
62 * @param Bookmark[] $bookmarks initialization. 66 * @param Bookmark[] $bookmarks initialization.
63 */ 67 */
64 public function __construct($bookmarks) 68 public function __construct($bookmarks, ConfigManager $conf)
65 { 69 {
66 $this->bookmarks = $bookmarks; 70 $this->bookmarks = $bookmarks;
71 $this->conf = $conf;
67 } 72 }
68 73
69 /** 74 /**
@@ -107,10 +112,14 @@ class BookmarkFilter
107 $filtered = $this->bookmarks; 112 $filtered = $this->bookmarks;
108 } 113 }
109 if (!empty($request[0])) { 114 if (!empty($request[0])) {
110 $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); 115 $filtered = (new BookmarkFilter($filtered, $this->conf))
116 ->filterTags($request[0], $casesensitive, $visibility)
117 ;
111 } 118 }
112 if (!empty($request[1])) { 119 if (!empty($request[1])) {
113 $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); 120 $filtered = (new BookmarkFilter($filtered, $this->conf))
121 ->filterFulltext($request[1], $visibility)
122 ;
114 } 123 }
115 return $filtered; 124 return $filtered;
116 case self::$FILTER_TEXT: 125 case self::$FILTER_TEXT:
@@ -280,8 +289,9 @@ class BookmarkFilter
280 * 289 *
281 * @return string generated regex fragment 290 * @return string generated regex fragment
282 */ 291 */
283 private static function tag2regex(string $tag): string 292 protected function tag2regex(string $tag): string
284 { 293 {
294 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
285 $len = strlen($tag); 295 $len = strlen($tag);
286 if (!$len || $tag === "-" || $tag === "*") { 296 if (!$len || $tag === "-" || $tag === "*") {
287 // nothing to search, return empty regex 297 // nothing to search, return empty regex
@@ -295,12 +305,13 @@ class BookmarkFilter
295 $i = 0; // start at first character 305 $i = 0; // start at first character
296 $regex = '(?='; // use positive lookahead 306 $regex = '(?='; // use positive lookahead
297 } 307 }
298 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning 308 // before tag may only be the separator or the beginning
309 $regex .= '.*(?:^|' . $tagsSeparator . ')';
299 // iterate over string, separating it into placeholder and content 310 // iterate over string, separating it into placeholder and content
300 for (; $i < $len; $i++) { 311 for (; $i < $len; $i++) {
301 if ($tag[$i] === '*') { 312 if ($tag[$i] === '*') {
302 // placeholder found 313 // placeholder found
303 $regex .= '[^ ]*?'; 314 $regex .= '[^' . $tagsSeparator . ']*?';
304 } else { 315 } else {
305 // regular characters 316 // regular characters
306 $offset = strpos($tag, '*', $i); 317 $offset = strpos($tag, '*', $i);
@@ -316,7 +327,8 @@ class BookmarkFilter
316 $i = $offset; 327 $i = $offset;
317 } 328 }
318 } 329 }
319 $regex .= '(?:$| ))'; // after the tag may only be a space or the end 330 // after the tag may only be the separator or the end
331 $regex .= '(?:$|' . $tagsSeparator . '))';
320 return $regex; 332 return $regex;
321 } 333 }
322 334
@@ -334,14 +346,15 @@ class BookmarkFilter
334 */ 346 */
335 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all') 347 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
336 { 348 {
349 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
337 // get single tags (we may get passed an array, even though the docs say different) 350 // get single tags (we may get passed an array, even though the docs say different)
338 $inputTags = $tags; 351 $inputTags = $tags;
339 if (!is_array($tags)) { 352 if (!is_array($tags)) {
340 // we got an input string, split tags 353 // we got an input string, split tags
341 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); 354 $inputTags = tags_str2array($inputTags, $tagsSeparator);
342 } 355 }
343 356
344 if (!count($inputTags)) { 357 if (count($inputTags) === 0) {
345 // no input tags 358 // no input tags
346 return $this->noFilter($visibility); 359 return $this->noFilter($visibility);
347 } 360 }
@@ -358,7 +371,7 @@ class BookmarkFilter
358 } 371 }
359 372
360 // build regex from all tags 373 // build regex from all tags
361 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; 374 $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
362 if (!$casesensitive) { 375 if (!$casesensitive) {
363 // make regex case insensitive 376 // make regex case insensitive
364 $re .= 'i'; 377 $re .= 'i';
@@ -378,7 +391,8 @@ class BookmarkFilter
378 continue; 391 continue;
379 } 392 }
380 } 393 }
381 $search = $link->getTagsString(); // build search string, start with tags of current link 394 // build search string, start with tags of current link
395 $search = $link->getTagsString($tagsSeparator);
382 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { 396 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
383 // description given and at least one possible tag found 397 // description given and at least one possible tag found
384 $descTags = array(); 398 $descTags = array();
@@ -390,9 +404,9 @@ class BookmarkFilter
390 ); 404 );
391 if (count($descTags[1])) { 405 if (count($descTags[1])) {
392 // there were some tags in the description, add them to the search string 406 // there were some tags in the description, add them to the search string
393 $search .= ' ' . implode(' ', $descTags[1]); 407 $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
394 } 408 }
395 }; 409 }
396 // match regular expression with search string 410 // match regular expression with search string
397 if (!preg_match($re, $search)) { 411 if (!preg_match($re, $search)) {
398 // this entry does _not_ match our regex 412 // this entry does _not_ match our regex
@@ -422,7 +436,7 @@ class BookmarkFilter
422 } 436 }
423 } 437 }
424 438
425 if (empty(trim($link->getTagsString()))) { 439 if (empty($link->getTags())) {
426 $filtered[$key] = $link; 440 $filtered[$key] = $link;
427 } 441 }
428 } 442 }
@@ -537,10 +551,11 @@ class BookmarkFilter
537 */ 551 */
538 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string 552 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
539 { 553 {
554 $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
540 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; 555 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
541 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; 556 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
542 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; 557 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
543 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; 558 $content .= mb_convert_case($tagString, MB_CASE_LOWER, 'UTF-8') .'\\';
544 559
545 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())]; 560 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
546 $nextField = $lengths['title']['end'] + 1; 561 $nextField = $lengths['title']['end'] + 1;
@@ -548,7 +563,7 @@ class BookmarkFilter
548 $nextField = $lengths['description']['end'] + 1; 563 $nextField = $lengths['description']['end'] + 1;
549 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())]; 564 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
550 $nextField = $lengths['url']['end'] + 1; 565 $nextField = $lengths['url']['end'] + 1;
551 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())]; 566 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
552 567
553 return $content; 568 return $content;
554 } 569 }
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index a74fda57..cf97e3b0 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -176,3 +176,49 @@ function is_note($linkUrl)
176{ 176{
177 return isset($linkUrl[0]) && $linkUrl[0] === '?'; 177 return isset($linkUrl[0]) && $linkUrl[0] === '?';
178} 178}
179
180/**
181 * Extract an array of tags from a given tag string, with provided separator.
182 *
183 * @param string|null $tags String containing a list of tags separated by $separator.
184 * @param string $separator Shaarli's default: ' ' (whitespace)
185 *
186 * @return array List of tags
187 */
188function tags_str2array(?string $tags, string $separator): array
189{
190 // For whitespaces, we use the special \s regex character
191 $separator = $separator === ' ' ? '\s' : $separator;
192
193 return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
194}
195
196/**
197 * Return a tag string with provided separator from a list of tags.
198 * Note that given array is clean up by tags_filter().
199 *
200 * @param array|null $tags List of tags
201 * @param string $separator
202 *
203 * @return string
204 */
205function tags_array2str(?array $tags, string $separator): string
206{
207 return implode($separator, tags_filter($tags, $separator));
208}
209
210/**
211 * Clean an array of tags: trim + remove empty entries
212 *
213 * @param array|null $tags List of tags
214 * @param string $separator
215 *
216 * @return array
217 */
218function tags_filter(?array $tags, string $separator): array
219{
220 $trimDefault = " \t\n\r\0\x0B";
221 return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
222 return trim($entry, $trimDefault . $separator);
223 }, $tags ?? [])));
224}
diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php
index fb085023..a035baae 100644
--- a/application/config/ConfigManager.php
+++ b/application/config/ConfigManager.php
@@ -368,6 +368,7 @@ class ConfigManager
368 $this->setEmpty('general.default_note_title', 'Note: '); 368 $this->setEmpty('general.default_note_title', 'Note: ');
369 $this->setEmpty('general.retrieve_description', true); 369 $this->setEmpty('general.retrieve_description', true);
370 $this->setEmpty('general.enable_async_metadata', true); 370 $this->setEmpty('general.enable_async_metadata', true);
371 $this->setEmpty('general.tags_separator', ' ');
371 372
372 $this->setEmpty('updates.check_updates', false); 373 $this->setEmpty('updates.check_updates', false);
373 $this->setEmpty('updates.check_updates_branch', 'stable'); 374 $this->setEmpty('updates.check_updates_branch', 'stable');
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
index 149a3eb9..51bea0f1 100644
--- a/application/formatter/BookmarkDefaultFormatter.php
+++ b/application/formatter/BookmarkDefaultFormatter.php
@@ -68,15 +68,16 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
68 */ 68 */
69 protected function formatTagListHtml($bookmark) 69 protected function formatTagListHtml($bookmark)
70 { 70 {
71 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
71 if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { 72 if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) {
72 return $this->formatTagList($bookmark); 73 return $this->formatTagList($bookmark);
73 } 74 }
74 75
75 $tags = $this->tokenizeSearchHighlightField( 76 $tags = $this->tokenizeSearchHighlightField(
76 $bookmark->getTagsString(), 77 $bookmark->getTagsString($tagsSeparator),
77 $bookmark->getAdditionalContentEntry('search_highlight')['tags'] 78 $bookmark->getAdditionalContentEntry('search_highlight')['tags']
78 ); 79 );
79 $tags = $this->filterTagList(explode(' ', $tags)); 80 $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator));
80 $tags = escape($tags); 81 $tags = escape($tags);
81 $tags = $this->replaceTokensArray($tags); 82 $tags = $this->replaceTokensArray($tags);
82 83
@@ -88,7 +89,7 @@ class BookmarkDefaultFormatter extends BookmarkFormatter
88 */ 89 */
89 protected function formatTagString($bookmark) 90 protected function formatTagString($bookmark)
90 { 91 {
91 return implode(' ', $this->formatTagList($bookmark)); 92 return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark));
92 } 93 }
93 94
94 /** 95 /**
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
index e1b7f705..124ce78b 100644
--- a/application/formatter/BookmarkFormatter.php
+++ b/application/formatter/BookmarkFormatter.php
@@ -267,7 +267,7 @@ abstract class BookmarkFormatter
267 */ 267 */
268 protected function formatTagString($bookmark) 268 protected function formatTagString($bookmark)
269 { 269 {
270 return implode(' ', $this->formatTagList($bookmark)); 270 return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark));
271 } 271 }
272 272
273 /** 273 /**
@@ -351,6 +351,7 @@ abstract class BookmarkFormatter
351 351
352 /** 352 /**
353 * Format tag list, e.g. remove private tags if the user is not logged in. 353 * Format tag list, e.g. remove private tags if the user is not logged in.
354 * TODO: this method is called multiple time to format tags, the result should be cached.
354 * 355 *
355 * @param array $tags 356 * @param array $tags
356 * 357 *
diff --git a/application/front/controller/admin/ManageTagController.php b/application/front/controller/admin/ManageTagController.php
index 2065c3e2..22fb461c 100644
--- a/application/front/controller/admin/ManageTagController.php
+++ b/application/front/controller/admin/ManageTagController.php
@@ -24,6 +24,12 @@ class ManageTagController extends ShaarliAdminController
24 $fromTag = $request->getParam('fromtag') ?? ''; 24 $fromTag = $request->getParam('fromtag') ?? '';
25 25
26 $this->assignView('fromtag', escape($fromTag)); 26 $this->assignView('fromtag', escape($fromTag));
27 $separator = escape($this->container->conf->get('general.tags_separator', ' '));
28 if ($separator === ' ') {
29 $separator = '&nbsp;';
30 $this->assignView('tags_separator_desc', t('whitespace'));
31 }
32 $this->assignView('tags_separator', $separator);
27 $this->assignView( 33 $this->assignView(
28 'pagetitle', 34 'pagetitle',
29 t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli') 35 t('Manage tags') .' - '. $this->container->conf->get('general.title', 'Shaarli')
@@ -85,4 +91,31 @@ class ManageTagController extends ShaarliAdminController
85 91
86 return $this->redirect($response, $redirect); 92 return $this->redirect($response, $redirect);
87 } 93 }
94
95 /**
96 * POST /admin/tags/change-separator - Change tag separator
97 */
98 public function changeSeparator(Request $request, Response $response): Response
99 {
100 $this->checkToken($request);
101
102 $reservedCharacters = ['-', '.', '*'];
103 $newSeparator = $request->getParam('separator');
104 if ($newSeparator === null || mb_strlen($newSeparator) !== 1) {
105 $this->saveErrorMessage(t('Tags separator must be a single character.'));
106 } elseif (in_array($newSeparator, $reservedCharacters, true)) {
107 $reservedCharacters = implode(' ', array_map(function (string $character) {
108 return '<code>' . $character . '</code>';
109 }, $reservedCharacters));
110 $this->saveErrorMessage(
111 t('These characters are reserved and can\'t be used as tags separator: ') . $reservedCharacters
112 );
113 } else {
114 $this->container->conf->set('general.tags_separator', $newSeparator, true, true);
115
116 $this->saveSuccessMessage('Your tags separator setting has been updated!');
117 }
118
119 return $this->redirect($response, '/admin/tags');
120 }
88} 121}
diff --git a/application/front/controller/admin/ShaareManageController.php b/application/front/controller/admin/ShaareManageController.php
index 2ed298f5..0b143172 100644
--- a/application/front/controller/admin/ShaareManageController.php
+++ b/application/front/controller/admin/ShaareManageController.php
@@ -125,7 +125,7 @@ class ShaareManageController extends ShaarliAdminController
125 // To preserve backward compatibility with 3rd parties, plugins still use arrays 125 // To preserve backward compatibility with 3rd parties, plugins still use arrays
126 $data = $formatter->format($bookmark); 126 $data = $formatter->format($bookmark);
127 $this->executePageHooks('save_link', $data); 127 $this->executePageHooks('save_link', $data);
128 $bookmark->fromArray($data); 128 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
129 129
130 $this->container->bookmarkService->set($bookmark, false); 130 $this->container->bookmarkService->set($bookmark, false);
131 ++$count; 131 ++$count;
@@ -167,7 +167,7 @@ class ShaareManageController extends ShaarliAdminController
167 // To preserve backward compatibility with 3rd parties, plugins still use arrays 167 // To preserve backward compatibility with 3rd parties, plugins still use arrays
168 $data = $formatter->format($bookmark); 168 $data = $formatter->format($bookmark);
169 $this->executePageHooks('save_link', $data); 169 $this->executePageHooks('save_link', $data);
170 $bookmark->fromArray($data); 170 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
171 171
172 $this->container->bookmarkService->set($bookmark); 172 $this->container->bookmarkService->set($bookmark);
173 173
diff --git a/application/front/controller/admin/ShaarePublishController.php b/application/front/controller/admin/ShaarePublishController.php
index 18afc2d1..625a5680 100644
--- a/application/front/controller/admin/ShaarePublishController.php
+++ b/application/front/controller/admin/ShaarePublishController.php
@@ -113,7 +113,10 @@ class ShaarePublishController extends ShaarliAdminController
113 $bookmark->setDescription($request->getParam('lf_description')); 113 $bookmark->setDescription($request->getParam('lf_description'));
114 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', [])); 114 $bookmark->setUrl($request->getParam('lf_url'), $this->container->conf->get('security.allowed_protocols', []));
115 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN)); 115 $bookmark->setPrivate(filter_var($request->getParam('lf_private'), FILTER_VALIDATE_BOOLEAN));
116 $bookmark->setTagsString($request->getParam('lf_tags')); 116 $bookmark->setTagsString(
117 $request->getParam('lf_tags'),
118 $this->container->conf->get('general.tags_separator', ' ')
119 );
117 120
118 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE 121 if ($this->container->conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE
119 && true !== $this->container->conf->get('general.enable_async_metadata', true) 122 && true !== $this->container->conf->get('general.enable_async_metadata', true)
@@ -128,7 +131,7 @@ class ShaarePublishController extends ShaarliAdminController
128 $data = $formatter->format($bookmark); 131 $data = $formatter->format($bookmark);
129 $this->executePageHooks('save_link', $data); 132 $this->executePageHooks('save_link', $data);
130 133
131 $bookmark->fromArray($data); 134 $bookmark->fromArray($data, $this->container->conf->get('general.tags_separator', ' '));
132 $this->container->bookmarkService->set($bookmark); 135 $this->container->bookmarkService->set($bookmark);
133 136
134 // If we are called from the bookmarklet, we must close the popup: 137 // If we are called from the bookmarklet, we must close the popup:
@@ -221,6 +224,11 @@ class ShaarePublishController extends ShaarliAdminController
221 224
222 protected function buildFormData(array $link, bool $isNew, Request $request): array 225 protected function buildFormData(array $link, bool $isNew, Request $request): array
223 { 226 {
227 $link['tags'] = strlen($link['tags']) > 0
228 ? $link['tags'] . $this->container->conf->get('general.tags_separator', ' ')
229 : $link['tags']
230 ;
231
224 return escape([ 232 return escape([
225 'link' => $link, 233 'link' => $link,
226 'link_is_new' => $isNew, 234 'link_is_new' => $isNew,
diff --git a/application/front/controller/visitor/BookmarkListController.php b/application/front/controller/visitor/BookmarkListController.php
index 78c474c9..cc3837ce 100644
--- a/application/front/controller/visitor/BookmarkListController.php
+++ b/application/front/controller/visitor/BookmarkListController.php
@@ -95,6 +95,10 @@ class BookmarkListController extends ShaarliVisitorController
95 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl; 95 $next_page_url = '?page=' . ($page - 1) . $searchtermUrl . $searchtagsUrl;
96 } 96 }
97 97
98 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
99 $searchTagsUrlEncoded = array_map('urlencode', tags_str2array($searchTags, $tagsSeparator));
100 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
101
98 // Fill all template fields. 102 // Fill all template fields.
99 $data = array_merge( 103 $data = array_merge(
100 $this->initializeTemplateVars(), 104 $this->initializeTemplateVars(),
@@ -106,7 +110,7 @@ class BookmarkListController extends ShaarliVisitorController
106 'result_count' => count($linksToDisplay), 110 'result_count' => count($linksToDisplay),
107 'search_term' => escape($searchTerm), 111 'search_term' => escape($searchTerm),
108 'search_tags' => escape($searchTags), 112 'search_tags' => escape($searchTags),
109 'search_tags_url' => array_map('urlencode', explode(' ', $searchTags)), 113 'search_tags_url' => $searchTagsUrlEncoded,
110 'visibility' => $visibility, 114 'visibility' => $visibility,
111 'links' => $linkDisp, 115 'links' => $linkDisp,
112 ] 116 ]
@@ -119,8 +123,9 @@ class BookmarkListController extends ShaarliVisitorController
119 return '[' . $tag . ']'; 123 return '[' . $tag . ']';
120 }; 124 };
121 $data['pagetitle'] .= ! empty($searchTags) 125 $data['pagetitle'] .= ! empty($searchTags)
122 ? implode(' ', array_map($bracketWrap, preg_split('/\s+/', $searchTags))) . ' ' 126 ? implode(' ', array_map($bracketWrap, tags_str2array($searchTags, $tagsSeparator))) . ' '
123 : ''; 127 : ''
128 ;
124 $data['pagetitle'] .= '- '; 129 $data['pagetitle'] .= '- ';
125 } 130 }
126 131
diff --git a/application/front/controller/visitor/TagCloudController.php b/application/front/controller/visitor/TagCloudController.php
index 76ed7690..560cad08 100644
--- a/application/front/controller/visitor/TagCloudController.php
+++ b/application/front/controller/visitor/TagCloudController.php
@@ -47,13 +47,14 @@ class TagCloudController extends ShaarliVisitorController
47 */ 47 */
48 protected function processRequest(string $type, Request $request, Response $response): Response 48 protected function processRequest(string $type, Request $request, Response $response): Response
49 { 49 {
50 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
50 if ($this->container->loginManager->isLoggedIn() === true) { 51 if ($this->container->loginManager->isLoggedIn() === true) {
51 $visibility = $this->container->sessionManager->getSessionParameter('visibility'); 52 $visibility = $this->container->sessionManager->getSessionParameter('visibility');
52 } 53 }
53 54
54 $sort = $request->getQueryParam('sort'); 55 $sort = $request->getQueryParam('sort');
55 $searchTags = $request->getQueryParam('searchtags'); 56 $searchTags = $request->getQueryParam('searchtags');
56 $filteringTags = $searchTags !== null ? explode(' ', $searchTags) : []; 57 $filteringTags = $searchTags !== null ? explode($tagsSeparator, $searchTags) : [];
57 58
58 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null); 59 $tags = $this->container->bookmarkService->bookmarksCountPerTag($filteringTags, $visibility ?? null);
59 60
@@ -71,8 +72,9 @@ class TagCloudController extends ShaarliVisitorController
71 $tagsUrl[escape($tag)] = urlencode((string) $tag); 72 $tagsUrl[escape($tag)] = urlencode((string) $tag);
72 } 73 }
73 74
74 $searchTags = implode(' ', escape($filteringTags)); 75 $searchTags = tags_array2str($filteringTags, $tagsSeparator);
75 $searchTagsUrl = urlencode(implode(' ', $filteringTags)); 76 $searchTags = !empty($searchTags) ? trim($searchTags, $tagsSeparator) . $tagsSeparator : '';
77 $searchTagsUrl = urlencode($searchTags);
76 $data = [ 78 $data = [
77 'search_tags' => escape($searchTags), 79 'search_tags' => escape($searchTags),
78 'search_tags_url' => $searchTagsUrl, 80 'search_tags_url' => $searchTagsUrl,
@@ -82,7 +84,7 @@ class TagCloudController extends ShaarliVisitorController
82 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type); 84 $this->executePageHooks('render_tag' . $type, $data, 'tag.' . $type);
83 $this->assignAllView($data); 85 $this->assignAllView($data);
84 86
85 $searchTags = !empty($searchTags) ? $searchTags .' - ' : ''; 87 $searchTags = !empty($searchTags) ? trim(str_replace($tagsSeparator, ' ', $searchTags)) .' - ' : '';
86 $this->assignView( 88 $this->assignView(
87 'pagetitle', 89 'pagetitle',
88 $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli') 90 $searchTags . t('Tag '. $type) .' - '. $this->container->conf->get('general.title', 'Shaarli')
diff --git a/application/front/controller/visitor/TagController.php b/application/front/controller/visitor/TagController.php
index de4e7ea2..7a3377a7 100644
--- a/application/front/controller/visitor/TagController.php
+++ b/application/front/controller/visitor/TagController.php
@@ -45,9 +45,10 @@ class TagController extends ShaarliVisitorController
45 unset($params['addtag']); 45 unset($params['addtag']);
46 } 46 }
47 47
48 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
48 // Check if this tag is already in the search query and ignore it if it is. 49 // Check if this tag is already in the search query and ignore it if it is.
49 // Each tag is always separated by a space 50 // Each tag is always separated by a space
50 $currentTags = isset($params['searchtags']) ? explode(' ', $params['searchtags']) : []; 51 $currentTags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
51 52
52 $addtag = true; 53 $addtag = true;
53 foreach ($currentTags as $value) { 54 foreach ($currentTags as $value) {
@@ -62,7 +63,7 @@ class TagController extends ShaarliVisitorController
62 $currentTags[] = trim($newTag); 63 $currentTags[] = trim($newTag);
63 } 64 }
64 65
65 $params['searchtags'] = trim(implode(' ', $currentTags)); 66 $params['searchtags'] = tags_array2str($currentTags, $tagsSeparator);
66 67
67 // We also remove page (keeping the same page has no sense, since the results are different) 68 // We also remove page (keeping the same page has no sense, since the results are different)
68 unset($params['page']); 69 unset($params['page']);
@@ -98,10 +99,11 @@ class TagController extends ShaarliVisitorController
98 } 99 }
99 100
100 if (isset($params['searchtags'])) { 101 if (isset($params['searchtags'])) {
101 $tags = explode(' ', $params['searchtags']); 102 $tagsSeparator = $this->container->conf->get('general.tags_separator', ' ');
103 $tags = tags_str2array($params['searchtags'] ?? '', $tagsSeparator);
102 // Remove value from array $tags. 104 // Remove value from array $tags.
103 $tags = array_diff($tags, [$tagToRemove]); 105 $tags = array_diff($tags, [$tagToRemove]);
104 $params['searchtags'] = implode(' ', $tags); 106 $params['searchtags'] = tags_array2str($tags, $tagsSeparator);
105 107
106 if (empty($params['searchtags'])) { 108 if (empty($params['searchtags'])) {
107 unset($params['searchtags']); 109 unset($params['searchtags']);
diff --git a/application/http/HttpAccess.php b/application/http/HttpAccess.php
index 646a5264..e80e0c01 100644
--- a/application/http/HttpAccess.php
+++ b/application/http/HttpAccess.php
@@ -29,14 +29,16 @@ class HttpAccess
29 &$title, 29 &$title,
30 &$description, 30 &$description,
31 &$keywords, 31 &$keywords,
32 $retrieveDescription 32 $retrieveDescription,
33 $tagsSeparator
33 ) { 34 ) {
34 return get_curl_download_callback( 35 return get_curl_download_callback(
35 $charset, 36 $charset,
36 $title, 37 $title,
37 $description, 38 $description,
38 $keywords, 39 $keywords,
39 $retrieveDescription 40 $retrieveDescription,
41 $tagsSeparator
40 ); 42 );
41 } 43 }
42 44
diff --git a/application/http/HttpUtils.php b/application/http/HttpUtils.php
index 28c12969..ed1002b0 100644
--- a/application/http/HttpUtils.php
+++ b/application/http/HttpUtils.php
@@ -550,7 +550,8 @@ function get_curl_download_callback(
550 &$title, 550 &$title,
551 &$description, 551 &$description,
552 &$keywords, 552 &$keywords,
553 $retrieveDescription 553 $retrieveDescription,
554 $tagsSeparator
554) { 555) {
555 $currentChunk = 0; 556 $currentChunk = 0;
556 $foundChunk = null; 557 $foundChunk = null;
@@ -568,6 +569,7 @@ function get_curl_download_callback(
568 */ 569 */
569 return function ($ch, $data) use ( 570 return function ($ch, $data) use (
570 $retrieveDescription, 571 $retrieveDescription,
572 $tagsSeparator,
571 &$charset, 573 &$charset,
572 &$title, 574 &$title,
573 &$description, 575 &$description,
@@ -598,10 +600,10 @@ function get_curl_download_callback(
598 if (! empty($keywords)) { 600 if (! empty($keywords)) {
599 $foundChunk = $currentChunk; 601 $foundChunk = $currentChunk;
600 // Keywords use the format tag1, tag2 multiple words, tag 602 // Keywords use the format tag1, tag2 multiple words, tag
601 // So we format them to match Shaarli's separator and glue multiple words with '-' 603 // So we split the result with `,`, then if a tag contains the separator we replace it by `-`.
602 $keywords = implode(' ', array_map(function($keyword) { 604 $keywords = tags_array2str(array_map(function(string $keyword) use ($tagsSeparator): string {
603 return implode('-', preg_split('/\s+/', trim($keyword))); 605 return tags_array2str(tags_str2array($keyword, $tagsSeparator), '-');
604 }, explode(',', $keywords))); 606 }, tags_str2array($keywords, ',')), $tagsSeparator);
605 } 607 }
606 } 608 }
607 609
diff --git a/application/http/MetadataRetriever.php b/application/http/MetadataRetriever.php
index ba9bd40c..2e1401ec 100644
--- a/application/http/MetadataRetriever.php
+++ b/application/http/MetadataRetriever.php
@@ -38,7 +38,6 @@ class MetadataRetriever
38 $title = null; 38 $title = null;
39 $description = null; 39 $description = null;
40 $tags = null; 40 $tags = null;
41 $retrieveDescription = $this->conf->get('general.retrieve_description');
42 41
43 // Short timeout to keep the application responsive 42 // Short timeout to keep the application responsive
44 // The callback will fill $charset and $title with data from the downloaded page. 43 // The callback will fill $charset and $title with data from the downloaded page.
@@ -52,7 +51,8 @@ class MetadataRetriever
52 $title, 51 $title,
53 $description, 52 $description,
54 $tags, 53 $tags,
55 $retrieveDescription 54 $this->conf->get('general.retrieve_description'),
55 $this->conf->get('general.tags_separator', ' ')
56 ) 56 )
57 ); 57 );
58 58
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php
index fe1a286f..ed949b1e 100644
--- a/application/legacy/LegacyUpdater.php
+++ b/application/legacy/LegacyUpdater.php
@@ -585,7 +585,7 @@ class LegacyUpdater
585 585
586 $linksArray = new BookmarkArray(); 586 $linksArray = new BookmarkArray();
587 foreach ($this->linkDB as $key => $link) { 587 foreach ($this->linkDB as $key => $link) {
588 $linksArray[$key] = (new Bookmark())->fromArray($link); 588 $linksArray[$key] = (new Bookmark())->fromArray($link, $this->conf->get('general.tags_separator', ' '));
589 } 589 }
590 $linksIo = new BookmarkIO($this->conf); 590 $linksIo = new BookmarkIO($this->conf);
591 $linksIo->write($linksArray); 591 $linksIo->write($linksArray);
diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php
index b83f16f8..6ca728b7 100644
--- a/application/netscape/NetscapeBookmarkUtils.php
+++ b/application/netscape/NetscapeBookmarkUtils.php
@@ -101,11 +101,11 @@ class NetscapeBookmarkUtils
101 101
102 // Add tags to all imported bookmarks? 102 // Add tags to all imported bookmarks?
103 if (empty($post['default_tags'])) { 103 if (empty($post['default_tags'])) {
104 $defaultTags = array(); 104 $defaultTags = [];
105 } else { 105 } else {
106 $defaultTags = preg_split( 106 $defaultTags = tags_str2array(
107 '/[\s,]+/', 107 escape($post['default_tags']),
108 escape($post['default_tags']) 108 $this->conf->get('general.tags_separator', ' ')
109 ); 109 );
110 } 110 }
111 111
@@ -171,7 +171,7 @@ class NetscapeBookmarkUtils
171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols')); 171 $link->setUrl($bkm['uri'], $this->conf->get('security.allowed_protocols'));
172 $link->setDescription($bkm['note']); 172 $link->setDescription($bkm['note']);
173 $link->setPrivate($private); 173 $link->setPrivate($private);
174 $link->setTagsString($bkm['tags']); 174 $link->setTags($bkm['tags']);
175 175
176 $this->bookmarkService->addOrSet($link, false); 176 $this->bookmarkService->addOrSet($link, false);
177 $importCount++; 177 $importCount++;
diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php
index c2fae705..bf0ae326 100644
--- a/application/render/PageBuilder.php
+++ b/application/render/PageBuilder.php
@@ -161,6 +161,7 @@ class PageBuilder
161 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); 161 $this->tpl->assign('formatter', $this->conf->get('formatter', 'default'));
162 162
163 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20); 163 $this->tpl->assign('links_per_page', $this->session['LINKS_PER_PAGE'] ?? 20);
164 $this->tpl->assign('tags_separator', $this->conf->get('general.tags_separator', ' '));
164 165
165 // To be removed with a proper theme configuration. 166 // To be removed with a proper theme configuration.
166 $this->tpl->assign('conf', $this->conf); 167 $this->tpl->assign('conf', $this->conf);
diff --git a/assets/default/js/base.js b/assets/default/js/base.js
index 66badfb2..dd532bb7 100644
--- a/assets/default/js/base.js
+++ b/assets/default/js/base.js
@@ -42,19 +42,21 @@ function refreshToken(basePath, callback) {
42 xhr.send(); 42 xhr.send();
43} 43}
44 44
45function createAwesompleteInstance(element, tags = []) { 45function createAwesompleteInstance(element, separator, tags = []) {
46 const awesome = new Awesomplete(Awesomplete.$(element)); 46 const awesome = new Awesomplete(Awesomplete.$(element));
47 // Tags are separated by a space 47
48 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); 48 // Tags are separated by separator
49 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
49 // Insert new selected tag in the input 50 // Insert new selected tag in the input
50 awesome.replace = (text) => { 51 awesome.replace = (text) => {
51 const before = awesome.input.value.match(/^.+ \s*|/)[0]; 52 const before = awesome.input.value.match(new RegExp(`^.+${separator}+|`))[0];
52 awesome.input.value = `${before}${text} `; 53 awesome.input.value = `${before}${text}${separator}`;
53 }; 54 };
54 // Highlight found items 55 // Highlight found items
55 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(/[^ ]*$/)[0]); 56 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${separator}]*$`))[0]);
56 // Don't display already selected items 57 // Don't display already selected items
57 const reg = /(\w+) /g; 58 // WARNING: pseudo classes does not seem to work with string litterals...
59 const reg = new RegExp(`([^${separator}]+)${separator}`, 'g');
58 let match; 60 let match;
59 awesome.data = (item, input) => { 61 awesome.data = (item, input) => {
60 while ((match = reg.exec(input))) { 62 while ((match = reg.exec(input))) {
@@ -78,13 +80,14 @@ function createAwesompleteInstance(element, tags = []) {
78 * @param selector CSS selector 80 * @param selector CSS selector
79 * @param tags Array of tags 81 * @param tags Array of tags
80 * @param instances List of existing awesomplete instances 82 * @param instances List of existing awesomplete instances
83 * @param separator Tags separator character
81 */ 84 */
82function updateAwesompleteList(selector, tags, instances) { 85function updateAwesompleteList(selector, tags, instances, separator) {
83 if (instances.length === 0) { 86 if (instances.length === 0) {
84 // First load: create Awesomplete instances 87 // First load: create Awesomplete instances
85 const elements = document.querySelectorAll(selector); 88 const elements = document.querySelectorAll(selector);
86 [...elements].forEach((element) => { 89 [...elements].forEach((element) => {
87 instances.push(createAwesompleteInstance(element, tags)); 90 instances.push(createAwesompleteInstance(element, separator, tags));
88 }); 91 });
89 } else { 92 } else {
90 // Update awesomplete tag list 93 // Update awesomplete tag list
@@ -214,6 +217,8 @@ function init(description) {
214 217
215(() => { 218(() => {
216 const basePath = document.querySelector('input[name="js_base_path"]').value; 219 const basePath = document.querySelector('input[name="js_base_path"]').value;
220 const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
221 const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
217 222
218 /** 223 /**
219 * Handle responsive menu. 224 * Handle responsive menu.
@@ -575,7 +580,7 @@ function init(description) {
575 580
576 // Refresh awesomplete values 581 // Refresh awesomplete values
577 existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag)); 582 existingTags = existingTags.map((tag) => (tag === fromtag ? totag : tag));
578 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); 583 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
579 } 584 }
580 }; 585 };
581 xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`); 586 xhr.send(`renametag=1&fromtag=${fromtagUrl}&totag=${encodeURIComponent(totag)}&token=${refreshedToken}`);
@@ -615,14 +620,14 @@ function init(description) {
615 refreshToken(basePath); 620 refreshToken(basePath);
616 621
617 existingTags = existingTags.filter((tagItem) => tagItem !== tag); 622 existingTags = existingTags.filter((tagItem) => tagItem !== tag);
618 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes); 623 awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes, tagsSeparator);
619 } 624 }
620 }); 625 });
621 }); 626 });
622 627
623 const autocompleteFields = document.querySelectorAll('input[data-multiple]'); 628 const autocompleteFields = document.querySelectorAll('input[data-multiple]');
624 [...autocompleteFields].forEach((autocompleteField) => { 629 [...autocompleteFields].forEach((autocompleteField) => {
625 awesomepletes.push(createAwesompleteInstance(autocompleteField)); 630 awesomepletes.push(createAwesompleteInstance(autocompleteField, tagsSeparator));
626 }); 631 });
627 632
628 const exportForm = document.querySelector('#exportform'); 633 const exportForm = document.querySelector('#exportform');
diff --git a/assets/default/scss/shaarli.scss b/assets/default/scss/shaarli.scss
index 3404ce12..cc8ccc1e 100644
--- a/assets/default/scss/shaarli.scss
+++ b/assets/default/scss/shaarli.scss
@@ -139,6 +139,16 @@ body,
139 } 139 }
140} 140}
141 141
142.page-form,
143.pure-alert {
144 code {
145 display: inline-block;
146 padding: 0 2px;
147 color: $dark-grey;
148 background-color: var(--background-color);
149 }
150}
151
142// Make pure-extras alert closable. 152// Make pure-extras alert closable.
143.pure-alert-closable { 153.pure-alert-closable {
144 .fa-times { 154 .fa-times {
diff --git a/assets/vintage/js/base.js b/assets/vintage/js/base.js
index 66830b59..55f1c37d 100644
--- a/assets/vintage/js/base.js
+++ b/assets/vintage/js/base.js
@@ -2,29 +2,38 @@ import Awesomplete from 'awesomplete';
2import 'awesomplete/awesomplete.css'; 2import 'awesomplete/awesomplete.css';
3 3
4(() => { 4(() => {
5 const awp = Awesomplete.$;
6 const autocompleteFields = document.querySelectorAll('input[data-multiple]'); 5 const autocompleteFields = document.querySelectorAll('input[data-multiple]');
6 const tagsSeparatorElement = document.querySelector('input[name="tags_separator"]');
7 const tagsSeparator = tagsSeparatorElement ? tagsSeparatorElement.value || ' ' : ' ';
8
7 [...autocompleteFields].forEach((autocompleteField) => { 9 [...autocompleteFields].forEach((autocompleteField) => {
8 const awesomplete = new Awesomplete(awp(autocompleteField)); 10 const awesome = new Awesomplete(Awesomplete.$(autocompleteField));
9 awesomplete.filter = (text, input) => Awesomplete.FILTER_CONTAINS(text, input.match(/[^ ]*$/)[0]); 11
10 awesomplete.replace = (text) => { 12 // Tags are separated by separator
11 const before = awesomplete.input.value.match(/^.+ \s*|/)[0]; 13 awesome.filter = (text, input) => Awesomplete.FILTER_CONTAINS(
12 awesomplete.input.value = `${before}${text} `; 14 text,
15 input.match(new RegExp(`[^${tagsSeparator}]*$`))[0],
16 );
17 // Insert new selected tag in the input
18 awesome.replace = (text) => {
19 const before = awesome.input.value.match(new RegExp(`^.+${tagsSeparator}+|`))[0];
20 awesome.input.value = `${before}${text}${tagsSeparator}`;
13 }; 21 };
14 awesomplete.minChars = 1; 22 // Highlight found items
23 awesome.item = (text, input) => Awesomplete.ITEM(text, input.match(new RegExp(`[^${tagsSeparator}]*$`))[0]);
15 24
16 autocompleteField.addEventListener('input', () => { 25 // Don't display already selected items
17 const proposedTags = autocompleteField.getAttribute('data-list').replace(/,/g, '').split(' '); 26 // WARNING: pseudo classes does not seem to work with string litterals...
18 const reg = /(\w+) /g; 27 const reg = new RegExp(`([^${tagsSeparator}]+)${tagsSeparator}`, 'g');
19 let match; 28 let match;
20 while ((match = reg.exec(autocompleteField.value)) !== null) { 29 awesome.data = (item, input) => {
21 const id = proposedTags.indexOf(match[1]); 30 while ((match = reg.exec(input))) {
22 if (id !== -1) { 31 if (item === match[1]) {
23 proposedTags.splice(id, 1); 32 return '';
24 } 33 }
25 } 34 }
26 35 return item;
27 awesomplete.list = proposedTags; 36 };
28 }); 37 awesome.minChars = 1;
29 }); 38 });
30})(); 39})();
diff --git a/composer.json b/composer.json
index 94492586..138319ca 100644
--- a/composer.json
+++ b/composer.json
@@ -26,7 +26,7 @@
26 "katzgrau/klogger": "^1.2", 26 "katzgrau/klogger": "^1.2",
27 "malkusch/lock": "^2.1", 27 "malkusch/lock": "^2.1",
28 "pubsubhubbub/publisher": "dev-master", 28 "pubsubhubbub/publisher": "dev-master",
29 "shaarli/netscape-bookmark-parser": "^2.1", 29 "shaarli/netscape-bookmark-parser": "^3.0",
30 "slim/slim": "^3.0" 30 "slim/slim": "^3.0"
31 }, 31 },
32 "require-dev": { 32 "require-dev": {
diff --git a/composer.lock b/composer.lock
index 3c89036f..0023df88 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
4 "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 4 "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
5 "This file is @generated automatically" 5 "This file is @generated automatically"
6 ], 6 ],
7 "content-hash": "61360efbb2e1ba4c4fe00ce1f7a78ec5", 7 "content-hash": "83852dec81e299a117a81206a5091472",
8 "packages": [ 8 "packages": [
9 { 9 {
10 "name": "arthurhoaro/web-thumbnailer", 10 "name": "arthurhoaro/web-thumbnailer",
@@ -786,24 +786,25 @@
786 }, 786 },
787 { 787 {
788 "name": "shaarli/netscape-bookmark-parser", 788 "name": "shaarli/netscape-bookmark-parser",
789 "version": "v2.2.0", 789 "version": "v3.0.1",
790 "source": { 790 "source": {
791 "type": "git", 791 "type": "git",
792 "url": "https://github.com/shaarli/netscape-bookmark-parser.git", 792 "url": "https://github.com/shaarli/netscape-bookmark-parser.git",
793 "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df" 793 "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305"
794 }, 794 },
795 "dist": { 795 "dist": {
796 "type": "zip", 796 "type": "zip",
797 "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/432a010af2bb1832d6fbc4763e6b0100b980a1df", 797 "url": "https://api.github.com/repos/shaarli/netscape-bookmark-parser/zipball/d2321f30413944b2d0a9844bf8cc588c71ae6305",
798 "reference": "432a010af2bb1832d6fbc4763e6b0100b980a1df", 798 "reference": "d2321f30413944b2d0a9844bf8cc588c71ae6305",
799 "shasum": "" 799 "shasum": ""
800 }, 800 },
801 "require": { 801 "require": {
802 "katzgrau/klogger": "~1.0", 802 "katzgrau/klogger": "~1.0",
803 "php": ">=5.6" 803 "php": ">=7.1"
804 }, 804 },
805 "require-dev": { 805 "require-dev": {
806 "phpunit/phpunit": "^5.0" 806 "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
807 "squizlabs/php_codesniffer": "^3.5"
807 }, 808 },
808 "type": "library", 809 "type": "library",
809 "autoload": { 810 "autoload": {
@@ -839,9 +840,9 @@
839 ], 840 ],
840 "support": { 841 "support": {
841 "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues", 842 "issues": "https://github.com/shaarli/netscape-bookmark-parser/issues",
842 "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v2.2.0" 843 "source": "https://github.com/shaarli/netscape-bookmark-parser/tree/v3.0.1"
843 }, 844 },
844 "time": "2020-06-06T15:53:53+00:00" 845 "time": "2020-11-03T12:27:58+00:00"
845 }, 846 },
846 { 847 {
847 "name": "slim/slim", 848 "name": "slim/slim",
@@ -1713,12 +1714,12 @@
1713 "source": { 1714 "source": {
1714 "type": "git", 1715 "type": "git",
1715 "url": "https://github.com/Roave/SecurityAdvisories.git", 1716 "url": "https://github.com/Roave/SecurityAdvisories.git",
1716 "reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff" 1717 "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6"
1717 }, 1718 },
1718 "dist": { 1719 "dist": {
1719 "type": "zip", 1720 "type": "zip",
1720 "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/ba5d234b3a1559321b816b64aafc2ce6728799ff", 1721 "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
1721 "reference": "ba5d234b3a1559321b816b64aafc2ce6728799ff", 1722 "reference": "065a018d3b5c2c84a53db3347cca4e1b7fa362a6",
1722 "shasum": "" 1723 "shasum": ""
1723 }, 1724 },
1724 "conflict": { 1725 "conflict": {
@@ -1734,7 +1735,7 @@
1734 "bagisto/bagisto": "<0.1.5", 1735 "bagisto/bagisto": "<0.1.5",
1735 "barrelstrength/sprout-base-email": "<1.2.7", 1736 "barrelstrength/sprout-base-email": "<1.2.7",
1736 "barrelstrength/sprout-forms": "<3.9", 1737 "barrelstrength/sprout-forms": "<3.9",
1737 "baserproject/basercms": ">=4,<=4.3.6", 1738 "baserproject/basercms": ">=4,<=4.3.6|>=4.4,<4.4.1",
1738 "bolt/bolt": "<3.7.1", 1739 "bolt/bolt": "<3.7.1",
1739 "brightlocal/phpwhois": "<=4.2.5", 1740 "brightlocal/phpwhois": "<=4.2.5",
1740 "buddypress/buddypress": "<5.1.2", 1741 "buddypress/buddypress": "<5.1.2",
@@ -1818,6 +1819,7 @@
1818 "magento/magento1ee": ">=1,<1.14.4.3", 1819 "magento/magento1ee": ">=1,<1.14.4.3",
1819 "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2", 1820 "magento/product-community-edition": ">=2,<2.2.10|>=2.3,<2.3.2-p.2",
1820 "marcwillmann/turn": "<0.3.3", 1821 "marcwillmann/turn": "<0.3.3",
1822 "mediawiki/core": ">=1.31,<1.31.9|>=1.32,<1.32.4|>=1.33,<1.33.3|>=1.34,<1.34.3|>=1.34.99,<1.35",
1821 "mittwald/typo3_forum": "<1.2.1", 1823 "mittwald/typo3_forum": "<1.2.1",
1822 "monolog/monolog": ">=1.8,<1.12", 1824 "monolog/monolog": ">=1.8,<1.12",
1823 "namshi/jose": "<2.2", 1825 "namshi/jose": "<2.2",
@@ -1832,7 +1834,8 @@
1832 "onelogin/php-saml": "<2.10.4", 1834 "onelogin/php-saml": "<2.10.4",
1833 "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5", 1835 "oneup/uploader-bundle": "<1.9.3|>=2,<2.1.5",
1834 "openid/php-openid": "<2.3", 1836 "openid/php-openid": "<2.3",
1835 "openmage/magento-lts": "<19.4.6|>=20,<20.0.2", 1837 "openmage/magento-lts": "<19.4.8|>=20,<20.0.4",
1838 "orchid/platform": ">=9,<9.4.4",
1836 "oro/crm": ">=1.7,<1.7.4", 1839 "oro/crm": ">=1.7,<1.7.4",
1837 "oro/platform": ">=1.7,<1.7.4", 1840 "oro/platform": ">=1.7,<1.7.4",
1838 "padraic/humbug_get_contents": "<1.1.2", 1841 "padraic/humbug_get_contents": "<1.1.2",
@@ -1867,8 +1870,8 @@
1867 "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11", 1870 "scheb/two-factor-bundle": ">=0,<3.26|>=4,<4.11",
1868 "sensiolabs/connect": "<4.2.3", 1871 "sensiolabs/connect": "<4.2.3",
1869 "serluck/phpwhois": "<=4.2.6", 1872 "serluck/phpwhois": "<=4.2.6",
1870 "shopware/core": "<=6.3.1", 1873 "shopware/core": "<=6.3.2",
1871 "shopware/platform": "<=6.3.1", 1874 "shopware/platform": "<=6.3.2",
1872 "shopware/shopware": "<5.3.7", 1875 "shopware/shopware": "<5.3.7",
1873 "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1", 1876 "silverstripe/admin": ">=1.0.3,<1.0.4|>=1.1,<1.1.1",
1874 "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2", 1877 "silverstripe/assets": ">=1,<1.4.7|>=1.5,<1.5.2",
@@ -1901,7 +1904,7 @@
1901 "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", 1904 "sylius/grid": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
1902 "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1", 1905 "sylius/grid-bundle": ">=1,<1.1.19|>=1.2,<1.2.18|>=1.3,<1.3.13|>=1.4,<1.4.5|>=1.5,<1.5.1",
1903 "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4", 1906 "sylius/resource-bundle": "<1.3.14|>=1.4,<1.4.7|>=1.5,<1.5.2|>=1.6,<1.6.4",
1904 "sylius/sylius": "<1.3.16|>=1.4,<1.4.12|>=1.5,<1.5.9|>=1.6,<1.6.5", 1907 "sylius/sylius": "<1.6.9|>=1.7,<1.7.9|>=1.8,<1.8.3",
1905 "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99", 1908 "symbiote/silverstripe-multivaluefield": ">=3,<3.0.99",
1906 "symbiote/silverstripe-versionedfiles": "<=2.0.3", 1909 "symbiote/silverstripe-versionedfiles": "<=2.0.3",
1907 "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8", 1910 "symfony/cache": ">=3.1,<3.4.35|>=4,<4.2.12|>=4.3,<4.3.8",
@@ -2018,7 +2021,7 @@
2018 "type": "tidelift" 2021 "type": "tidelift"
2019 } 2022 }
2020 ], 2023 ],
2021 "time": "2020-10-08T21:02:27+00:00" 2024 "time": "2020-11-01T20:01:47+00:00"
2022 }, 2025 },
2023 { 2026 {
2024 "name": "sebastian/code-unit-reverse-lookup", 2027 "name": "sebastian/code-unit-reverse-lookup",
@@ -2632,16 +2635,16 @@
2632 }, 2635 },
2633 { 2636 {
2634 "name": "squizlabs/php_codesniffer", 2637 "name": "squizlabs/php_codesniffer",
2635 "version": "3.5.6", 2638 "version": "3.5.8",
2636 "source": { 2639 "source": {
2637 "type": "git", 2640 "type": "git",
2638 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", 2641 "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
2639 "reference": "e97627871a7eab2f70e59166072a6b767d5834e0" 2642 "reference": "9d583721a7157ee997f235f327de038e7ea6dac4"
2640 }, 2643 },
2641 "dist": { 2644 "dist": {
2642 "type": "zip", 2645 "type": "zip",
2643 "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/e97627871a7eab2f70e59166072a6b767d5834e0", 2646 "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/9d583721a7157ee997f235f327de038e7ea6dac4",
2644 "reference": "e97627871a7eab2f70e59166072a6b767d5834e0", 2647 "reference": "9d583721a7157ee997f235f327de038e7ea6dac4",
2645 "shasum": "" 2648 "shasum": ""
2646 }, 2649 },
2647 "require": { 2650 "require": {
@@ -2684,24 +2687,24 @@
2684 "source": "https://github.com/squizlabs/PHP_CodeSniffer", 2687 "source": "https://github.com/squizlabs/PHP_CodeSniffer",
2685 "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" 2688 "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
2686 }, 2689 },
2687 "time": "2020-08-10T04:50:15+00:00" 2690 "time": "2020-10-23T02:01:07+00:00"
2688 }, 2691 },
2689 { 2692 {
2690 "name": "symfony/polyfill-ctype", 2693 "name": "symfony/polyfill-ctype",
2691 "version": "v1.18.1", 2694 "version": "v1.20.0",
2692 "source": { 2695 "source": {
2693 "type": "git", 2696 "type": "git",
2694 "url": "https://github.com/symfony/polyfill-ctype.git", 2697 "url": "https://github.com/symfony/polyfill-ctype.git",
2695 "reference": "1c302646f6efc070cd46856e600e5e0684d6b454" 2698 "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41"
2696 }, 2699 },
2697 "dist": { 2700 "dist": {
2698 "type": "zip", 2701 "type": "zip",
2699 "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/1c302646f6efc070cd46856e600e5e0684d6b454", 2702 "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/f4ba089a5b6366e453971d3aad5fe8e897b37f41",
2700 "reference": "1c302646f6efc070cd46856e600e5e0684d6b454", 2703 "reference": "f4ba089a5b6366e453971d3aad5fe8e897b37f41",
2701 "shasum": "" 2704 "shasum": ""
2702 }, 2705 },
2703 "require": { 2706 "require": {
2704 "php": ">=5.3.3" 2707 "php": ">=7.1"
2705 }, 2708 },
2706 "suggest": { 2709 "suggest": {
2707 "ext-ctype": "For best performance" 2710 "ext-ctype": "For best performance"
@@ -2709,7 +2712,7 @@
2709 "type": "library", 2712 "type": "library",
2710 "extra": { 2713 "extra": {
2711 "branch-alias": { 2714 "branch-alias": {
2712 "dev-master": "1.18-dev" 2715 "dev-main": "1.20-dev"
2713 }, 2716 },
2714 "thanks": { 2717 "thanks": {
2715 "name": "symfony/polyfill", 2718 "name": "symfony/polyfill",
@@ -2747,7 +2750,7 @@
2747 "portable" 2750 "portable"
2748 ], 2751 ],
2749 "support": { 2752 "support": {
2750 "source": "https://github.com/symfony/polyfill-ctype/tree/v1.18.0" 2753 "source": "https://github.com/symfony/polyfill-ctype/tree/v1.20.0"
2751 }, 2754 },
2752 "funding": [ 2755 "funding": [
2753 { 2756 {
@@ -2763,7 +2766,7 @@
2763 "type": "tidelift" 2766 "type": "tidelift"
2764 } 2767 }
2765 ], 2768 ],
2766 "time": "2020-07-14T12:35:20+00:00" 2769 "time": "2020-10-23T14:02:19+00:00"
2767 }, 2770 },
2768 { 2771 {
2769 "name": "theseer/tokenizer", 2772 "name": "theseer/tokenizer",
diff --git a/doc/md/Shaarli-configuration.md b/doc/md/Shaarli-configuration.md
index 99084728..b1326cce 100644
--- a/doc/md/Shaarli-configuration.md
+++ b/doc/md/Shaarli-configuration.md
@@ -74,6 +74,7 @@ Some settings can be configured directly from a web browser by accesing the `Too
74 "timezone": "Europe\/Paris", 74 "timezone": "Europe\/Paris",
75 "title": "My Shaarli", 75 "title": "My Shaarli",
76 "header_link": "?" 76 "header_link": "?"
77 "tags_separator": " "
77 }, 78 },
78 "dev": { 79 "dev": {
79 "debug": false, 80 "debug": false,
@@ -153,6 +154,7 @@ _These settings should not be edited_
153- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown. 154- **enable_async_metadata** (boolean): Retrieve external bookmark metadata asynchronously to prevent bookmark creation slowdown.
154- **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags. 155- **retrieve_description** (boolean): If set to true, for every new Shaare Shaarli will try to retrieve the description and keywords from the HTML meta tags.
155- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`. 156- **root_url**: Overrides automatic discovery of Shaarli instance's URL (e.g.) `https://sub.domain.tld/shaarli-folder/`.
157- **tags_separator**: Defines your tags separator (default: whitespace).
156 158
157### Security 159### Security
158 160
diff --git a/index.php b/index.php
index 4b5602ac..8fe86236 100644
--- a/index.php
+++ b/index.php
@@ -125,6 +125,7 @@ $app->group('/admin', function () {
125 $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save'); 125 $this->post('/configure', '\Shaarli\Front\Controller\Admin\ConfigureController:save');
126 $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index'); 126 $this->get('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:index');
127 $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save'); 127 $this->post('/tags', '\Shaarli\Front\Controller\Admin\ManageTagController:save');
128 $this->post('/tags/change-separator', '\Shaarli\Front\Controller\Admin\ManageTagController:changeSeparator');
128 $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare'); 129 $this->get('/add-shaare', '\Shaarli\Front\Controller\Admin\ShaareAddController:addShaare');
129 $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm'); 130 $this->get('/shaare', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayCreateForm');
130 $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm'); 131 $this->get('/shaare/{id:[0-9]+}', '\Shaarli\Front\Controller\Admin\ShaarePublishController:displayEditForm');
diff --git a/tests/bookmark/BookmarkFilterTest.php b/tests/bookmark/BookmarkFilterTest.php
index 574d8e3f..835674f2 100644
--- a/tests/bookmark/BookmarkFilterTest.php
+++ b/tests/bookmark/BookmarkFilterTest.php
@@ -44,7 +44,7 @@ class BookmarkFilterTest extends TestCase
44 self::$refDB->write(self::$testDatastore); 44 self::$refDB->write(self::$testDatastore);
45 $history = new History('sandbox/history.php'); 45 $history = new History('sandbox/history.php');
46 self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true); 46 self::$bookmarkService = new \FakeBookmarkService($conf, $history, $mutex, true);
47 self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks()); 47 self::$linkFilter = new BookmarkFilter(self::$bookmarkService->getBookmarks(), $conf);
48 } 48 }
49 49
50 /** 50 /**
diff --git a/tests/bookmark/BookmarkTest.php b/tests/bookmark/BookmarkTest.php
index 4c1ae25d..cb91b26b 100644
--- a/tests/bookmark/BookmarkTest.php
+++ b/tests/bookmark/BookmarkTest.php
@@ -79,6 +79,23 @@ class BookmarkTest extends TestCase
79 } 79 }
80 80
81 /** 81 /**
82 * Test fromArray() with a link with a custom tags separator
83 */
84 public function testFromArrayCustomTagsSeparator()
85 {
86 $data = [
87 'id' => 1,
88 'tags' => ['tag1', 'tag2', 'chair'],
89 ];
90
91 $bookmark = (new Bookmark())->fromArray($data, '@');
92 $this->assertEquals($data['id'], $bookmark->getId());
93 $this->assertEquals($data['tags'], $bookmark->getTags());
94 $this->assertEquals('tag1@tag2@chair', $bookmark->getTagsString('@'));
95 }
96
97
98 /**
82 * Test validate() with a valid minimal bookmark 99 * Test validate() with a valid minimal bookmark
83 */ 100 */
84 public function testValidateValidFullBookmark() 101 public function testValidateValidFullBookmark()
@@ -252,7 +269,7 @@ class BookmarkTest extends TestCase
252 { 269 {
253 $bookmark = new Bookmark(); 270 $bookmark = new Bookmark();
254 271
255 $str = 'tag1 tag2 tag3.tag3-2, tag4 , -tag5 '; 272 $str = 'tag1 tag2 tag3.tag3-2 tag4 -tag5 ';
256 $bookmark->setTagsString($str); 273 $bookmark->setTagsString($str);
257 $this->assertEquals( 274 $this->assertEquals(
258 [ 275 [
@@ -276,9 +293,9 @@ class BookmarkTest extends TestCase
276 $array = [ 293 $array = [
277 'tag1 ', 294 'tag1 ',
278 ' tag2', 295 ' tag2',
279 'tag3.tag3-2,', 296 'tag3.tag3-2',
280 ', tag4', 297 ' tag4',
281 ', ', 298 ' ',
282 '-tag5 ', 299 '-tag5 ',
283 ]; 300 ];
284 $bookmark->setTags($array); 301 $bookmark->setTags($array);
diff --git a/tests/bookmark/LinkUtilsTest.php b/tests/bookmark/LinkUtilsTest.php
index 9bddf84b..ddab4e3c 100644
--- a/tests/bookmark/LinkUtilsTest.php
+++ b/tests/bookmark/LinkUtilsTest.php
@@ -277,7 +277,8 @@ class LinkUtilsTest extends TestCase
277 $title, 277 $title,
278 $desc, 278 $desc,
279 $keywords, 279 $keywords,
280 false 280 false,
281 ' '
281 ); 282 );
282 283
283 $data = [ 284 $data = [
@@ -327,7 +328,8 @@ class LinkUtilsTest extends TestCase
327 $title, 328 $title,
328 $desc, 329 $desc,
329 $keywords, 330 $keywords,
330 false 331 false,
332 ' '
331 ); 333 );
332 334
333 $data = [ 335 $data = [
@@ -360,7 +362,8 @@ class LinkUtilsTest extends TestCase
360 $title, 362 $title,
361 $desc, 363 $desc,
362 $keywords, 364 $keywords,
363 false 365 false,
366 ' '
364 ); 367 );
365 368
366 $data = [ 369 $data = [
@@ -393,7 +396,8 @@ class LinkUtilsTest extends TestCase
393 $title, 396 $title,
394 $desc, 397 $desc,
395 $keywords, 398 $keywords,
396 false 399 false,
400 ' '
397 ); 401 );
398 402
399 $data = [ 403 $data = [
@@ -458,7 +462,8 @@ class LinkUtilsTest extends TestCase
458 $title, 462 $title,
459 $desc, 463 $desc,
460 $keywords, 464 $keywords,
461 true 465 true,
466 ' '
462 ); 467 );
463 $data = [ 468 $data = [
464 'th=device-width">' 469 'th=device-width">'
@@ -605,6 +610,115 @@ class LinkUtilsTest extends TestCase
605 } 610 }
606 611
607 /** 612 /**
613 * Test tags_str2array with whitespace separator.
614 */
615 public function testTagsStr2ArrayWithSpaceSeparator(): void
616 {
617 $separator = ' ';
618
619 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1 tag2 tag3', $separator));
620 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1 tag2 tag3', $separator));
621 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array(' tag1 tag2 tag3 ', $separator));
622 static::assertSame(['tag1@', 'tag2,', '.tag3'], tags_str2array(' tag1@ tag2, .tag3 ', $separator));
623 static::assertSame([], tags_str2array('', $separator));
624 static::assertSame([], tags_str2array(' ', $separator));
625 static::assertSame([], tags_str2array(null, $separator));
626 }
627
628 /**
629 * Test tags_str2array with @ separator.
630 */
631 public function testTagsStr2ArrayWithCharSeparator(): void
632 {
633 $separator = '@';
634
635 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@tag2@tag3', $separator));
636 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('tag1@@@@tag2@@@@tag3', $separator));
637 static::assertSame(['tag1', 'tag2', 'tag3'], tags_str2array('@@@tag1@@@tag2@@@@tag3@@', $separator));
638 static::assertSame(
639 ['tag1#', 'tag2, and other', '.tag3'],
640 tags_str2array('@@@ tag1# @@@ tag2, and other @@@@.tag3@@', $separator)
641 );
642 static::assertSame([], tags_str2array('', $separator));
643 static::assertSame([], tags_str2array(' ', $separator));
644 static::assertSame([], tags_str2array(null, $separator));
645 }
646
647 /**
648 * Test tags_array2str with ' ' separator.
649 */
650 public function testTagsArray2StrWithSpaceSeparator(): void
651 {
652 $separator = ' ';
653
654 static::assertSame('tag1 tag2 tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
655 static::assertSame('tag1, tag2@ tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
656 static::assertSame('tag1 tag2 tag3', tags_array2str([' tag1 ', 'tag2', 'tag3 '], $separator));
657 static::assertSame('tag1 tag2 tag3', tags_array2str([' tag1 ', ' ', 'tag2', ' ', 'tag3 '], $separator));
658 static::assertSame('tag1', tags_array2str([' tag1 '], $separator));
659 static::assertSame('', tags_array2str([' '], $separator));
660 static::assertSame('', tags_array2str([], $separator));
661 static::assertSame('', tags_array2str(null, $separator));
662 }
663
664 /**
665 * Test tags_array2str with @ separator.
666 */
667 public function testTagsArray2StrWithCharSeparator(): void
668 {
669 $separator = '@';
670
671 static::assertSame('tag1@tag2@tag3', tags_array2str(['tag1', 'tag2', 'tag3'], $separator));
672 static::assertSame('tag1,@tag2@tag3', tags_array2str(['tag1,', 'tag2@', 'tag3'], $separator));
673 static::assertSame(
674 'tag1@tag2, and other@tag3',
675 tags_array2str(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
676 );
677 static::assertSame('tag1@tag2@tag3', tags_array2str(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
678 static::assertSame('tag1', tags_array2str(['@@@@tag1@@@@'], $separator));
679 static::assertSame('', tags_array2str(['@@@'], $separator));
680 static::assertSame('', tags_array2str([], $separator));
681 static::assertSame('', tags_array2str(null, $separator));
682 }
683
684 /**
685 * Test tags_array2str with @ separator.
686 */
687 public function testTagsFilterWithSpaceSeparator(): void
688 {
689 $separator = ' ';
690
691 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
692 static::assertSame(['tag1,', 'tag2@', 'tag3'], tags_filter(['tag1,', 'tag2@', 'tag3'], $separator));
693 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter([' tag1 ', 'tag2', 'tag3 '], $separator));
694 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter([' tag1 ', ' ', 'tag2', ' ', 'tag3 '], $separator));
695 static::assertSame(['tag1'], tags_filter([' tag1 '], $separator));
696 static::assertSame([], tags_filter([' '], $separator));
697 static::assertSame([], tags_filter([], $separator));
698 static::assertSame([], tags_filter(null, $separator));
699 }
700
701 /**
702 * Test tags_array2str with @ separator.
703 */
704 public function testTagsArrayFilterWithSpaceSeparator(): void
705 {
706 $separator = '@';
707
708 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['tag1', 'tag2', 'tag3'], $separator));
709 static::assertSame(['tag1,', 'tag2#', 'tag3'], tags_filter(['tag1,', 'tag2#', 'tag3'], $separator));
710 static::assertSame(
711 ['tag1', 'tag2, and other', 'tag3'],
712 tags_filter(['@@@@ tag1@@@', ' @tag2, and other @', 'tag3@@@@'], $separator)
713 );
714 static::assertSame(['tag1', 'tag2', 'tag3'], tags_filter(['@@@tag1@@@', '@', 'tag2', '@@@', 'tag3@@@'], $separator));
715 static::assertSame(['tag1'], tags_filter(['@@@@tag1@@@@'], $separator));
716 static::assertSame([], tags_filter(['@@@'], $separator));
717 static::assertSame([], tags_filter([], $separator));
718 static::assertSame([], tags_filter(null, $separator));
719 }
720
721 /**
608 * Util function to build an hashtag link. 722 * Util function to build an hashtag link.
609 * 723 *
610 * @param string $hashtag Hashtag name. 724 * @param string $hashtag Hashtag name.
diff --git a/tests/front/controller/admin/ManageTagControllerTest.php b/tests/front/controller/admin/ManageTagControllerTest.php
index 8a0ff7a9..af6f273f 100644
--- a/tests/front/controller/admin/ManageTagControllerTest.php
+++ b/tests/front/controller/admin/ManageTagControllerTest.php
@@ -6,6 +6,7 @@ namespace Shaarli\Front\Controller\Admin;
6 6
7use Shaarli\Bookmark\Bookmark; 7use Shaarli\Bookmark\Bookmark;
8use Shaarli\Bookmark\BookmarkFilter; 8use Shaarli\Bookmark\BookmarkFilter;
9use Shaarli\Config\ConfigManager;
9use Shaarli\Front\Exception\WrongTokenException; 10use Shaarli\Front\Exception\WrongTokenException;
10use Shaarli\Security\SessionManager; 11use Shaarli\Security\SessionManager;
11use Shaarli\TestCase; 12use Shaarli\TestCase;
@@ -44,10 +45,33 @@ class ManageTagControllerTest extends TestCase
44 static::assertSame('changetag', (string) $result->getBody()); 45 static::assertSame('changetag', (string) $result->getBody());
45 46
46 static::assertSame('fromtag', $assignedVariables['fromtag']); 47 static::assertSame('fromtag', $assignedVariables['fromtag']);
48 static::assertSame('@', $assignedVariables['tags_separator']);
47 static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']); 49 static::assertSame('Manage tags - Shaarli', $assignedVariables['pagetitle']);
48 } 50 }
49 51
50 /** 52 /**
53 * Test displaying manage tag page
54 */
55 public function testIndexWhitespaceSeparator(): void
56 {
57 $assignedVariables = [];
58 $this->assignTemplateVars($assignedVariables);
59
60 $this->container->conf = $this->createMock(ConfigManager::class);
61 $this->container->conf->method('get')->willReturnCallback(function (string $key) {
62 return $key === 'general.tags_separator' ? ' ' : $key;
63 });
64
65 $request = $this->createMock(Request::class);
66 $response = new Response();
67
68 $this->controller->index($request, $response);
69
70 static::assertSame('&nbsp;', $assignedVariables['tags_separator']);
71 static::assertSame('whitespace', $assignedVariables['tags_separator_desc']);
72 }
73
74 /**
51 * Test posting a tag update - rename tag - valid info provided. 75 * Test posting a tag update - rename tag - valid info provided.
52 */ 76 */
53 public function testSaveRenameTagValid(): void 77 public function testSaveRenameTagValid(): void
@@ -269,4 +293,116 @@ class ManageTagControllerTest extends TestCase
269 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session); 293 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
270 static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]); 294 static::assertSame(['Invalid tags provided.'], $session[SessionManager::KEY_WARNING_MESSAGES]);
271 } 295 }
296
297 /**
298 * Test changeSeparator to '#': redirection + success message.
299 */
300 public function testChangeSeparatorValid(): void
301 {
302 $toSeparator = '#';
303
304 $session = [];
305 $this->assignSessionVars($session);
306
307 $request = $this->createMock(Request::class);
308 $request
309 ->expects(static::atLeastOnce())
310 ->method('getParam')
311 ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
312 return $key === 'separator' ? $toSeparator : $key;
313 })
314 ;
315 $response = new Response();
316
317 $this->container->conf
318 ->expects(static::once())
319 ->method('set')
320 ->with('general.tags_separator', $toSeparator, true, true)
321 ;
322
323 $result = $this->controller->changeSeparator($request, $response);
324
325 static::assertSame(302, $result->getStatusCode());
326 static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
327
328 static::assertArrayNotHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
329 static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
330 static::assertArrayHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
331 static::assertSame(
332 ['Your tags separator setting has been updated!'],
333 $session[SessionManager::KEY_SUCCESS_MESSAGES]
334 );
335 }
336
337 /**
338 * Test changeSeparator to '#@' (too long): redirection + error message.
339 */
340 public function testChangeSeparatorInvalidTooLong(): void
341 {
342 $toSeparator = '#@';
343
344 $session = [];
345 $this->assignSessionVars($session);
346
347 $request = $this->createMock(Request::class);
348 $request
349 ->expects(static::atLeastOnce())
350 ->method('getParam')
351 ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
352 return $key === 'separator' ? $toSeparator : $key;
353 })
354 ;
355 $response = new Response();
356
357 $this->container->conf->expects(static::never())->method('set');
358
359 $result = $this->controller->changeSeparator($request, $response);
360
361 static::assertSame(302, $result->getStatusCode());
362 static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
363
364 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
365 static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
366 static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
367 static::assertSame(
368 ['Tags separator must be a single character.'],
369 $session[SessionManager::KEY_ERROR_MESSAGES]
370 );
371 }
372
373 /**
374 * Test changeSeparator to '#@' (too long): redirection + error message.
375 */
376 public function testChangeSeparatorInvalidReservedCharacter(): void
377 {
378 $toSeparator = '*';
379
380 $session = [];
381 $this->assignSessionVars($session);
382
383 $request = $this->createMock(Request::class);
384 $request
385 ->expects(static::atLeastOnce())
386 ->method('getParam')
387 ->willReturnCallback(function (string $key) use ($toSeparator): ?string {
388 return $key === 'separator' ? $toSeparator : $key;
389 })
390 ;
391 $response = new Response();
392
393 $this->container->conf->expects(static::never())->method('set');
394
395 $result = $this->controller->changeSeparator($request, $response);
396
397 static::assertSame(302, $result->getStatusCode());
398 static::assertSame(['/subfolder/admin/tags'], $result->getHeader('location'));
399
400 static::assertArrayNotHasKey(SessionManager::KEY_SUCCESS_MESSAGES, $session);
401 static::assertArrayNotHasKey(SessionManager::KEY_WARNING_MESSAGES, $session);
402 static::assertArrayHasKey(SessionManager::KEY_ERROR_MESSAGES, $session);
403 static::assertStringStartsWith(
404 'These characters are reserved and can\'t be used as tags separator',
405 $session[SessionManager::KEY_ERROR_MESSAGES][0]
406 );
407 }
272} 408}
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
index f20b1def..964773da 100644
--- a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
+++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayCreateFormTest.php
@@ -101,7 +101,7 @@ class DisplayCreateFormTest extends TestCase
101 static::assertSame($expectedUrl, $assignedVariables['link']['url']); 101 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
102 static::assertSame($remoteTitle, $assignedVariables['link']['title']); 102 static::assertSame($remoteTitle, $assignedVariables['link']['title']);
103 static::assertSame($remoteDesc, $assignedVariables['link']['description']); 103 static::assertSame($remoteDesc, $assignedVariables['link']['description']);
104 static::assertSame($remoteTags, $assignedVariables['link']['tags']); 104 static::assertSame($remoteTags . ' ', $assignedVariables['link']['tags']);
105 static::assertFalse($assignedVariables['link']['private']); 105 static::assertFalse($assignedVariables['link']['private']);
106 106
107 static::assertTrue($assignedVariables['link_is_new']); 107 static::assertTrue($assignedVariables['link_is_new']);
@@ -192,7 +192,7 @@ class DisplayCreateFormTest extends TestCase
192 'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash', 192 'post' => 'http://url.tld/other?part=3&utm_ad=pay#hash',
193 'title' => 'Provided Title', 193 'title' => 'Provided Title',
194 'description' => 'Provided description.', 194 'description' => 'Provided description.',
195 'tags' => 'abc def', 195 'tags' => 'abc@def',
196 'private' => '1', 196 'private' => '1',
197 'source' => 'apps', 197 'source' => 'apps',
198 ]; 198 ];
@@ -216,7 +216,7 @@ class DisplayCreateFormTest extends TestCase
216 static::assertSame($expectedUrl, $assignedVariables['link']['url']); 216 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
217 static::assertSame($parameters['title'], $assignedVariables['link']['title']); 217 static::assertSame($parameters['title'], $assignedVariables['link']['title']);
218 static::assertSame($parameters['description'], $assignedVariables['link']['description']); 218 static::assertSame($parameters['description'], $assignedVariables['link']['description']);
219 static::assertSame($parameters['tags'], $assignedVariables['link']['tags']); 219 static::assertSame($parameters['tags'] . '@', $assignedVariables['link']['tags']);
220 static::assertTrue($assignedVariables['link']['private']); 220 static::assertTrue($assignedVariables['link']['private']);
221 static::assertTrue($assignedVariables['link_is_new']); 221 static::assertTrue($assignedVariables['link_is_new']);
222 static::assertSame($parameters['source'], $assignedVariables['source']); 222 static::assertSame($parameters['source'], $assignedVariables['source']);
@@ -360,7 +360,7 @@ class DisplayCreateFormTest extends TestCase
360 static::assertSame($expectedUrl, $assignedVariables['link']['url']); 360 static::assertSame($expectedUrl, $assignedVariables['link']['url']);
361 static::assertSame($title, $assignedVariables['link']['title']); 361 static::assertSame($title, $assignedVariables['link']['title']);
362 static::assertSame($description, $assignedVariables['link']['description']); 362 static::assertSame($description, $assignedVariables['link']['description']);
363 static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']); 363 static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
364 static::assertTrue($assignedVariables['link']['private']); 364 static::assertTrue($assignedVariables['link']['private']);
365 static::assertSame($createdAt, $assignedVariables['link']['created']); 365 static::assertSame($createdAt, $assignedVariables['link']['created']);
366 } 366 }
diff --git a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
index da393e49..738cea12 100644
--- a/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
+++ b/tests/front/controller/admin/ShaarePublishControllerTest/DisplayEditFormTest.php
@@ -74,7 +74,7 @@ class DisplayEditFormTest extends TestCase
74 static::assertSame($url, $assignedVariables['link']['url']); 74 static::assertSame($url, $assignedVariables['link']['url']);
75 static::assertSame($title, $assignedVariables['link']['title']); 75 static::assertSame($title, $assignedVariables['link']['title']);
76 static::assertSame($description, $assignedVariables['link']['description']); 76 static::assertSame($description, $assignedVariables['link']['description']);
77 static::assertSame(implode(' ', $tags), $assignedVariables['link']['tags']); 77 static::assertSame(implode('@', $tags) . '@', $assignedVariables['link']['tags']);
78 static::assertTrue($assignedVariables['link']['private']); 78 static::assertTrue($assignedVariables['link']['private']);
79 static::assertSame($createdAt, $assignedVariables['link']['created']); 79 static::assertSame($createdAt, $assignedVariables['link']['created']);
80 } 80 }
diff --git a/tests/front/controller/visitor/BookmarkListControllerTest.php b/tests/front/controller/visitor/BookmarkListControllerTest.php
index 5cbc8c73..dec938f2 100644
--- a/tests/front/controller/visitor/BookmarkListControllerTest.php
+++ b/tests/front/controller/visitor/BookmarkListControllerTest.php
@@ -173,7 +173,7 @@ class BookmarkListControllerTest extends TestCase
173 $request = $this->createMock(Request::class); 173 $request = $this->createMock(Request::class);
174 $request->method('getParam')->willReturnCallback(function (string $key) { 174 $request->method('getParam')->willReturnCallback(function (string $key) {
175 if ('searchtags' === $key) { 175 if ('searchtags' === $key) {
176 return 'abc def'; 176 return 'abc@def';
177 } 177 }
178 if ('searchterm' === $key) { 178 if ('searchterm' === $key) {
179 return 'ghi jkl'; 179 return 'ghi jkl';
@@ -204,7 +204,7 @@ class BookmarkListControllerTest extends TestCase
204 ->expects(static::once()) 204 ->expects(static::once())
205 ->method('search') 205 ->method('search')
206 ->with( 206 ->with(
207 ['searchtags' => 'abc def', 'searchterm' => 'ghi jkl'], 207 ['searchtags' => 'abc@def', 'searchterm' => 'ghi jkl'],
208 'private', 208 'private',
209 false, 209 false,
210 true 210 true
@@ -222,7 +222,7 @@ class BookmarkListControllerTest extends TestCase
222 static::assertSame('linklist', (string) $result->getBody()); 222 static::assertSame('linklist', (string) $result->getBody());
223 223
224 static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']); 224 static::assertSame('Search: ghi jkl [abc] [def] - Shaarli', $assignedVariables['pagetitle']);
225 static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc+def', $assignedVariables['previous_page_url']); 225 static::assertSame('?page=2&searchterm=ghi+jkl&searchtags=abc%40def', $assignedVariables['previous_page_url']);
226 } 226 }
227 227
228 /** 228 /**
diff --git a/tests/front/controller/visitor/FrontControllerMockHelper.php b/tests/front/controller/visitor/FrontControllerMockHelper.php
index fc0bb7d1..02229f68 100644
--- a/tests/front/controller/visitor/FrontControllerMockHelper.php
+++ b/tests/front/controller/visitor/FrontControllerMockHelper.php
@@ -41,6 +41,10 @@ trait FrontControllerMockHelper
41 // Config 41 // Config
42 $this->container->conf = $this->createMock(ConfigManager::class); 42 $this->container->conf = $this->createMock(ConfigManager::class);
43 $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) { 43 $this->container->conf->method('get')->willReturnCallback(function (string $parameter, $default) {
44 if ($parameter === 'general.tags_separator') {
45 return '@';
46 }
47
44 return $default === null ? $parameter : $default; 48 return $default === null ? $parameter : $default;
45 }); 49 });
46 50
diff --git a/tests/front/controller/visitor/TagCloudControllerTest.php b/tests/front/controller/visitor/TagCloudControllerTest.php
index 9305612e..4915573d 100644
--- a/tests/front/controller/visitor/TagCloudControllerTest.php
+++ b/tests/front/controller/visitor/TagCloudControllerTest.php
@@ -100,7 +100,7 @@ class TagCloudControllerTest extends TestCase
100 ->with() 100 ->with()
101 ->willReturnCallback(function (string $key): ?string { 101 ->willReturnCallback(function (string $key): ?string {
102 if ('searchtags' === $key) { 102 if ('searchtags' === $key) {
103 return 'ghi def'; 103 return 'ghi@def';
104 } 104 }
105 105
106 return null; 106 return null;
@@ -131,7 +131,7 @@ class TagCloudControllerTest extends TestCase
131 ->withConsecutive(['render_tagcloud']) 131 ->withConsecutive(['render_tagcloud'])
132 ->willReturnCallback(function (string $hook, array $data, array $param): array { 132 ->willReturnCallback(function (string $hook, array $data, array $param): array {
133 if ('render_tagcloud' === $hook) { 133 if ('render_tagcloud' === $hook) {
134 static::assertSame('ghi def', $data['search_tags']); 134 static::assertSame('ghi@def@', $data['search_tags']);
135 static::assertCount(1, $data['tags']); 135 static::assertCount(1, $data['tags']);
136 136
137 static::assertArrayHasKey('loggedin', $param); 137 static::assertArrayHasKey('loggedin', $param);
@@ -147,7 +147,7 @@ class TagCloudControllerTest extends TestCase
147 static::assertSame('tag.cloud', (string) $result->getBody()); 147 static::assertSame('tag.cloud', (string) $result->getBody());
148 static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']); 148 static::assertSame('ghi def - Tag cloud - Shaarli', $assignedVariables['pagetitle']);
149 149
150 static::assertSame('ghi def', $assignedVariables['search_tags']); 150 static::assertSame('ghi@def@', $assignedVariables['search_tags']);
151 static::assertCount(1, $assignedVariables['tags']); 151 static::assertCount(1, $assignedVariables['tags']);
152 152
153 static::assertArrayHasKey('abc', $assignedVariables['tags']); 153 static::assertArrayHasKey('abc', $assignedVariables['tags']);
@@ -277,7 +277,7 @@ class TagCloudControllerTest extends TestCase
277 ->with() 277 ->with()
278 ->willReturnCallback(function (string $key): ?string { 278 ->willReturnCallback(function (string $key): ?string {
279 if ('searchtags' === $key) { 279 if ('searchtags' === $key) {
280 return 'ghi def'; 280 return 'ghi@def';
281 } elseif ('sort' === $key) { 281 } elseif ('sort' === $key) {
282 return 'alpha'; 282 return 'alpha';
283 } 283 }
@@ -310,7 +310,7 @@ class TagCloudControllerTest extends TestCase
310 ->withConsecutive(['render_taglist']) 310 ->withConsecutive(['render_taglist'])
311 ->willReturnCallback(function (string $hook, array $data, array $param): array { 311 ->willReturnCallback(function (string $hook, array $data, array $param): array {
312 if ('render_taglist' === $hook) { 312 if ('render_taglist' === $hook) {
313 static::assertSame('ghi def', $data['search_tags']); 313 static::assertSame('ghi@def@', $data['search_tags']);
314 static::assertCount(1, $data['tags']); 314 static::assertCount(1, $data['tags']);
315 315
316 static::assertArrayHasKey('loggedin', $param); 316 static::assertArrayHasKey('loggedin', $param);
@@ -326,7 +326,7 @@ class TagCloudControllerTest extends TestCase
326 static::assertSame('tag.list', (string) $result->getBody()); 326 static::assertSame('tag.list', (string) $result->getBody());
327 static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']); 327 static::assertSame('ghi def - Tag list - Shaarli', $assignedVariables['pagetitle']);
328 328
329 static::assertSame('ghi def', $assignedVariables['search_tags']); 329 static::assertSame('ghi@def@', $assignedVariables['search_tags']);
330 static::assertCount(1, $assignedVariables['tags']); 330 static::assertCount(1, $assignedVariables['tags']);
331 static::assertSame(3, $assignedVariables['tags']['abc']); 331 static::assertSame(3, $assignedVariables['tags']['abc']);
332 } 332 }
diff --git a/tests/front/controller/visitor/TagControllerTest.php b/tests/front/controller/visitor/TagControllerTest.php
index 750ea02d..5a556c6d 100644
--- a/tests/front/controller/visitor/TagControllerTest.php
+++ b/tests/front/controller/visitor/TagControllerTest.php
@@ -50,7 +50,7 @@ class TagControllerTest extends TestCase
50 50
51 static::assertInstanceOf(Response::class, $result); 51 static::assertInstanceOf(Response::class, $result);
52 static::assertSame(302, $result->getStatusCode()); 52 static::assertSame(302, $result->getStatusCode());
53 static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); 53 static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
54 } 54 }
55 55
56 public function testAddTagWithoutRefererAndExistingSearch(): void 56 public function testAddTagWithoutRefererAndExistingSearch(): void
@@ -80,7 +80,7 @@ class TagControllerTest extends TestCase
80 80
81 static::assertInstanceOf(Response::class, $result); 81 static::assertInstanceOf(Response::class, $result);
82 static::assertSame(302, $result->getStatusCode()); 82 static::assertSame(302, $result->getStatusCode());
83 static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); 83 static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
84 } 84 }
85 85
86 public function testAddTagResetPagination(): void 86 public function testAddTagResetPagination(): void
@@ -96,7 +96,7 @@ class TagControllerTest extends TestCase
96 96
97 static::assertInstanceOf(Response::class, $result); 97 static::assertInstanceOf(Response::class, $result);
98 static::assertSame(302, $result->getStatusCode()); 98 static::assertSame(302, $result->getStatusCode());
99 static::assertSame(['/controller/?searchtags=def+abc'], $result->getHeader('location')); 99 static::assertSame(['/controller/?searchtags=def%40abc'], $result->getHeader('location'));
100 } 100 }
101 101
102 public function testAddTagWithRefererAndEmptySearch(): void 102 public function testAddTagWithRefererAndEmptySearch(): void
diff --git a/tests/netscape/BookmarkImportTest.php b/tests/netscape/BookmarkImportTest.php
index c526d5c8..6856ebca 100644
--- a/tests/netscape/BookmarkImportTest.php
+++ b/tests/netscape/BookmarkImportTest.php
@@ -531,7 +531,7 @@ class BookmarkImportTest extends TestCase
531 { 531 {
532 $post = array( 532 $post = array(
533 'privacy' => 'public', 533 'privacy' => 'public',
534 'default_tags' => 'tag1,tag2 tag3' 534 'default_tags' => 'tag1 tag2 tag3'
535 ); 535 );
536 $files = file2array('netscape_basic.htm'); 536 $files = file2array('netscape_basic.htm');
537 $this->assertStringMatchesFormat( 537 $this->assertStringMatchesFormat(
@@ -552,7 +552,7 @@ class BookmarkImportTest extends TestCase
552 { 552 {
553 $post = array( 553 $post = array(
554 'privacy' => 'public', 554 'privacy' => 'public',
555 'default_tags' => 'tag1&,tag2 "tag3"' 555 'default_tags' => 'tag1& tag2 "tag3"'
556 ); 556 );
557 $files = file2array('netscape_basic.htm'); 557 $files = file2array('netscape_basic.htm');
558 $this->assertStringMatchesFormat( 558 $this->assertStringMatchesFormat(
@@ -573,6 +573,43 @@ class BookmarkImportTest extends TestCase
573 } 573 }
574 574
575 /** 575 /**
576 * Add user-specified tags to all imported bookmarks
577 */
578 public function testSetDefaultTagsWithCustomSeparator()
579 {
580 $separator = '@';
581 $this->conf->set('general.tags_separator', $separator);
582 $post = [
583 'privacy' => 'public',
584 'default_tags' => 'tag1@tag2@tag3@multiple words tag'
585 ];
586 $files = file2array('netscape_basic.htm');
587 $this->assertStringMatchesFormat(
588 'File netscape_basic.htm (482 bytes) was successfully processed in %d seconds:'
589 .' 2 bookmarks imported, 0 bookmarks overwritten, 0 bookmarks skipped.',
590 $this->netscapeBookmarkUtils->import($post, $files)
591 );
592 $this->assertEquals(2, $this->bookmarkService->count());
593 $this->assertEquals(0, $this->bookmarkService->count(BookmarkFilter::$PRIVATE));
594 $this->assertEquals(
595 'tag1@tag2@tag3@multiple words tag@private@secret',
596 $this->bookmarkService->get(0)->getTagsString($separator)
597 );
598 $this->assertEquals(
599 ['tag1', 'tag2', 'tag3', 'multiple words tag', 'private', 'secret'],
600 $this->bookmarkService->get(0)->getTags()
601 );
602 $this->assertEquals(
603 'tag1@tag2@tag3@multiple words tag@public@hello@world',
604 $this->bookmarkService->get(1)->getTagsString($separator)
605 );
606 $this->assertEquals(
607 ['tag1', 'tag2', 'tag3', 'multiple words tag', 'public', 'hello', 'world'],
608 $this->bookmarkService->get(1)->getTags()
609 );
610 }
611
612 /**
576 * Ensure each imported bookmark has a unique id 613 * Ensure each imported bookmark has a unique id
577 * 614 *
578 * See https://github.com/shaarli/Shaarli/issues/351 615 * See https://github.com/shaarli/Shaarli/issues/351
diff --git a/tpl/default/changetag.html b/tpl/default/changetag.html
index a5fbd31e..13b7f24a 100644
--- a/tpl/default/changetag.html
+++ b/tpl/default/changetag.html
@@ -36,6 +36,29 @@
36 <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p> 36 <p>{'You can also edit tags in the'|t} <a href="{$base_path}/tags/list?sort=usage">{'tag list'|t}</a>.</p>
37 </div> 37 </div>
38</div> 38</div>
39
40<div class="pure-g">
41 <div class="pure-u-lg-1-3 pure-u-1-24"></div>
42 <div class="page-form page-form-light pure-u-lg-1-3 pure-u-22-24">
43 <h2 class="window-title">{"Change tags separator"|t}</h2>
44 <form method="POST" action="{$base_path}/admin/tags/change-separator" name="changeseparator" id="changeseparator">
45 <p>
46 {'Your current tag separator is'|t} <code>{$tags_separator}</code>{if="!empty($tags_separator_desc)"} ({$tags_separator_desc}){/if}.
47 </p>
48 <div>
49 <input type="text" name="separator" placeholder="{'New separator'|t}"
50 id="separator">
51 </div>
52 <input type="hidden" name="token" value="{$token}">
53 <div>
54 <input type="submit" value="{'Save'|t}" name="saveseparator">
55 </div>
56 <p>
57 {'Note that hashtags won\'t fully work with a non-whitespace separator.'|t}
58 </p>
59 </form>
60 </div>
61</div>
39{include="page.footer"} 62{include="page.footer"}
40</body> 63</body>
41</html> 64</html>
diff --git a/tpl/default/linklist.html b/tpl/default/linklist.html
index e1115d49..7208a3b6 100644
--- a/tpl/default/linklist.html
+++ b/tpl/default/linklist.html
@@ -90,7 +90,7 @@
90 {'for'|t} <em><strong>{$search_term}</strong></em> 90 {'for'|t} <em><strong>{$search_term}</strong></em>
91 {/if} 91 {/if}
92 {if="!empty($search_tags)"} 92 {if="!empty($search_tags)"}
93 {$exploded_tags=explode(' ', $search_tags)} 93 {$exploded_tags=tags_str2array($search_tags, $tags_separator)}
94 {'tagged'|t} 94 {'tagged'|t}
95 {loop="$exploded_tags"} 95 {loop="$exploded_tags"}
96 <span class="label label-tag" title="{'Remove tag'|t}"> 96 <span class="label label-tag" title="{'Remove tag'|t}">
diff --git a/tpl/default/page.footer.html b/tpl/default/page.footer.html
index 964ffff1..58ca18c5 100644
--- a/tpl/default/page.footer.html
+++ b/tpl/default/page.footer.html
@@ -18,8 +18,6 @@
18 <div class="pure-u-2-24"></div> 18 <div class="pure-u-2-24"></div>
19</div> 19</div>
20 20
21<input type="hidden" name="token" value="{$token}" id="token" />
22
23{loop="$plugins_footer.endofpage"} 21{loop="$plugins_footer.endofpage"}
24 {$value} 22 {$value}
25{/loop} 23{/loop}
@@ -41,4 +39,7 @@
41</div> 39</div>
42 40
43<input type="hidden" name="js_base_path" value="{$base_path}" /> 41<input type="hidden" name="js_base_path" value="{$base_path}" />
42<input type="hidden" name="token" value="{$token}" id="token" />
43<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
44
44<script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script> 45<script src="{$asset_path}/js/shaarli.min.js?v={$version_hash}#"></script>
diff --git a/tpl/default/tag.cloud.html b/tpl/default/tag.cloud.html
index c067e1d4..01b50b02 100644
--- a/tpl/default/tag.cloud.html
+++ b/tpl/default/tag.cloud.html
@@ -48,7 +48,7 @@
48 48
49 <div id="cloudtag" class="cloudtag-container"> 49 <div id="cloudtag" class="cloudtag-container">
50 {loop="tags"} 50 {loop="tags"}
51 <a href="{$base_path}/?searchtags={$tags_url.$key1} {$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a 51 <a href="{$base_path}/?searchtags={$tags_url.$key1}{$tags_separator|urlencode}{$search_tags_url}" style="font-size:{$value.size}em;">{$key}</a
52 ><a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value.count}</a> 52 ><a href="{$base_path}/add-tag/{$tags_url.$key1}" title="{'Filter by tag'|t}" class="count">{$value.count}</a>
53 {loop="$value.tag_plugin"} 53 {loop="$value.tag_plugin"}
54 {$value} 54 {$value}
diff --git a/tpl/vintage/includes.html b/tpl/vintage/includes.html
index eac05701..2ce9da42 100644
--- a/tpl/vintage/includes.html
+++ b/tpl/vintage/includes.html
@@ -5,13 +5,13 @@
5<meta name="referrer" content="same-origin"> 5<meta name="referrer" content="same-origin">
6<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" /> 6<link rel="alternate" type="application/rss+xml" href="{$feedurl}feed/rss?{$searchcrits}#" title="RSS Feed" />
7<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" /> 7<link rel="alternate" type="application/atom+xml" href="{$feedurl}feed/atom?{$searchcrits}#" title="ATOM Feed" />
8<link href="img/favicon.ico" rel="shortcut icon" type="image/x-icon" /> 8<link href="{$asset_path}/img/favicon.ico#" rel="shortcut icon" type="image/x-icon" />
9<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css#" /> 9<link type="text/css" rel="stylesheet" href="{$asset_path}/css/shaarli.min.css#" />
10{if="$formatter==='markdown'"} 10{if="$formatter==='markdown'"}
11 <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" /> 11 <link type="text/css" rel="stylesheet" href="{$asset_path}/css/markdown.min.css?v={$version_hash}#" />
12{/if} 12{/if}
13{loop="$plugins_includes.css_files"} 13{loop="$plugins_includes.css_files"}
14<link type="text/css" rel="stylesheet" href="{$base_path}/{$value}#"/> 14<link type="text/css" rel="stylesheet" href="{$root_path}/{$value}#"/>
15{/loop} 15{/loop}
16{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />{/if} 16{if="is_file('data/user.css')"}<link type="text/css" rel="stylesheet" href="{$base_path}/data/user.css#" />{/if}
17<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#" 17<link rel="search" type="application/opensearchdescription+xml" href="{$base_path}/open-search#"
diff --git a/tpl/vintage/linklist.html b/tpl/vintage/linklist.html
index 90f5cf8f..ff0dd40c 100644
--- a/tpl/vintage/linklist.html
+++ b/tpl/vintage/linklist.html
@@ -61,7 +61,7 @@
61 for <em>{$search_term}</em> 61 for <em>{$search_term}</em>
62 {/if} 62 {/if}
63 {if="!empty($search_tags)"} 63 {if="!empty($search_tags)"}
64 {$exploded_tags=explode(' ', $search_tags)} 64 {$exploded_tags=tags_str2array($search_tags, $tags_separator)}
65 tagged 65 tagged
66 {loop="$exploded_tags"} 66 {loop="$exploded_tags"}
67 <span class="linktag" title="Remove tag"> 67 <span class="linktag" title="Remove tag">
diff --git a/tpl/vintage/page.footer.html b/tpl/vintage/page.footer.html
index 0fe4c736..be709aeb 100644
--- a/tpl/vintage/page.footer.html
+++ b/tpl/vintage/page.footer.html
@@ -23,8 +23,6 @@
23</div> 23</div>
24{/if} 24{/if}
25 25
26<script src="{$asset_path}/js/shaarli.min.js#"></script>
27
28{if="$is_logged_in"} 26{if="$is_logged_in"}
29<script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script> 27<script>function confirmDeleteLink() { var agree=confirm("Are you sure you want to delete this link ?"); if (agree) return true ; else return false ; }</script>
30{/if} 28{/if}
@@ -34,3 +32,7 @@
34{/loop} 32{/loop}
35 33
36<input type="hidden" name="js_base_path" value="{$base_path}" /> 34<input type="hidden" name="js_base_path" value="{$base_path}" />
35<input type="hidden" name="token" value="{$token}" id="token" />
36<input type="hidden" name="tags_separator" value="{$tags_separator}" id="tags_separator" />
37
38<script src="{$asset_path}/js/shaarli.min.js#"></script>