aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/bookmark
diff options
context:
space:
mode:
Diffstat (limited to 'application/bookmark')
-rw-r--r--application/bookmark/Bookmark.php64
-rw-r--r--application/bookmark/BookmarkArray.php6
-rw-r--r--application/bookmark/BookmarkFileService.php67
-rw-r--r--application/bookmark/BookmarkFilter.php57
-rw-r--r--application/bookmark/BookmarkIO.php26
-rw-r--r--application/bookmark/BookmarkInitializer.php13
-rw-r--r--application/bookmark/BookmarkServiceInterface.php32
-rw-r--r--application/bookmark/LinkUtils.php74
-rw-r--r--application/bookmark/exception/BookmarkNotFoundException.php1
-rw-r--r--application/bookmark/exception/EmptyDataStoreException.php6
-rw-r--r--application/bookmark/exception/InvalidBookmarkException.php14
-rw-r--r--application/bookmark/exception/NotWritableDataStoreException.php4
12 files changed, 254 insertions, 110 deletions
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
index ea565d1f..4238ef25 100644
--- a/application/bookmark/Bookmark.php
+++ b/application/bookmark/Bookmark.php
@@ -19,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException;
19class Bookmark 19class Bookmark
20{ 20{
21 /** @var string Date format used in string (former ID format) */ 21 /** @var string Date format used in string (former ID format) */
22 const LINK_DATE_FORMAT = 'Ymd_His'; 22 public const LINK_DATE_FORMAT = 'Ymd_His';
23 23
24 /** @var int Bookmark ID */ 24 /** @var int Bookmark ID */
25 protected $id; 25 protected $id;
@@ -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'];
@@ -104,7 +106,8 @@ class Bookmark
104 */ 106 */
105 public function validate(): void 107 public function validate(): void
106 { 108 {
107 if ($this->id === null 109 if (
110 $this->id === null
108 || ! is_int($this->id) 111 || ! is_int($this->id)
109 || empty($this->shortUrl) 112 || empty($this->shortUrl)
110 || empty($this->created) 113 || empty($this->created)
@@ -112,7 +115,7 @@ class Bookmark
112 throw new InvalidBookmarkException($this); 115 throw new InvalidBookmarkException($this);
113 } 116 }
114 if (empty($this->url)) { 117 if (empty($this->url)) {
115 $this->url = '/shaare/'. $this->shortUrl; 118 $this->url = '/shaare/' . $this->shortUrl;
116 } 119 }
117 if (empty($this->title)) { 120 if (empty($this->title)) {
118 $this->title = $this->url; 121 $this->title = $this->url;
@@ -348,7 +351,12 @@ class Bookmark
348 */ 351 */
349 public function setTags(?array $tags): Bookmark 352 public function setTags(?array $tags): Bookmark
350 { 353 {
351 $this->setTagsString(implode(' ', $tags ?? [])); 354 $this->tags = array_map(
355 function (string $tag): string {
356 return $tag[0] === '-' ? substr($tag, 1) : $tag;
357 },
358 tags_filter($tags, ' ')
359 );
352 360
353 return $this; 361 return $this;
354 } 362 }
@@ -378,6 +386,24 @@ class Bookmark
378 } 386 }
379 387
380 /** 388 /**
389 * Return true if:
390 * - the bookmark's thumbnail is not already set to false (= not found)
391 * - it's not a note
392 * - it's an HTTP(S) link
393 * - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
394 *
395 * @return bool True if the bookmark's thumbnail needs to be retrieved.
396 */
397 public function shouldUpdateThumbnail(): bool
398 {
399 return $this->thumbnail !== false
400 && !$this->isNote()
401 && startsWith(strtolower($this->url), 'http')
402 && (null === $this->thumbnail || !is_file($this->thumbnail))
403 ;
404 }
405
406 /**
381 * Get the Sticky. 407 * Get the Sticky.
382 * 408 *
383 * @return bool 409 * @return bool
@@ -402,11 +428,13 @@ class Bookmark
402 } 428 }
403 429
404 /** 430 /**
405 * @return string Bookmark's tags as a string, separated by a space 431 * @param string $separator Tags separator loaded from the config file.
432 *
433 * @return string Bookmark's tags as a string, separated by a separator
406 */ 434 */
407 public function getTagsString(): string 435 public function getTagsString(string $separator = ' '): string
408 { 436 {
409 return implode(' ', $this->getTags()); 437 return tags_array2str($this->getTags(), $separator);
410 } 438 }
411 439
412 /** 440 /**
@@ -426,19 +454,13 @@ class Bookmark
426 * - trailing dash in tags will be removed 454 * - trailing dash in tags will be removed
427 * 455 *
428 * @param string|null $tags 456 * @param string|null $tags
457 * @param string $separator Tags separator loaded from the config file.
429 * 458 *
430 * @return $this 459 * @return $this
431 */ 460 */
432 public function setTagsString(?string $tags): Bookmark 461 public function setTagsString(?string $tags, string $separator = ' '): Bookmark
433 { 462 {
434 // Remove first '-' char in tags. 463 $this->setTags(tags_str2array($tags, $separator));
435 $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
436 // Explode all tags separted by spaces or commas
437 $tags = preg_split('/[\s,]+/', $tags);
438 // Remove eventual empty values
439 $tags = array_values(array_filter($tags));
440
441 $this->tags = $tags;
442 464
443 return $this; 465 return $this;
444 } 466 }
@@ -489,7 +511,7 @@ class Bookmark
489 */ 511 */
490 public function renameTag(string $fromTag, string $toTag): void 512 public function renameTag(string $fromTag, string $toTag): void
491 { 513 {
492 if (($pos = array_search($fromTag, $this->tags)) !== false) { 514 if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
493 $this->tags[$pos] = trim($toTag); 515 $this->tags[$pos] = trim($toTag);
494 } 516 }
495 } 517 }
@@ -501,7 +523,7 @@ class Bookmark
501 */ 523 */
502 public function deleteTag(string $tag): void 524 public function deleteTag(string $tag): void
503 { 525 {
504 if (($pos = array_search($tag, $this->tags)) !== false) { 526 if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
505 unset($this->tags[$pos]); 527 unset($this->tags[$pos]);
506 $this->tags = array_values($this->tags); 528 $this->tags = array_values($this->tags);
507 } 529 }
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
index 67bb3b73..b9328116 100644
--- a/application/bookmark/BookmarkArray.php
+++ b/application/bookmark/BookmarkArray.php
@@ -72,7 +72,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
72 */ 72 */
73 public function offsetSet($offset, $value) 73 public function offsetSet($offset, $value)
74 { 74 {
75 if (! $value instanceof Bookmark 75 if (
76 ! $value instanceof Bookmark
76 || $value->getId() === null || empty($value->getUrl()) 77 || $value->getId() === null || empty($value->getUrl())
77 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) 78 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
78 || $offset !== null && $offset !== $value->getId() 79 || $offset !== null && $offset !== $value->getId()
@@ -222,7 +223,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
222 */ 223 */
223 public function getByUrl(string $url): ?Bookmark 224 public function getByUrl(string $url): ?Bookmark
224 { 225 {
225 if (! empty($url) 226 if (
227 ! empty($url)
226 && isset($this->urls[$url]) 228 && isset($this->urls[$url])
227 && isset($this->bookmarks[$this->urls[$url]]) 229 && isset($this->bookmarks[$this->urls[$url]])
228 ) { 230 ) {
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index eb7899bf..6666a251 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -69,7 +69,7 @@ class BookmarkFileService implements BookmarkServiceInterface
69 } else { 69 } else {
70 try { 70 try {
71 $this->bookmarks = $this->bookmarksIO->read(); 71 $this->bookmarks = $this->bookmarksIO->read();
72 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { 72 } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
73 $this->bookmarks = new BookmarkArray(); 73 $this->bookmarks = new BookmarkArray();
74 74
75 if ($this->isLoggedIn) { 75 if ($this->isLoggedIn) {
@@ -85,25 +85,29 @@ class BookmarkFileService implements BookmarkServiceInterface
85 if (! $this->bookmarks instanceof BookmarkArray) { 85 if (! $this->bookmarks instanceof BookmarkArray) {
86 $this->migrate(); 86 $this->migrate();
87 exit( 87 exit(
88 'Your data store has been migrated, please reload the page.'. PHP_EOL . 88 'Your data store has been migrated, please reload the page.' . PHP_EOL .
89 'If this message keeps showing up, please delete data/updates.txt file.' 89 'If this message keeps showing up, please delete data/updates.txt file.'
90 ); 90 );
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 /**
98 * @inheritDoc 98 * @inheritDoc
99 */ 99 */
100 public function findByHash(string $hash): Bookmark 100 public function findByHash(string $hash, string $privateKey = null): Bookmark
101 { 101 {
102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); 102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
103 // PHP 7.3 introduced array_key_first() to avoid this hack 103 // PHP 7.3 introduced array_key_first() to avoid this hack
104 $first = reset($bookmark); 104 $first = reset($bookmark);
105 if (! $this->isLoggedIn && $first->isPrivate()) { 105 if (
106 throw new Exception('Not authorized'); 106 !$this->isLoggedIn
107 && $first->isPrivate()
108 && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
109 ) {
110 throw new BookmarkNotFoundException();
107 } 111 }
108 112
109 return $first; 113 return $first;
@@ -162,7 +166,8 @@ class BookmarkFileService implements BookmarkServiceInterface
162 } 166 }
163 167
164 $bookmark = $this->bookmarks[$id]; 168 $bookmark = $this->bookmarks[$id];
165 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 169 if (
170 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
166 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 171 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
167 ) { 172 ) {
168 throw new Exception('Unauthorized'); 173 throw new Exception('Unauthorized');
@@ -262,7 +267,8 @@ class BookmarkFileService implements BookmarkServiceInterface
262 } 267 }
263 268
264 $bookmark = $this->bookmarks[$id]; 269 $bookmark = $this->bookmarks[$id];
265 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 270 if (
271 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
266 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 272 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
267 ) { 273 ) {
268 return false; 274 return false;
@@ -304,7 +310,8 @@ class BookmarkFileService implements BookmarkServiceInterface
304 $caseMapping = []; 310 $caseMapping = [];
305 foreach ($bookmarks as $bookmark) { 311 foreach ($bookmarks as $bookmark) {
306 foreach ($bookmark->getTags() as $tag) { 312 foreach ($bookmark->getTags() as $tag) {
307 if (empty($tag) 313 if (
314 empty($tag)
308 || (! $this->isLoggedIn && startsWith($tag, '.')) 315 || (! $this->isLoggedIn && startsWith($tag, '.'))
309 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG 316 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
310 || in_array($tag, $filteringTags, true) 317 || in_array($tag, $filteringTags, true)
@@ -340,26 +347,42 @@ class BookmarkFileService implements BookmarkServiceInterface
340 /** 347 /**
341 * @inheritDoc 348 * @inheritDoc
342 */ 349 */
343 public function days(): array 350 public function findByDate(
344 { 351 \DateTimeInterface $from,
345 $bookmarkDays = []; 352 \DateTimeInterface $to,
346 foreach ($this->search() as $bookmark) { 353 ?\DateTimeInterface &$previous,
347 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; 354 ?\DateTimeInterface &$next
355 ): array {
356 $out = [];
357 $previous = null;
358 $next = null;
359
360 foreach ($this->search([], null, false, false, true) as $bookmark) {
361 if ($to < $bookmark->getCreated()) {
362 $next = $bookmark->getCreated();
363 } elseif ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
364 $out[] = $bookmark;
365 } else {
366 if ($previous !== null) {
367 break;
368 }
369 $previous = $bookmark->getCreated();
370 }
348 } 371 }
349 $bookmarkDays = array_keys($bookmarkDays);
350 sort($bookmarkDays);
351 372
352 return array_map('strval', $bookmarkDays); 373 return $out;
353 } 374 }
354 375
355 /** 376 /**
356 * @inheritDoc 377 * @inheritDoc
357 */ 378 */
358 public function filterDay(string $request) 379 public function getLatest(): ?Bookmark
359 { 380 {
360 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; 381 foreach ($this->search([], null, false, false, true) as $bookmark) {
382 return $bookmark;
383 }
361 384
362 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); 385 return null;
363 } 386 }
364 387
365 /** 388 /**
@@ -386,14 +409,14 @@ class BookmarkFileService implements BookmarkServiceInterface
386 false 409 false
387 ); 410 );
388 $updater = new LegacyUpdater( 411 $updater = new LegacyUpdater(
389 UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), 412 UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
390 $bookmarkDb, 413 $bookmarkDb,
391 $this->conf, 414 $this->conf,
392 true 415 true
393 ); 416 );
394 $newUpdates = $updater->update(); 417 $newUpdates = $updater->update();
395 if (! empty($newUpdates)) { 418 if (! empty($newUpdates)) {
396 UpdaterUtils::write_updates_file( 419 UpdaterUtils::writeUpdatesFile(
397 $this->conf->get('resource.updates'), 420 $this->conf->get('resource.updates'),
398 $updater->getDoneUpdates() 421 $updater->getDoneUpdates()
399 ); 422 );
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index c79386ea..db83c51c 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:
@@ -141,7 +150,7 @@ class BookmarkFilter
141 return $this->bookmarks; 150 return $this->bookmarks;
142 } 151 }
143 152
144 $out = array(); 153 $out = [];
145 foreach ($this->bookmarks as $key => $value) { 154 foreach ($this->bookmarks as $key => $value) {
146 if ($value->isPrivate() && $visibility === 'private') { 155 if ($value->isPrivate() && $visibility === 'private') {
147 $out[$key] = $value; 156 $out[$key] = $value;
@@ -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,10 +391,11 @@ 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 = [];
385 // find all tags in the form of #tag in the description 399 // find all tags in the form of #tag in the description
386 preg_match_all( 400 preg_match_all(
387 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 401 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -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 {
540 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; 554 $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
541 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; 555 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
542 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; 556 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') . '\\';
543 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; 557 $content .= mb_convert_case($link->getUrl(), 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/BookmarkIO.php b/application/bookmark/BookmarkIO.php
index f40fa476..8439d470 100644
--- a/application/bookmark/BookmarkIO.php
+++ b/application/bookmark/BookmarkIO.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
6 6
7use malkusch\lock\exception\LockAcquireException;
7use malkusch\lock\mutex\Mutex; 8use malkusch\lock\mutex\Mutex;
8use malkusch\lock\mutex\NoMutex; 9use malkusch\lock\mutex\NoMutex;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; 10use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
@@ -80,7 +81,7 @@ class BookmarkIO
80 } 81 }
81 82
82 $content = null; 83 $content = null;
83 $this->mutex->synchronized(function () use (&$content) { 84 $this->synchronized(function () use (&$content) {
84 $content = file_get_contents($this->datastore); 85 $content = file_get_contents($this->datastore);
85 }); 86 });
86 87
@@ -112,18 +113,35 @@ class BookmarkIO
112 if (is_file($this->datastore) && !is_writeable($this->datastore)) { 113 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
113 // The datastore exists but is not writeable 114 // The datastore exists but is not writeable
114 throw new NotWritableDataStoreException($this->datastore); 115 throw new NotWritableDataStoreException($this->datastore);
115 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { 116 } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
116 // The datastore does not exist and its parent directory is not writeable 117 // The datastore does not exist and its parent directory is not writeable
117 throw new NotWritableDataStoreException(dirname($this->datastore)); 118 throw new NotWritableDataStoreException(dirname($this->datastore));
118 } 119 }
119 120
120 $data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix; 121 $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix;
121 122
122 $this->mutex->synchronized(function () use ($data) { 123 $this->synchronized(function () use ($data) {
123 file_put_contents( 124 file_put_contents(
124 $this->datastore, 125 $this->datastore,
125 $data 126 $data
126 ); 127 );
127 }); 128 });
128 } 129 }
130
131 /**
132 * Wrapper applying mutex to provided function.
133 * If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex.
134 *
135 * @see https://github.com/shaarli/Shaarli/issues/1650
136 *
137 * @param callable $function
138 */
139 protected function synchronized(callable $function): void
140 {
141 try {
142 $this->mutex->synchronized($function);
143 } catch (LockAcquireException $exception) {
144 $function();
145 }
146 }
129} 147}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
index 04b996f3..8ab5c441 100644
--- a/application/bookmark/BookmarkInitializer.php
+++ b/application/bookmark/BookmarkInitializer.php
@@ -13,6 +13,9 @@ namespace Shaarli\Bookmark;
13 * To prevent data corruption, it does not overwrite existing bookmarks, 13 * To prevent data corruption, it does not overwrite existing bookmarks,
14 * even though there should not be any. 14 * even though there should not be any.
15 * 15 *
16 * We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext.
17 * @phpcs:disable Generic.Files.LineLength.TooLong
18 *
16 * @package Shaarli\Bookmark 19 * @package Shaarli\Bookmark
17 */ 20 */
18class BookmarkInitializer 21class BookmarkInitializer
@@ -36,10 +39,10 @@ class BookmarkInitializer
36 public function initialize(): void 39 public function initialize(): void
37 { 40 {
38 $bookmark = new Bookmark(); 41 $bookmark = new Bookmark();
39 $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); 42 $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)'));
40 $bookmark->setUrl('https://vimeo.com/153493904'); 43 $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
41 $bookmark->setDescription(t( 44 $bookmark->setDescription(t(
42'Shaarli will automatically pick up the thumbnail for links to a variety of websites. 45 'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
43 46
44Explore your new Shaarli instance by trying out controls and menus. 47Explore your new Shaarli instance by trying out controls and menus.
45Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. 48Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
@@ -54,7 +57,7 @@ Now you can edit or delete the default shaares.
54 $bookmark = new Bookmark(); 57 $bookmark = new Bookmark();
55 $bookmark->setTitle(t('Note: Shaare descriptions')); 58 $bookmark->setTitle(t('Note: Shaare descriptions'));
56 $bookmark->setDescription(t( 59 $bookmark->setDescription(t(
57'Adding a shaare without entering a URL creates a text-only "note" post such as this one. 60 'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
58This note is private, so you are the only one able to see it while logged in. 61This note is private, so you are the only one able to see it while logged in.
59 62
60You can use this to keep notes, post articles, code snippets, and much more. 63You can use this to keep notes, post articles, code snippets, and much more.
@@ -91,7 +94,7 @@ Markdown also supports tables:
91 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') 94 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
92 ); 95 );
93 $bookmark->setDescription(t( 96 $bookmark->setDescription(t(
94'Welcome to Shaarli! 97 'Welcome to Shaarli!
95 98
96Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. 99Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
97You can add a description to your bookmarks, such as this one, and tag them. 100You can add a description to your bookmarks, such as this one, and tag them.
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
index 37a54d03..08cdbb4e 100644
--- a/application/bookmark/BookmarkServiceInterface.php
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -20,13 +20,14 @@ interface BookmarkServiceInterface
20 /** 20 /**
21 * Find a bookmark by hash 21 * Find a bookmark by hash
22 * 22 *
23 * @param string $hash 23 * @param string $hash Bookmark's hash
24 * @param string|null $privateKey Optional key used to access private links while logged out
24 * 25 *
25 * @return Bookmark 26 * @return Bookmark
26 * 27 *
27 * @throws \Exception 28 * @throws \Exception
28 */ 29 */
29 public function findByHash(string $hash): Bookmark; 30 public function findByHash(string $hash, string $privateKey = null);
30 31
31 /** 32 /**
32 * @param $url 33 * @param $url
@@ -155,22 +156,29 @@ interface BookmarkServiceInterface
155 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; 156 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
156 157
157 /** 158 /**
158 * Returns the list of days containing articles (oldest first) 159 * Return a list of bookmark matching provided period of time.
160 * It also update directly previous and next date outside of given period found in the datastore.
159 * 161 *
160 * @return array containing days (in format YYYYMMDD). 162 * @param \DateTimeInterface $from Starting date.
163 * @param \DateTimeInterface $to Ending date.
164 * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from.
165 * @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to.
166 *
167 * @return array List of bookmarks matching provided period of time.
161 */ 168 */
162 public function days(): array; 169 public function findByDate(
170 \DateTimeInterface $from,
171 \DateTimeInterface $to,
172 ?\DateTimeInterface &$previous,
173 ?\DateTimeInterface &$next
174 ): array;
163 175
164 /** 176 /**
165 * Returns the list of articles for a given day. 177 * Returns the latest bookmark by creation date.
166 *
167 * @param string $request day to filter. Format: YYYYMMDD.
168 * 178 *
169 * @return Bookmark[] list of shaare found. 179 * @return Bookmark|null Found Bookmark or null if the datastore is empty.
170 *
171 * @throws BookmarkNotFoundException
172 */ 180 */
173 public function filterDay(string $request); 181 public function getLatest(): ?Bookmark;
174 182
175 /** 183 /**
176 * Creates the default database after a fresh install. 184 * Creates the default database after a fresh install.
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index faf5dbfd..0ab2d213 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -67,17 +67,20 @@ function html_extract_tag($tag, $html)
67 $propertiesKey = ['property', 'name', 'itemprop']; 67 $propertiesKey = ['property', 'name', 'itemprop'];
68 $properties = implode('|', $propertiesKey); 68 $properties = implode('|', $propertiesKey);
69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' 69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
70 $orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; 70 $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
71 // Try to retrieve OpenGraph image. 71 // Support quotes in double quoted content, and the other way around
72 $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#'; 72 $content = 'content=(["\'])((?:(?!\1).)*)\1';
73 // Try to retrieve OpenGraph tag.
74 $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#';
73 // If the attributes are not in the order property => content (e.g. Github) 75 // If the attributes are not in the order property => content (e.g. Github)
74 // New regex to keep this readable... more or less. 76 // New regex to keep this readable... more or less.
75 $ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#'; 77 $ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
76 78
77 if (preg_match($ogRegex, $html, $matches) > 0 79 if (
80 preg_match($ogRegex, $html, $matches) > 0
78 || preg_match($ogRegexReverse, $html, $matches) > 0 81 || preg_match($ogRegexReverse, $html, $matches) > 0
79 ) { 82 ) {
80 return $matches[1]; 83 return $matches[2];
81 } 84 }
82 85
83 return false; 86 return false;
@@ -116,7 +119,7 @@ function hashtag_autolink($description, $indexUrl = '')
116 * \p{Mn} - any non marking space (accents, umlauts, etc) 119 * \p{Mn} - any non marking space (accents, umlauts, etc)
117 */ 120 */
118 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 121 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
119 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>'; 122 $replacement = '$1<a href="' . $indexUrl . './add-tag/$2" title="Hashtag $2">#$2</a>';
120 return preg_replace($regex, $replacement, $description); 123 return preg_replace($regex, $replacement, $description);
121} 124}
122 125
@@ -138,12 +141,17 @@ function space2nbsp($text)
138 * 141 *
139 * @param string $description shaare's description. 142 * @param string $description shaare's description.
140 * @param string $indexUrl URL to Shaarli's index. 143 * @param string $indexUrl URL to Shaarli's index.
141 144 * @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
145 *
142 * @return string formatted description. 146 * @return string formatted description.
143 */ 147 */
144function format_description($description, $indexUrl = '') 148function format_description($description, $indexUrl = '', $autolink = true)
145{ 149{
146 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl))); 150 if ($autolink) {
151 $description = hashtag_autolink(text2clickable($description), $indexUrl);
152 }
153
154 return nl2br(space2nbsp($description));
147} 155}
148 156
149/** 157/**
@@ -171,3 +179,49 @@ function is_note($linkUrl)
171{ 179{
172 return isset($linkUrl[0]) && $linkUrl[0] === '?'; 180 return isset($linkUrl[0]) && $linkUrl[0] === '?';
173} 181}
182
183/**
184 * Extract an array of tags from a given tag string, with provided separator.
185 *
186 * @param string|null $tags String containing a list of tags separated by $separator.
187 * @param string $separator Shaarli's default: ' ' (whitespace)
188 *
189 * @return array List of tags
190 */
191function tags_str2array(?string $tags, string $separator): array
192{
193 // For whitespaces, we use the special \s regex character
194 $separator = $separator === ' ' ? '\s' : $separator;
195
196 return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
197}
198
199/**
200 * Return a tag string with provided separator from a list of tags.
201 * Note that given array is clean up by tags_filter().
202 *
203 * @param array|null $tags List of tags
204 * @param string $separator
205 *
206 * @return string
207 */
208function tags_array2str(?array $tags, string $separator): string
209{
210 return implode($separator, tags_filter($tags, $separator));
211}
212
213/**
214 * Clean an array of tags: trim + remove empty entries
215 *
216 * @param array|null $tags List of tags
217 * @param string $separator
218 *
219 * @return array
220 */
221function tags_filter(?array $tags, string $separator): array
222{
223 $trimDefault = " \t\n\r\0\x0B";
224 return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
225 return trim($entry, $trimDefault . $separator);
226 }, $tags ?? [])));
227}
diff --git a/application/bookmark/exception/BookmarkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php
index 827a3d35..a91d1efa 100644
--- a/application/bookmark/exception/BookmarkNotFoundException.php
+++ b/application/bookmark/exception/BookmarkNotFoundException.php
@@ -1,4 +1,5 @@
1<?php 1<?php
2
2namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
3 4
4use Exception; 5use Exception;
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
index cd48c1e6..16a98470 100644
--- a/application/bookmark/exception/EmptyDataStoreException.php
+++ b/application/bookmark/exception/EmptyDataStoreException.php
@@ -1,7 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6 5class EmptyDataStoreException extends \Exception
7class EmptyDataStoreException extends \Exception {} 6{
7}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
index 10c84a6d..fe184f8c 100644
--- a/application/bookmark/exception/InvalidBookmarkException.php
+++ b/application/bookmark/exception/InvalidBookmarkException.php
@@ -16,14 +16,14 @@ class InvalidBookmarkException extends \Exception
16 } else { 16 } else {
17 $created = 'Not a DateTime object'; 17 $created = 'Not a DateTime object';
18 } 18 }
19 $this->message = 'This bookmark is not valid'. PHP_EOL; 19 $this->message = 'This bookmark is not valid' . PHP_EOL;
20 $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; 20 $this->message .= ' - ID: ' . $bookmark->getId() . PHP_EOL;
21 $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; 21 $this->message .= ' - Title: ' . $bookmark->getTitle() . PHP_EOL;
22 $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; 22 $this->message .= ' - Url: ' . $bookmark->getUrl() . PHP_EOL;
23 $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; 23 $this->message .= ' - ShortUrl: ' . $bookmark->getShortUrl() . PHP_EOL;
24 $this->message .= ' - Created: '. $created . PHP_EOL; 24 $this->message .= ' - Created: ' . $created . PHP_EOL;
25 } else { 25 } else {
26 $this->message = 'The provided data is not a bookmark'. PHP_EOL; 26 $this->message = 'The provided data is not a bookmark' . PHP_EOL;
27 $this->message .= var_export($bookmark, true); 27 $this->message .= var_export($bookmark, true);
28 } 28 }
29 } 29 }
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
index 95f34b50..df91f3bc 100644
--- a/application/bookmark/exception/NotWritableDataStoreException.php
+++ b/application/bookmark/exception/NotWritableDataStoreException.php
@@ -1,9 +1,7 @@
1<?php 1<?php
2 2
3
4namespace Shaarli\Bookmark\Exception; 3namespace Shaarli\Bookmark\Exception;
5 4
6
7class NotWritableDataStoreException extends \Exception 5class NotWritableDataStoreException extends \Exception
8{ 6{
9 /** 7 /**
@@ -13,7 +11,7 @@ class NotWritableDataStoreException extends \Exception
13 */ 11 */
14 public function __construct($dataStore) 12 public function __construct($dataStore)
15 { 13 {
16 $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. 14 $this->message = 'Couldn\'t load data from the data store file "' . $dataStore . '". ' .
17 'Your data might be corrupted, or your file isn\'t readable.'; 15 'Your data might be corrupted, or your file isn\'t readable.';
18 } 16 }
19} 17}