aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2020-10-22 16:21:03 +0200
committerArthurHoaro <arthur@hoa.ro>2020-11-05 17:54:42 +0100
commitb3bd8c3e8d367975980043e772f7cd78b7f96bc6 (patch)
treeec79899ea564c093d8b0578f3e614881a4ea7c3d /application
parent48df9f45b8c4b2995c1e04146071628668531b37 (diff)
downloadShaarli-b3bd8c3e8d367975980043e772f7cd78b7f96bc6.tar.gz
Shaarli-b3bd8c3e8d367975980043e772f7cd78b7f96bc6.tar.zst
Shaarli-b3bd8c3e8d367975980043e772f7cd78b7f96bc6.zip
Feature: support any tag separator
So it allows to have multiple words tags. Breaking change: commas ',' are no longer a default separator. Fixes #594
Diffstat (limited to 'application')
-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
19 files changed, 191 insertions, 69 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 17c37979..9493b0aa 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);