aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/bookmark
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2020-11-12 13:11:07 +0100
committerArthurHoaro <arthur@hoa.ro>2020-11-12 13:11:07 +0100
commitaf50eba28a7bd286de4c8c9ee6dc5216b915d149 (patch)
treeffa30a9358e82d27be75d8fc5e57f3c8820dc6d3 /application/bookmark
parentb6f678a5a1d15acf284ebcec16c905e976671ce1 (diff)
parent1409f1c89a7ca01456ae2dcd6357d296e2b99f5a (diff)
downloadShaarli-latest.tar.gz
Shaarli-latest.tar.zst
Shaarli-latest.zip
Merge tag 'v0.12.1' into latestlatest
v0.12.1
Diffstat (limited to 'application/bookmark')
-rw-r--r--application/bookmark/Bookmark.php217
-rw-r--r--application/bookmark/BookmarkArray.php20
-rw-r--r--application/bookmark/BookmarkFileService.php134
-rw-r--r--application/bookmark/BookmarkFilter.php203
-rw-r--r--application/bookmark/BookmarkIO.php43
-rw-r--r--application/bookmark/BookmarkInitializer.php19
-rw-r--r--application/bookmark/BookmarkServiceInterface.php109
-rw-r--r--application/bookmark/LinkUtils.php72
-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, 555 insertions, 287 deletions
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
index 1beb8be2..4238ef25 100644
--- a/application/bookmark/Bookmark.php
+++ b/application/bookmark/Bookmark.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use DateTime; 7use DateTime;
@@ -17,7 +19,7 @@ use Shaarli\Bookmark\Exception\InvalidBookmarkException;
17class Bookmark 19class Bookmark
18{ 20{
19 /** @var string Date format used in string (former ID format) */ 21 /** @var string Date format used in string (former ID format) */
20 const LINK_DATE_FORMAT = 'Ymd_His'; 22 public const LINK_DATE_FORMAT = 'Ymd_His';
21 23
22 /** @var int Bookmark ID */ 24 /** @var int Bookmark ID */
23 protected $id; 25 protected $id;
@@ -52,32 +54,37 @@ class Bookmark
52 /** @var bool True if the bookmark can only be seen while logged in */ 54 /** @var bool True if the bookmark can only be seen while logged in */
53 protected $private; 55 protected $private;
54 56
57 /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
58 protected $additionalContent = [];
59
55 /** 60 /**
56 * 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.
57 * 62 *
58 * @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.
59 * 66 *
60 * @return $this 67 * @return $this
61 */ 68 */
62 public function fromArray($data) 69 public function fromArray(array $data, string $tagsSeparator = ' '): Bookmark
63 { 70 {
64 $this->id = $data['id']; 71 $this->id = $data['id'] ?? null;
65 $this->shortUrl = $data['shorturl']; 72 $this->shortUrl = $data['shorturl'] ?? null;
66 $this->url = $data['url']; 73 $this->url = $data['url'] ?? null;
67 $this->title = $data['title']; 74 $this->title = $data['title'] ?? null;
68 $this->description = $data['description']; 75 $this->description = $data['description'] ?? null;
69 $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; 76 $this->thumbnail = $data['thumbnail'] ?? null;
70 $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; 77 $this->sticky = $data['sticky'] ?? false;
71 $this->created = $data['created']; 78 $this->created = $data['created'] ?? null;
72 if (is_array($data['tags'])) { 79 if (is_array($data['tags'])) {
73 $this->tags = $data['tags']; 80 $this->tags = $data['tags'];
74 } else { 81 } else {
75 $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); 82 $this->tags = tags_str2array($data['tags'] ?? '', $tagsSeparator);
76 } 83 }
77 if (! empty($data['updated'])) { 84 if (! empty($data['updated'])) {
78 $this->updated = $data['updated']; 85 $this->updated = $data['updated'];
79 } 86 }
80 $this->private = $data['private'] ? true : false; 87 $this->private = ($data['private'] ?? false) ? true : false;
81 88
82 return $this; 89 return $this;
83 } 90 }
@@ -93,24 +100,29 @@ class Bookmark
93 * - the URL with the permalink 100 * - the URL with the permalink
94 * - the title with the URL 101 * - the title with the URL
95 * 102 *
103 * Also make sure that we do not save search highlights in the datastore.
104 *
96 * @throws InvalidBookmarkException 105 * @throws InvalidBookmarkException
97 */ 106 */
98 public function validate() 107 public function validate(): void
99 { 108 {
100 if ($this->id === null 109 if (
110 $this->id === null
101 || ! is_int($this->id) 111 || ! is_int($this->id)
102 || empty($this->shortUrl) 112 || empty($this->shortUrl)
103 || empty($this->created) 113 || empty($this->created)
104 || ! $this->created instanceof DateTimeInterface
105 ) { 114 ) {
106 throw new InvalidBookmarkException($this); 115 throw new InvalidBookmarkException($this);
107 } 116 }
108 if (empty($this->url)) { 117 if (empty($this->url)) {
109 $this->url = '/shaare/'. $this->shortUrl; 118 $this->url = '/shaare/' . $this->shortUrl;
110 } 119 }
111 if (empty($this->title)) { 120 if (empty($this->title)) {
112 $this->title = $this->url; 121 $this->title = $this->url;
113 } 122 }
123 if (array_key_exists('search_highlight', $this->additionalContent)) {
124 unset($this->additionalContent['search_highlight']);
125 }
114 } 126 }
115 127
116 /** 128 /**
@@ -119,11 +131,11 @@ class Bookmark
119 * - created: with the current datetime 131 * - created: with the current datetime
120 * - shortUrl: with a generated small hash from the date and the given ID 132 * - shortUrl: with a generated small hash from the date and the given ID
121 * 133 *
122 * @param int $id 134 * @param int|null $id
123 * 135 *
124 * @return Bookmark 136 * @return Bookmark
125 */ 137 */
126 public function setId($id) 138 public function setId(?int $id): Bookmark
127 { 139 {
128 $this->id = $id; 140 $this->id = $id;
129 if (empty($this->created)) { 141 if (empty($this->created)) {
@@ -139,9 +151,9 @@ class Bookmark
139 /** 151 /**
140 * Get the Id. 152 * Get the Id.
141 * 153 *
142 * @return int 154 * @return int|null
143 */ 155 */
144 public function getId() 156 public function getId(): ?int
145 { 157 {
146 return $this->id; 158 return $this->id;
147 } 159 }
@@ -149,9 +161,9 @@ class Bookmark
149 /** 161 /**
150 * Get the ShortUrl. 162 * Get the ShortUrl.
151 * 163 *
152 * @return string 164 * @return string|null
153 */ 165 */
154 public function getShortUrl() 166 public function getShortUrl(): ?string
155 { 167 {
156 return $this->shortUrl; 168 return $this->shortUrl;
157 } 169 }
@@ -159,9 +171,9 @@ class Bookmark
159 /** 171 /**
160 * Get the Url. 172 * Get the Url.
161 * 173 *
162 * @return string 174 * @return string|null
163 */ 175 */
164 public function getUrl() 176 public function getUrl(): ?string
165 { 177 {
166 return $this->url; 178 return $this->url;
167 } 179 }
@@ -171,7 +183,7 @@ class Bookmark
171 * 183 *
172 * @return string 184 * @return string
173 */ 185 */
174 public function getTitle() 186 public function getTitle(): ?string
175 { 187 {
176 return $this->title; 188 return $this->title;
177 } 189 }
@@ -181,7 +193,7 @@ class Bookmark
181 * 193 *
182 * @return string 194 * @return string
183 */ 195 */
184 public function getDescription() 196 public function getDescription(): string
185 { 197 {
186 return ! empty($this->description) ? $this->description : ''; 198 return ! empty($this->description) ? $this->description : '';
187 } 199 }
@@ -191,7 +203,7 @@ class Bookmark
191 * 203 *
192 * @return DateTimeInterface 204 * @return DateTimeInterface
193 */ 205 */
194 public function getCreated() 206 public function getCreated(): ?DateTimeInterface
195 { 207 {
196 return $this->created; 208 return $this->created;
197 } 209 }
@@ -201,7 +213,7 @@ class Bookmark
201 * 213 *
202 * @return DateTimeInterface 214 * @return DateTimeInterface
203 */ 215 */
204 public function getUpdated() 216 public function getUpdated(): ?DateTimeInterface
205 { 217 {
206 return $this->updated; 218 return $this->updated;
207 } 219 }
@@ -209,11 +221,11 @@ class Bookmark
209 /** 221 /**
210 * Set the ShortUrl. 222 * Set the ShortUrl.
211 * 223 *
212 * @param string $shortUrl 224 * @param string|null $shortUrl
213 * 225 *
214 * @return Bookmark 226 * @return Bookmark
215 */ 227 */
216 public function setShortUrl($shortUrl) 228 public function setShortUrl(?string $shortUrl): Bookmark
217 { 229 {
218 $this->shortUrl = $shortUrl; 230 $this->shortUrl = $shortUrl;
219 231
@@ -223,14 +235,14 @@ class Bookmark
223 /** 235 /**
224 * Set the Url. 236 * Set the Url.
225 * 237 *
226 * @param string $url 238 * @param string|null $url
227 * @param array $allowedProtocols 239 * @param string[] $allowedProtocols
228 * 240 *
229 * @return Bookmark 241 * @return Bookmark
230 */ 242 */
231 public function setUrl($url, $allowedProtocols = []) 243 public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
232 { 244 {
233 $url = trim($url); 245 $url = $url !== null ? trim($url) : '';
234 if (! empty($url)) { 246 if (! empty($url)) {
235 $url = whitelist_protocols($url, $allowedProtocols); 247 $url = whitelist_protocols($url, $allowedProtocols);
236 } 248 }
@@ -242,13 +254,13 @@ class Bookmark
242 /** 254 /**
243 * Set the Title. 255 * Set the Title.
244 * 256 *
245 * @param string $title 257 * @param string|null $title
246 * 258 *
247 * @return Bookmark 259 * @return Bookmark
248 */ 260 */
249 public function setTitle($title) 261 public function setTitle(?string $title): Bookmark
250 { 262 {
251 $this->title = trim($title); 263 $this->title = $title !== null ? trim($title) : '';
252 264
253 return $this; 265 return $this;
254 } 266 }
@@ -256,11 +268,11 @@ class Bookmark
256 /** 268 /**
257 * Set the Description. 269 * Set the Description.
258 * 270 *
259 * @param string $description 271 * @param string|null $description
260 * 272 *
261 * @return Bookmark 273 * @return Bookmark
262 */ 274 */
263 public function setDescription($description) 275 public function setDescription(?string $description): Bookmark
264 { 276 {
265 $this->description = $description; 277 $this->description = $description;
266 278
@@ -271,11 +283,11 @@ class Bookmark
271 * Set the Created. 283 * Set the Created.
272 * Note: you shouldn't set this manually except for special cases (like bookmark import) 284 * Note: you shouldn't set this manually except for special cases (like bookmark import)
273 * 285 *
274 * @param DateTimeInterface $created 286 * @param DateTimeInterface|null $created
275 * 287 *
276 * @return Bookmark 288 * @return Bookmark
277 */ 289 */
278 public function setCreated($created) 290 public function setCreated(?DateTimeInterface $created): Bookmark
279 { 291 {
280 $this->created = $created; 292 $this->created = $created;
281 293
@@ -285,11 +297,11 @@ class Bookmark
285 /** 297 /**
286 * Set the Updated. 298 * Set the Updated.
287 * 299 *
288 * @param DateTimeInterface $updated 300 * @param DateTimeInterface|null $updated
289 * 301 *
290 * @return Bookmark 302 * @return Bookmark
291 */ 303 */
292 public function setUpdated($updated) 304 public function setUpdated(?DateTimeInterface $updated): Bookmark
293 { 305 {
294 $this->updated = $updated; 306 $this->updated = $updated;
295 307
@@ -301,7 +313,7 @@ class Bookmark
301 * 313 *
302 * @return bool 314 * @return bool
303 */ 315 */
304 public function isPrivate() 316 public function isPrivate(): bool
305 { 317 {
306 return $this->private ? true : false; 318 return $this->private ? true : false;
307 } 319 }
@@ -309,11 +321,11 @@ class Bookmark
309 /** 321 /**
310 * Set the Private. 322 * Set the Private.
311 * 323 *
312 * @param bool $private 324 * @param bool|null $private
313 * 325 *
314 * @return Bookmark 326 * @return Bookmark
315 */ 327 */
316 public function setPrivate($private) 328 public function setPrivate(?bool $private): Bookmark
317 { 329 {
318 $this->private = $private ? true : false; 330 $this->private = $private ? true : false;
319 331
@@ -323,9 +335,9 @@ class Bookmark
323 /** 335 /**
324 * Get the Tags. 336 * Get the Tags.
325 * 337 *
326 * @return array 338 * @return string[]
327 */ 339 */
328 public function getTags() 340 public function getTags(): array
329 { 341 {
330 return is_array($this->tags) ? $this->tags : []; 342 return is_array($this->tags) ? $this->tags : [];
331 } 343 }
@@ -333,13 +345,18 @@ class Bookmark
333 /** 345 /**
334 * Set the Tags. 346 * Set the Tags.
335 * 347 *
336 * @param array $tags 348 * @param string[]|null $tags
337 * 349 *
338 * @return Bookmark 350 * @return Bookmark
339 */ 351 */
340 public function setTags($tags) 352 public function setTags(?array $tags): Bookmark
341 { 353 {
342 $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 );
343 360
344 return $this; 361 return $this;
345 } 362 }
@@ -357,11 +374,11 @@ class Bookmark
357 /** 374 /**
358 * Set the Thumbnail. 375 * Set the Thumbnail.
359 * 376 *
360 * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found 377 * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
361 * 378 *
362 * @return Bookmark 379 * @return Bookmark
363 */ 380 */
364 public function setThumbnail($thumbnail) 381 public function setThumbnail($thumbnail): Bookmark
365 { 382 {
366 $this->thumbnail = $thumbnail; 383 $this->thumbnail = $thumbnail;
367 384
@@ -369,11 +386,29 @@ class Bookmark
369 } 386 }
370 387
371 /** 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 /**
372 * Get the Sticky. 407 * Get the Sticky.
373 * 408 *
374 * @return bool 409 * @return bool
375 */ 410 */
376 public function isSticky() 411 public function isSticky(): bool
377 { 412 {
378 return $this->sticky ? true : false; 413 return $this->sticky ? true : false;
379 } 414 }
@@ -381,11 +416,11 @@ class Bookmark
381 /** 416 /**
382 * Set the Sticky. 417 * Set the Sticky.
383 * 418 *
384 * @param bool $sticky 419 * @param bool|null $sticky
385 * 420 *
386 * @return Bookmark 421 * @return Bookmark
387 */ 422 */
388 public function setSticky($sticky) 423 public function setSticky(?bool $sticky): Bookmark
389 { 424 {
390 $this->sticky = $sticky ? true : false; 425 $this->sticky = $sticky ? true : false;
391 426
@@ -393,17 +428,19 @@ class Bookmark
393 } 428 }
394 429
395 /** 430 /**
396 * @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
397 */ 434 */
398 public function getTagsString() 435 public function getTagsString(string $separator = ' '): string
399 { 436 {
400 return implode(' ', $this->getTags()); 437 return tags_array2str($this->getTags(), $separator);
401 } 438 }
402 439
403 /** 440 /**
404 * @return bool 441 * @return bool
405 */ 442 */
406 public function isNote() 443 public function isNote(): bool
407 { 444 {
408 // We check empty value to get a valid result if the link has not been saved yet 445 // We check empty value to get a valid result if the link has not been saved yet
409 return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?'; 446 return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
@@ -416,33 +453,65 @@ class Bookmark
416 * - multiple spaces will be removed 453 * - multiple spaces will be removed
417 * - trailing dash in tags will be removed 454 * - trailing dash in tags will be removed
418 * 455 *
419 * @param string $tags 456 * @param string|null $tags
457 * @param string $separator Tags separator loaded from the config file.
420 * 458 *
421 * @return $this 459 * @return $this
422 */ 460 */
423 public function setTagsString($tags) 461 public function setTagsString(?string $tags, string $separator = ' '): Bookmark
424 { 462 {
425 // Remove first '-' char in tags. 463 $this->setTags(tags_str2array($tags, $separator));
426 $tags = preg_replace('/(^| )\-/', '$1', $tags);
427 // Explode all tags separted by spaces or commas
428 $tags = preg_split('/[\s,]+/', $tags);
429 // Remove eventual empty values
430 $tags = array_values(array_filter($tags));
431 464
432 $this->tags = $tags; 465 return $this;
466 }
467
468 /**
469 * Get entire additionalContent array.
470 *
471 * @return mixed[]
472 */
473 public function getAdditionalContent(): array
474 {
475 return $this->additionalContent;
476 }
477
478 /**
479 * Set a single entry in additionalContent, by key.
480 *
481 * @param string $key
482 * @param mixed|null $value Any type of value can be set.
483 *
484 * @return $this
485 */
486 public function addAdditionalContentEntry(string $key, $value): self
487 {
488 $this->additionalContent[$key] = $value;
433 489
434 return $this; 490 return $this;
435 } 491 }
436 492
437 /** 493 /**
494 * Get a single entry in additionalContent, by key.
495 *
496 * @param string $key
497 * @param mixed|null $default
498 *
499 * @return mixed|null can be any type or even null.
500 */
501 public function getAdditionalContentEntry(string $key, $default = null)
502 {
503 return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
504 }
505
506 /**
438 * Rename a tag in tags list. 507 * Rename a tag in tags list.
439 * 508 *
440 * @param string $fromTag 509 * @param string $fromTag
441 * @param string $toTag 510 * @param string $toTag
442 */ 511 */
443 public function renameTag($fromTag, $toTag) 512 public function renameTag(string $fromTag, string $toTag): void
444 { 513 {
445 if (($pos = array_search($fromTag, $this->tags)) !== false) { 514 if (($pos = array_search($fromTag, $this->tags ?? [])) !== false) {
446 $this->tags[$pos] = trim($toTag); 515 $this->tags[$pos] = trim($toTag);
447 } 516 }
448 } 517 }
@@ -452,9 +521,9 @@ class Bookmark
452 * 521 *
453 * @param string $tag 522 * @param string $tag
454 */ 523 */
455 public function deleteTag($tag) 524 public function deleteTag(string $tag): void
456 { 525 {
457 if (($pos = array_search($tag, $this->tags)) !== false) { 526 if (($pos = array_search($tag, $this->tags ?? [])) !== false) {
458 unset($this->tags[$pos]); 527 unset($this->tags[$pos]);
459 $this->tags = array_values($this->tags); 528 $this->tags = array_values($this->tags);
460 } 529 }
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
index 3bd5eb20..b9328116 100644
--- a/application/bookmark/BookmarkArray.php
+++ b/application/bookmark/BookmarkArray.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use Shaarli\Bookmark\Exception\InvalidBookmarkException; 7use Shaarli\Bookmark\Exception\InvalidBookmarkException;
@@ -70,7 +72,8 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
70 */ 72 */
71 public function offsetSet($offset, $value) 73 public function offsetSet($offset, $value)
72 { 74 {
73 if (! $value instanceof Bookmark 75 if (
76 ! $value instanceof Bookmark
74 || $value->getId() === null || empty($value->getUrl()) 77 || $value->getId() === null || empty($value->getUrl())
75 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) 78 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
76 || $offset !== null && $offset !== $value->getId() 79 || $offset !== null && $offset !== $value->getId()
@@ -187,13 +190,13 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
187 /** 190 /**
188 * Returns a bookmark offset in bookmarks array from its unique ID. 191 * Returns a bookmark offset in bookmarks array from its unique ID.
189 * 192 *
190 * @param int $id Persistent ID of a bookmark. 193 * @param int|null $id Persistent ID of a bookmark.
191 * 194 *
192 * @return int Real offset in local array, or null if doesn't exist. 195 * @return int Real offset in local array, or null if doesn't exist.
193 */ 196 */
194 protected function getBookmarkOffset($id) 197 protected function getBookmarkOffset(?int $id): ?int
195 { 198 {
196 if (isset($this->ids[$id])) { 199 if ($id !== null && isset($this->ids[$id])) {
197 return $this->ids[$id]; 200 return $this->ids[$id];
198 } 201 }
199 return null; 202 return null;
@@ -205,7 +208,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
205 * 208 *
206 * @return int next ID. 209 * @return int next ID.
207 */ 210 */
208 public function getNextId() 211 public function getNextId(): int
209 { 212 {
210 if (!empty($this->ids)) { 213 if (!empty($this->ids)) {
211 return max(array_keys($this->ids)) + 1; 214 return max(array_keys($this->ids)) + 1;
@@ -214,13 +217,14 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
214 } 217 }
215 218
216 /** 219 /**
217 * @param $url 220 * @param string $url
218 * 221 *
219 * @return Bookmark|null 222 * @return Bookmark|null
220 */ 223 */
221 public function getByUrl($url) 224 public function getByUrl(string $url): ?Bookmark
222 { 225 {
223 if (! empty($url) 226 if (
227 ! empty($url)
224 && isset($this->urls[$url]) 228 && isset($this->urls[$url])
225 && isset($this->bookmarks[$this->urls[$url]]) 229 && isset($this->bookmarks[$this->urls[$url]])
226 ) { 230 ) {
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index c9ec2609..6666a251 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -1,10 +1,12 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
3 4
4namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
5 6
6 7use DateTime;
7use Exception; 8use Exception;
9use malkusch\lock\mutex\Mutex;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 10use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; 11use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
10use Shaarli\Bookmark\Exception\EmptyDataStoreException; 12use Shaarli\Bookmark\Exception\EmptyDataStoreException;
@@ -47,15 +49,19 @@ class BookmarkFileService implements BookmarkServiceInterface
47 /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ 49 /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
48 protected $isLoggedIn; 50 protected $isLoggedIn;
49 51
52 /** @var Mutex */
53 protected $mutex;
54
50 /** 55 /**
51 * @inheritDoc 56 * @inheritDoc
52 */ 57 */
53 public function __construct(ConfigManager $conf, History $history, $isLoggedIn) 58 public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn)
54 { 59 {
55 $this->conf = $conf; 60 $this->conf = $conf;
56 $this->history = $history; 61 $this->history = $history;
62 $this->mutex = $mutex;
57 $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn); 63 $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
58 $this->bookmarksIO = new BookmarkIO($this->conf); 64 $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
59 $this->isLoggedIn = $isLoggedIn; 65 $this->isLoggedIn = $isLoggedIn;
60 66
61 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { 67 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
@@ -63,7 +69,7 @@ class BookmarkFileService implements BookmarkServiceInterface
63 } else { 69 } else {
64 try { 70 try {
65 $this->bookmarks = $this->bookmarksIO->read(); 71 $this->bookmarks = $this->bookmarksIO->read();
66 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { 72 } catch (EmptyDataStoreException | DatastoreNotInitializedException $e) {
67 $this->bookmarks = new BookmarkArray(); 73 $this->bookmarks = new BookmarkArray();
68 74
69 if ($this->isLoggedIn) { 75 if ($this->isLoggedIn) {
@@ -79,25 +85,29 @@ class BookmarkFileService implements BookmarkServiceInterface
79 if (! $this->bookmarks instanceof BookmarkArray) { 85 if (! $this->bookmarks instanceof BookmarkArray) {
80 $this->migrate(); 86 $this->migrate();
81 exit( 87 exit(
82 '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 .
83 '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.'
84 ); 90 );
85 } 91 }
86 } 92 }
87 93
88 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); 94 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf);
89 } 95 }
90 96
91 /** 97 /**
92 * @inheritDoc 98 * @inheritDoc
93 */ 99 */
94 public function findByHash($hash) 100 public function findByHash(string $hash, string $privateKey = null): Bookmark
95 { 101 {
96 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); 102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
97 // PHP 7.3 introduced array_key_first() to avoid this hack 103 // PHP 7.3 introduced array_key_first() to avoid this hack
98 $first = reset($bookmark); 104 $first = reset($bookmark);
99 if (! $this->isLoggedIn && $first->isPrivate()) { 105 if (
100 throw new Exception('Not authorized'); 106 !$this->isLoggedIn
107 && $first->isPrivate()
108 && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
109 ) {
110 throw new BookmarkNotFoundException();
101 } 111 }
102 112
103 return $first; 113 return $first;
@@ -106,7 +116,7 @@ class BookmarkFileService implements BookmarkServiceInterface
106 /** 116 /**
107 * @inheritDoc 117 * @inheritDoc
108 */ 118 */
109 public function findByUrl($url) 119 public function findByUrl(string $url): ?Bookmark
110 { 120 {
111 return $this->bookmarks->getByUrl($url); 121 return $this->bookmarks->getByUrl($url);
112 } 122 }
@@ -115,10 +125,10 @@ class BookmarkFileService implements BookmarkServiceInterface
115 * @inheritDoc 125 * @inheritDoc
116 */ 126 */
117 public function search( 127 public function search(
118 $request = [], 128 array $request = [],
119 $visibility = null, 129 string $visibility = null,
120 $caseSensitive = false, 130 bool $caseSensitive = false,
121 $untaggedOnly = false, 131 bool $untaggedOnly = false,
122 bool $ignoreSticky = false 132 bool $ignoreSticky = false
123 ) { 133 ) {
124 if ($visibility === null) { 134 if ($visibility === null) {
@@ -126,8 +136,8 @@ class BookmarkFileService implements BookmarkServiceInterface
126 } 136 }
127 137
128 // Filter bookmark database according to parameters. 138 // Filter bookmark database according to parameters.
129 $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; 139 $searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
130 $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; 140 $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
131 141
132 if ($ignoreSticky) { 142 if ($ignoreSticky) {
133 $this->bookmarks->reorder('DESC', true); 143 $this->bookmarks->reorder('DESC', true);
@@ -135,7 +145,7 @@ class BookmarkFileService implements BookmarkServiceInterface
135 145
136 return $this->bookmarkFilter->filter( 146 return $this->bookmarkFilter->filter(
137 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, 147 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
138 [$searchtags, $searchterm], 148 [$searchTags, $searchTerm],
139 $caseSensitive, 149 $caseSensitive,
140 $visibility, 150 $visibility,
141 $untaggedOnly 151 $untaggedOnly
@@ -145,7 +155,7 @@ class BookmarkFileService implements BookmarkServiceInterface
145 /** 155 /**
146 * @inheritDoc 156 * @inheritDoc
147 */ 157 */
148 public function get($id, $visibility = null) 158 public function get(int $id, string $visibility = null): Bookmark
149 { 159 {
150 if (! isset($this->bookmarks[$id])) { 160 if (! isset($this->bookmarks[$id])) {
151 throw new BookmarkNotFoundException(); 161 throw new BookmarkNotFoundException();
@@ -156,7 +166,8 @@ class BookmarkFileService implements BookmarkServiceInterface
156 } 166 }
157 167
158 $bookmark = $this->bookmarks[$id]; 168 $bookmark = $this->bookmarks[$id];
159 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 169 if (
170 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
160 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 171 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
161 ) { 172 ) {
162 throw new Exception('Unauthorized'); 173 throw new Exception('Unauthorized');
@@ -168,20 +179,17 @@ class BookmarkFileService implements BookmarkServiceInterface
168 /** 179 /**
169 * @inheritDoc 180 * @inheritDoc
170 */ 181 */
171 public function set($bookmark, $save = true) 182 public function set(Bookmark $bookmark, bool $save = true): Bookmark
172 { 183 {
173 if (true !== $this->isLoggedIn) { 184 if (true !== $this->isLoggedIn) {
174 throw new Exception(t('You\'re not authorized to alter the datastore')); 185 throw new Exception(t('You\'re not authorized to alter the datastore'));
175 } 186 }
176 if (! $bookmark instanceof Bookmark) {
177 throw new Exception(t('Provided data is invalid'));
178 }
179 if (! isset($this->bookmarks[$bookmark->getId()])) { 187 if (! isset($this->bookmarks[$bookmark->getId()])) {
180 throw new BookmarkNotFoundException(); 188 throw new BookmarkNotFoundException();
181 } 189 }
182 $bookmark->validate(); 190 $bookmark->validate();
183 191
184 $bookmark->setUpdated(new \DateTime()); 192 $bookmark->setUpdated(new DateTime());
185 $this->bookmarks[$bookmark->getId()] = $bookmark; 193 $this->bookmarks[$bookmark->getId()] = $bookmark;
186 if ($save === true) { 194 if ($save === true) {
187 $this->save(); 195 $this->save();
@@ -193,15 +201,12 @@ class BookmarkFileService implements BookmarkServiceInterface
193 /** 201 /**
194 * @inheritDoc 202 * @inheritDoc
195 */ 203 */
196 public function add($bookmark, $save = true) 204 public function add(Bookmark $bookmark, bool $save = true): Bookmark
197 { 205 {
198 if (true !== $this->isLoggedIn) { 206 if (true !== $this->isLoggedIn) {
199 throw new Exception(t('You\'re not authorized to alter the datastore')); 207 throw new Exception(t('You\'re not authorized to alter the datastore'));
200 } 208 }
201 if (! $bookmark instanceof Bookmark) { 209 if (!empty($bookmark->getId())) {
202 throw new Exception(t('Provided data is invalid'));
203 }
204 if (! empty($bookmark->getId())) {
205 throw new Exception(t('This bookmarks already exists')); 210 throw new Exception(t('This bookmarks already exists'));
206 } 211 }
207 $bookmark->setId($this->bookmarks->getNextId()); 212 $bookmark->setId($this->bookmarks->getNextId());
@@ -218,14 +223,11 @@ class BookmarkFileService implements BookmarkServiceInterface
218 /** 223 /**
219 * @inheritDoc 224 * @inheritDoc
220 */ 225 */
221 public function addOrSet($bookmark, $save = true) 226 public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
222 { 227 {
223 if (true !== $this->isLoggedIn) { 228 if (true !== $this->isLoggedIn) {
224 throw new Exception(t('You\'re not authorized to alter the datastore')); 229 throw new Exception(t('You\'re not authorized to alter the datastore'));
225 } 230 }
226 if (! $bookmark instanceof Bookmark) {
227 throw new Exception('Provided data is invalid');
228 }
229 if ($bookmark->getId() === null) { 231 if ($bookmark->getId() === null) {
230 return $this->add($bookmark, $save); 232 return $this->add($bookmark, $save);
231 } 233 }
@@ -235,14 +237,11 @@ class BookmarkFileService implements BookmarkServiceInterface
235 /** 237 /**
236 * @inheritDoc 238 * @inheritDoc
237 */ 239 */
238 public function remove($bookmark, $save = true) 240 public function remove(Bookmark $bookmark, bool $save = true): void
239 { 241 {
240 if (true !== $this->isLoggedIn) { 242 if (true !== $this->isLoggedIn) {
241 throw new Exception(t('You\'re not authorized to alter the datastore')); 243 throw new Exception(t('You\'re not authorized to alter the datastore'));
242 } 244 }
243 if (! $bookmark instanceof Bookmark) {
244 throw new Exception(t('Provided data is invalid'));
245 }
246 if (! isset($this->bookmarks[$bookmark->getId()])) { 245 if (! isset($this->bookmarks[$bookmark->getId()])) {
247 throw new BookmarkNotFoundException(); 246 throw new BookmarkNotFoundException();
248 } 247 }
@@ -257,7 +256,7 @@ class BookmarkFileService implements BookmarkServiceInterface
257 /** 256 /**
258 * @inheritDoc 257 * @inheritDoc
259 */ 258 */
260 public function exists($id, $visibility = null) 259 public function exists(int $id, string $visibility = null): bool
261 { 260 {
262 if (! isset($this->bookmarks[$id])) { 261 if (! isset($this->bookmarks[$id])) {
263 return false; 262 return false;
@@ -268,7 +267,8 @@ class BookmarkFileService implements BookmarkServiceInterface
268 } 267 }
269 268
270 $bookmark = $this->bookmarks[$id]; 269 $bookmark = $this->bookmarks[$id];
271 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') 270 if (
271 ($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
272 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') 272 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
273 ) { 273 ) {
274 return false; 274 return false;
@@ -280,7 +280,7 @@ class BookmarkFileService implements BookmarkServiceInterface
280 /** 280 /**
281 * @inheritDoc 281 * @inheritDoc
282 */ 282 */
283 public function count($visibility = null) 283 public function count(string $visibility = null): int
284 { 284 {
285 return count($this->search([], $visibility)); 285 return count($this->search([], $visibility));
286 } 286 }
@@ -288,7 +288,7 @@ class BookmarkFileService implements BookmarkServiceInterface
288 /** 288 /**
289 * @inheritDoc 289 * @inheritDoc
290 */ 290 */
291 public function save() 291 public function save(): void
292 { 292 {
293 if (true !== $this->isLoggedIn) { 293 if (true !== $this->isLoggedIn) {
294 // TODO: raise an Exception instead 294 // TODO: raise an Exception instead
@@ -303,14 +303,15 @@ class BookmarkFileService implements BookmarkServiceInterface
303 /** 303 /**
304 * @inheritDoc 304 * @inheritDoc
305 */ 305 */
306 public function bookmarksCountPerTag($filteringTags = [], $visibility = null) 306 public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
307 { 307 {
308 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); 308 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
309 $tags = []; 309 $tags = [];
310 $caseMapping = []; 310 $caseMapping = [];
311 foreach ($bookmarks as $bookmark) { 311 foreach ($bookmarks as $bookmark) {
312 foreach ($bookmark->getTags() as $tag) { 312 foreach ($bookmark->getTags() as $tag) {
313 if (empty($tag) 313 if (
314 empty($tag)
314 || (! $this->isLoggedIn && startsWith($tag, '.')) 315 || (! $this->isLoggedIn && startsWith($tag, '.'))
315 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG 316 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
316 || in_array($tag, $filteringTags, true) 317 || in_array($tag, $filteringTags, true)
@@ -339,38 +340,55 @@ class BookmarkFileService implements BookmarkServiceInterface
339 $keys = array_keys($tags); 340 $keys = array_keys($tags);
340 $tmpTags = array_combine($keys, $keys); 341 $tmpTags = array_combine($keys, $keys);
341 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); 342 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
343
342 return $tags; 344 return $tags;
343 } 345 }
344 346
345 /** 347 /**
346 * @inheritDoc 348 * @inheritDoc
347 */ 349 */
348 public function days() 350 public function findByDate(
349 { 351 \DateTimeInterface $from,
350 $bookmarkDays = []; 352 \DateTimeInterface $to,
351 foreach ($this->search() as $bookmark) { 353 ?\DateTimeInterface &$previous,
352 $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 }
353 } 371 }
354 $bookmarkDays = array_keys($bookmarkDays);
355 sort($bookmarkDays);
356 372
357 return $bookmarkDays; 373 return $out;
358 } 374 }
359 375
360 /** 376 /**
361 * @inheritDoc 377 * @inheritDoc
362 */ 378 */
363 public function filterDay($request) 379 public function getLatest(): ?Bookmark
364 { 380 {
365 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; 381 foreach ($this->search([], null, false, false, true) as $bookmark) {
382 return $bookmark;
383 }
366 384
367 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); 385 return null;
368 } 386 }
369 387
370 /** 388 /**
371 * @inheritDoc 389 * @inheritDoc
372 */ 390 */
373 public function initialize() 391 public function initialize(): void
374 { 392 {
375 $initializer = new BookmarkInitializer($this); 393 $initializer = new BookmarkInitializer($this);
376 $initializer->initialize(); 394 $initializer->initialize();
@@ -383,7 +401,7 @@ class BookmarkFileService implements BookmarkServiceInterface
383 /** 401 /**
384 * Handles migration to the new database format (BookmarksArray). 402 * Handles migration to the new database format (BookmarksArray).
385 */ 403 */
386 protected function migrate() 404 protected function migrate(): void
387 { 405 {
388 $bookmarkDb = new LegacyLinkDB( 406 $bookmarkDb = new LegacyLinkDB(
389 $this->conf->get('resource.datastore'), 407 $this->conf->get('resource.datastore'),
@@ -391,14 +409,14 @@ class BookmarkFileService implements BookmarkServiceInterface
391 false 409 false
392 ); 410 );
393 $updater = new LegacyUpdater( 411 $updater = new LegacyUpdater(
394 UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), 412 UpdaterUtils::readUpdatesFile($this->conf->get('resource.updates')),
395 $bookmarkDb, 413 $bookmarkDb,
396 $this->conf, 414 $this->conf,
397 true 415 true
398 ); 416 );
399 $newUpdates = $updater->update(); 417 $newUpdates = $updater->update();
400 if (! empty($newUpdates)) { 418 if (! empty($newUpdates)) {
401 UpdaterUtils::write_updates_file( 419 UpdaterUtils::writeUpdatesFile(
402 $this->conf->get('resource.updates'), 420 $this->conf->get('resource.updates'),
403 $updater->getDoneUpdates() 421 $updater->getDoneUpdates()
404 ); 422 );
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index 6636bbfe..db83c51c 100644
--- a/application/bookmark/BookmarkFilter.php
+++ b/application/bookmark/BookmarkFilter.php
@@ -1,9 +1,12 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use Exception; 7use Exception;
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Config\ConfigManager;
7 10
8/** 11/**
9 * Class LinkFilter. 12 * Class LinkFilter.
@@ -56,12 +59,16 @@ class BookmarkFilter
56 */ 59 */
57 private $bookmarks; 60 private $bookmarks;
58 61
62 /** @var ConfigManager */
63 protected $conf;
64
59 /** 65 /**
60 * @param Bookmark[] $bookmarks initialization. 66 * @param Bookmark[] $bookmarks initialization.
61 */ 67 */
62 public function __construct($bookmarks) 68 public function __construct($bookmarks, ConfigManager $conf)
63 { 69 {
64 $this->bookmarks = $bookmarks; 70 $this->bookmarks = $bookmarks;
71 $this->conf = $conf;
65 } 72 }
66 73
67 /** 74 /**
@@ -77,8 +84,13 @@ class BookmarkFilter
77 * 84 *
78 * @throws BookmarkNotFoundException 85 * @throws BookmarkNotFoundException
79 */ 86 */
80 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) 87 public function filter(
81 { 88 string $type,
89 $request,
90 bool $casesensitive = false,
91 string $visibility = 'all',
92 bool $untaggedonly = false
93 ) {
82 if (!in_array($visibility, ['all', 'public', 'private'])) { 94 if (!in_array($visibility, ['all', 'public', 'private'])) {
83 $visibility = 'all'; 95 $visibility = 'all';
84 } 96 }
@@ -100,10 +112,14 @@ class BookmarkFilter
100 $filtered = $this->bookmarks; 112 $filtered = $this->bookmarks;
101 } 113 }
102 if (!empty($request[0])) { 114 if (!empty($request[0])) {
103 $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); 115 $filtered = (new BookmarkFilter($filtered, $this->conf))
116 ->filterTags($request[0], $casesensitive, $visibility)
117 ;
104 } 118 }
105 if (!empty($request[1])) { 119 if (!empty($request[1])) {
106 $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); 120 $filtered = (new BookmarkFilter($filtered, $this->conf))
121 ->filterFulltext($request[1], $visibility)
122 ;
107 } 123 }
108 return $filtered; 124 return $filtered;
109 case self::$FILTER_TEXT: 125 case self::$FILTER_TEXT:
@@ -128,13 +144,13 @@ class BookmarkFilter
128 * 144 *
129 * @return Bookmark[] filtered bookmarks. 145 * @return Bookmark[] filtered bookmarks.
130 */ 146 */
131 private function noFilter($visibility = 'all') 147 private function noFilter(string $visibility = 'all')
132 { 148 {
133 if ($visibility === 'all') { 149 if ($visibility === 'all') {
134 return $this->bookmarks; 150 return $this->bookmarks;
135 } 151 }
136 152
137 $out = array(); 153 $out = [];
138 foreach ($this->bookmarks as $key => $value) { 154 foreach ($this->bookmarks as $key => $value) {
139 if ($value->isPrivate() && $visibility === 'private') { 155 if ($value->isPrivate() && $visibility === 'private') {
140 $out[$key] = $value; 156 $out[$key] = $value;
@@ -151,11 +167,11 @@ class BookmarkFilter
151 * 167 *
152 * @param string $smallHash permalink hash. 168 * @param string $smallHash permalink hash.
153 * 169 *
154 * @return array $filtered array containing permalink data. 170 * @return Bookmark[] $filtered array containing permalink data.
155 * 171 *
156 * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. 172 * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
157 */ 173 */
158 private function filterSmallHash($smallHash) 174 private function filterSmallHash(string $smallHash)
159 { 175 {
160 foreach ($this->bookmarks as $key => $l) { 176 foreach ($this->bookmarks as $key => $l) {
161 if ($smallHash == $l->getShortUrl()) { 177 if ($smallHash == $l->getShortUrl()) {
@@ -186,15 +202,15 @@ class BookmarkFilter
186 * @param string $searchterms search query. 202 * @param string $searchterms search query.
187 * @param string $visibility Optional: return only all/private/public bookmarks. 203 * @param string $visibility Optional: return only all/private/public bookmarks.
188 * 204 *
189 * @return array search results. 205 * @return Bookmark[] search results.
190 */ 206 */
191 private function filterFulltext($searchterms, $visibility = 'all') 207 private function filterFulltext(string $searchterms, string $visibility = 'all')
192 { 208 {
193 if (empty($searchterms)) { 209 if (empty($searchterms)) {
194 return $this->noFilter($visibility); 210 return $this->noFilter($visibility);
195 } 211 }
196 212
197 $filtered = array(); 213 $filtered = [];
198 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); 214 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
199 $exactRegex = '/"([^"]+)"/'; 215 $exactRegex = '/"([^"]+)"/';
200 // Retrieve exact search terms. 216 // Retrieve exact search terms.
@@ -206,8 +222,8 @@ class BookmarkFilter
206 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); 222 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
207 223
208 // Filter excluding terms and update andSearch. 224 // Filter excluding terms and update andSearch.
209 $excludeSearch = array(); 225 $excludeSearch = [];
210 $andSearch = array(); 226 $andSearch = [];
211 foreach ($explodedSearchAnd as $needle) { 227 foreach ($explodedSearchAnd as $needle) {
212 if ($needle[0] == '-' && strlen($needle) > 1) { 228 if ($needle[0] == '-' && strlen($needle) > 1) {
213 $excludeSearch[] = substr($needle, 1); 229 $excludeSearch[] = substr($needle, 1);
@@ -227,33 +243,38 @@ class BookmarkFilter
227 } 243 }
228 } 244 }
229 245
230 // Concatenate link fields to search across fields. 246 $lengths = [];
231 // Adds a '\' separator for exact search terms. 247 $content = $this->buildFullTextSearchableLink($link, $lengths);
232 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
233 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
234 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
235 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
236 248
237 // Be optimistic 249 // Be optimistic
238 $found = true; 250 $found = true;
251 $foundPositions = [];
239 252
240 // First, we look for exact term search 253 // First, we look for exact term search
241 for ($i = 0; $i < count($exactSearch) && $found; $i++) { 254 // Then iterate over keywords, if keyword is not found,
242 $found = strpos($content, $exactSearch[$i]) !== false;
243 }
244
245 // Iterate over keywords, if keyword is not found,
246 // no need to check for the others. We want all or nothing. 255 // no need to check for the others. We want all or nothing.
247 for ($i = 0; $i < count($andSearch) && $found; $i++) { 256 foreach ([$exactSearch, $andSearch] as $search) {
248 $found = strpos($content, $andSearch[$i]) !== false; 257 for ($i = 0; $i < count($search) && $found !== false; $i++) {
258 $found = mb_strpos($content, $search[$i]);
259 if ($found === false) {
260 break;
261 }
262
263 $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
264 }
249 } 265 }
250 266
251 // Exclude terms. 267 // Exclude terms.
252 for ($i = 0; $i < count($excludeSearch) && $found; $i++) { 268 for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
253 $found = strpos($content, $excludeSearch[$i]) === false; 269 $found = strpos($content, $excludeSearch[$i]) === false;
254 } 270 }
255 271
256 if ($found) { 272 if ($found !== false) {
273 $link->addAdditionalContentEntry(
274 'search_highlight',
275 $this->postProcessFoundPositions($lengths, $foundPositions)
276 );
277
257 $filtered[$id] = $link; 278 $filtered[$id] = $link;
258 } 279 }
259 } 280 }
@@ -268,8 +289,9 @@ class BookmarkFilter
268 * 289 *
269 * @return string generated regex fragment 290 * @return string generated regex fragment
270 */ 291 */
271 private static function tag2regex($tag) 292 protected function tag2regex(string $tag): string
272 { 293 {
294 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
273 $len = strlen($tag); 295 $len = strlen($tag);
274 if (!$len || $tag === "-" || $tag === "*") { 296 if (!$len || $tag === "-" || $tag === "*") {
275 // nothing to search, return empty regex 297 // nothing to search, return empty regex
@@ -283,12 +305,13 @@ class BookmarkFilter
283 $i = 0; // start at first character 305 $i = 0; // start at first character
284 $regex = '(?='; // use positive lookahead 306 $regex = '(?='; // use positive lookahead
285 } 307 }
286 $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 . ')';
287 // iterate over string, separating it into placeholder and content 310 // iterate over string, separating it into placeholder and content
288 for (; $i < $len; $i++) { 311 for (; $i < $len; $i++) {
289 if ($tag[$i] === '*') { 312 if ($tag[$i] === '*') {
290 // placeholder found 313 // placeholder found
291 $regex .= '[^ ]*?'; 314 $regex .= '[^' . $tagsSeparator . ']*?';
292 } else { 315 } else {
293 // regular characters 316 // regular characters
294 $offset = strpos($tag, '*', $i); 317 $offset = strpos($tag, '*', $i);
@@ -304,7 +327,8 @@ class BookmarkFilter
304 $i = $offset; 327 $i = $offset;
305 } 328 }
306 } 329 }
307 $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 . '))';
308 return $regex; 332 return $regex;
309 } 333 }
310 334
@@ -314,22 +338,23 @@ class BookmarkFilter
314 * You can specify one or more tags, separated by space or a comma, e.g. 338 * You can specify one or more tags, separated by space or a comma, e.g.
315 * print_r($mydb->filterTags('linux programming')); 339 * print_r($mydb->filterTags('linux programming'));
316 * 340 *
317 * @param string $tags list of tags separated by commas or blank spaces. 341 * @param string|array $tags list of tags, separated by commas or blank spaces if passed as string.
318 * @param bool $casesensitive ignore case if false. 342 * @param bool $casesensitive ignore case if false.
319 * @param string $visibility Optional: return only all/private/public bookmarks. 343 * @param string $visibility Optional: return only all/private/public bookmarks.
320 * 344 *
321 * @return array filtered bookmarks. 345 * @return Bookmark[] filtered bookmarks.
322 */ 346 */
323 public function filterTags($tags, $casesensitive = false, $visibility = 'all') 347 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
324 { 348 {
349 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
325 // 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)
326 $inputTags = $tags; 351 $inputTags = $tags;
327 if (!is_array($tags)) { 352 if (!is_array($tags)) {
328 // we got an input string, split tags 353 // we got an input string, split tags
329 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); 354 $inputTags = tags_str2array($inputTags, $tagsSeparator);
330 } 355 }
331 356
332 if (!count($inputTags)) { 357 if (count($inputTags) === 0) {
333 // no input tags 358 // no input tags
334 return $this->noFilter($visibility); 359 return $this->noFilter($visibility);
335 } 360 }
@@ -346,7 +371,7 @@ class BookmarkFilter
346 } 371 }
347 372
348 // build regex from all tags 373 // build regex from all tags
349 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; 374 $re = '/^' . implode(array_map([$this, 'tag2regex'], $inputTags)) . '.*$/';
350 if (!$casesensitive) { 375 if (!$casesensitive) {
351 // make regex case insensitive 376 // make regex case insensitive
352 $re .= 'i'; 377 $re .= 'i';
@@ -366,10 +391,11 @@ class BookmarkFilter
366 continue; 391 continue;
367 } 392 }
368 } 393 }
369 $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);
370 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { 396 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
371 // description given and at least one possible tag found 397 // description given and at least one possible tag found
372 $descTags = array(); 398 $descTags = [];
373 // find all tags in the form of #tag in the description 399 // find all tags in the form of #tag in the description
374 preg_match_all( 400 preg_match_all(
375 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 401 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
@@ -378,9 +404,9 @@ class BookmarkFilter
378 ); 404 );
379 if (count($descTags[1])) { 405 if (count($descTags[1])) {
380 // 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
381 $search .= ' ' . implode(' ', $descTags[1]); 407 $search .= $tagsSeparator . tags_array2str($descTags[1], $tagsSeparator);
382 } 408 }
383 }; 409 }
384 // match regular expression with search string 410 // match regular expression with search string
385 if (!preg_match($re, $search)) { 411 if (!preg_match($re, $search)) {
386 // this entry does _not_ match our regex 412 // this entry does _not_ match our regex
@@ -396,9 +422,9 @@ class BookmarkFilter
396 * 422 *
397 * @param string $visibility return only all/private/public bookmarks. 423 * @param string $visibility return only all/private/public bookmarks.
398 * 424 *
399 * @return array filtered bookmarks. 425 * @return Bookmark[] filtered bookmarks.
400 */ 426 */
401 public function filterUntagged($visibility) 427 public function filterUntagged(string $visibility)
402 { 428 {
403 $filtered = []; 429 $filtered = [];
404 foreach ($this->bookmarks as $key => $link) { 430 foreach ($this->bookmarks as $key => $link) {
@@ -410,7 +436,7 @@ class BookmarkFilter
410 } 436 }
411 } 437 }
412 438
413 if (empty(trim($link->getTagsString()))) { 439 if (empty($link->getTags())) {
414 $filtered[$key] = $link; 440 $filtered[$key] = $link;
415 } 441 }
416 } 442 }
@@ -427,11 +453,11 @@ class BookmarkFilter
427 * @param string $day day to filter. 453 * @param string $day day to filter.
428 * @param string $visibility return only all/private/public bookmarks. 454 * @param string $visibility return only all/private/public bookmarks.
429 455
430 * @return array all link matching given day. 456 * @return Bookmark[] all link matching given day.
431 * 457 *
432 * @throws Exception if date format is invalid. 458 * @throws Exception if date format is invalid.
433 */ 459 */
434 public function filterDay($day, $visibility) 460 public function filterDay(string $day, string $visibility)
435 { 461 {
436 if (!checkDateFormat('Ymd', $day)) { 462 if (!checkDateFormat('Ymd', $day)) {
437 throw new Exception('Invalid date format'); 463 throw new Exception('Invalid date format');
@@ -460,9 +486,9 @@ class BookmarkFilter
460 * @param string $tags string containing a list of tags. 486 * @param string $tags string containing a list of tags.
461 * @param bool $casesensitive will convert everything to lowercase if false. 487 * @param bool $casesensitive will convert everything to lowercase if false.
462 * 488 *
463 * @return array filtered tags string. 489 * @return string[] filtered tags string.
464 */ 490 */
465 public static function tagsStrToArray($tags, $casesensitive) 491 public static function tagsStrToArray(string $tags, bool $casesensitive): array
466 { 492 {
467 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) 493 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
468 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); 494 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
@@ -470,4 +496,75 @@ class BookmarkFilter
470 496
471 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); 497 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
472 } 498 }
499
500 /**
501 * This method finalize the content of the foundPositions array,
502 * by associated all search results to their associated bookmark field,
503 * making sure that there is no overlapping results, etc.
504 *
505 * @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content.
506 * @param array $foundPositions Positions where the search results were found in the aggregated content.
507 *
508 * @return array Updated $foundPositions, by bookmark field.
509 */
510 protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
511 {
512 // Sort results by starting position ASC.
513 usort($foundPositions, function (array $entryA, array $entryB): int {
514 return $entryA['start'] > $entryB['start'] ? 1 : -1;
515 });
516
517 $out = [];
518 $currentMax = -1;
519 foreach ($foundPositions as $foundPosition) {
520 // we do not allow overlapping highlights
521 if ($foundPosition['start'] < $currentMax) {
522 continue;
523 }
524
525 $currentMax = $foundPosition['end'];
526 foreach ($fieldLengths as $part => $length) {
527 if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
528 continue;
529 }
530
531 $out[$part][] = [
532 'start' => $foundPosition['start'] - $length['start'],
533 'end' => $foundPosition['end'] - $length['start'],
534 ];
535 break;
536 }
537 }
538
539 return $out;
540 }
541
542 /**
543 * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
544 * Also populate $length array with starting and ending positions of every bookmark field
545 * inside concatenated content.
546 *
547 * @param Bookmark $link
548 * @param array $lengths (by reference)
549 *
550 * @return string Lowercase concatenated fields content.
551 */
552 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
553 {
554 $tagString = $link->getTagsString($this->conf->get('general.tags_separator', ' '));
555 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') . '\\';
556 $content .= mb_convert_case($link->getDescription(), 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') . '\\';
559
560 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
561 $nextField = $lengths['title']['end'] + 1;
562 $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
563 $nextField = $lengths['description']['end'] + 1;
564 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
565 $nextField = $lengths['url']['end'] + 1;
566 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($tagString)];
567
568 return $content;
569 }
473} 570}
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
index 6bf7f365..c78dbe41 100644
--- a/application/bookmark/BookmarkIO.php
+++ b/application/bookmark/BookmarkIO.php
@@ -1,7 +1,11 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
7use malkusch\lock\mutex\Mutex;
8use malkusch\lock\mutex\NoMutex;
5use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; 9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
6use Shaarli\Bookmark\Exception\EmptyDataStoreException; 10use Shaarli\Bookmark\Exception\EmptyDataStoreException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException; 11use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
@@ -27,11 +31,14 @@ class BookmarkIO
27 */ 31 */
28 protected $conf; 32 protected $conf;
29 33
34
35 /** @var Mutex */
36 protected $mutex;
37
30 /** 38 /**
31 * string Datastore PHP prefix 39 * string Datastore PHP prefix
32 */ 40 */
33 protected static $phpPrefix = '<?php /* '; 41 protected static $phpPrefix = '<?php /* ';
34
35 /** 42 /**
36 * string Datastore PHP suffix 43 * string Datastore PHP suffix
37 */ 44 */
@@ -42,16 +49,21 @@ class BookmarkIO
42 * 49 *
43 * @param ConfigManager $conf instance 50 * @param ConfigManager $conf instance
44 */ 51 */
45 public function __construct($conf) 52 public function __construct(ConfigManager $conf, Mutex $mutex = null)
46 { 53 {
54 if ($mutex === null) {
55 // This should only happen with legacy classes
56 $mutex = new NoMutex();
57 }
47 $this->conf = $conf; 58 $this->conf = $conf;
48 $this->datastore = $conf->get('resource.datastore'); 59 $this->datastore = $conf->get('resource.datastore');
60 $this->mutex = $mutex;
49 } 61 }
50 62
51 /** 63 /**
52 * Reads database from disk to memory 64 * Reads database from disk to memory
53 * 65 *
54 * @return BookmarkArray instance 66 * @return Bookmark[]
55 * 67 *
56 * @throws NotWritableDataStoreException Data couldn't be loaded 68 * @throws NotWritableDataStoreException Data couldn't be loaded
57 * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark 69 * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
@@ -67,11 +79,16 @@ class BookmarkIO
67 throw new NotWritableDataStoreException($this->datastore); 79 throw new NotWritableDataStoreException($this->datastore);
68 } 80 }
69 81
82 $content = null;
83 $this->mutex->synchronized(function () use (&$content) {
84 $content = file_get_contents($this->datastore);
85 });
86
70 // Note that gzinflate is faster than gzuncompress. 87 // Note that gzinflate is faster than gzuncompress.
71 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 88 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
72 $links = unserialize(gzinflate(base64_decode( 89 $links = unserialize(gzinflate(base64_decode(
73 substr(file_get_contents($this->datastore), 90 substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
74 strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); 91 )));
75 92
76 if (empty($links)) { 93 if (empty($links)) {
77 if (filesize($this->datastore) > 100) { 94 if (filesize($this->datastore) > 100) {
@@ -86,7 +103,7 @@ class BookmarkIO
86 /** 103 /**
87 * Saves the database from memory to disk 104 * Saves the database from memory to disk
88 * 105 *
89 * @param BookmarkArray $links instance. 106 * @param Bookmark[] $links
90 * 107 *
91 * @throws NotWritableDataStoreException the datastore is not writable 108 * @throws NotWritableDataStoreException the datastore is not writable
92 */ 109 */
@@ -95,14 +112,18 @@ class BookmarkIO
95 if (is_file($this->datastore) && !is_writeable($this->datastore)) { 112 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
96 // The datastore exists but is not writeable 113 // The datastore exists but is not writeable
97 throw new NotWritableDataStoreException($this->datastore); 114 throw new NotWritableDataStoreException($this->datastore);
98 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { 115 } elseif (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
99 // The datastore does not exist and its parent directory is not writeable 116 // The datastore does not exist and its parent directory is not writeable
100 throw new NotWritableDataStoreException(dirname($this->datastore)); 117 throw new NotWritableDataStoreException(dirname($this->datastore));
101 } 118 }
102 119
103 file_put_contents( 120 $data = self::$phpPrefix . base64_encode(gzdeflate(serialize($links))) . self::$phpSuffix;
104 $this->datastore, 121
105 self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix 122 $this->mutex->synchronized(function () use ($data) {
106 ); 123 file_put_contents(
124 $this->datastore,
125 $data
126 );
127 });
107 } 128 }
108} 129}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
index 815047e3..8ab5c441 100644
--- a/application/bookmark/BookmarkInitializer.php
+++ b/application/bookmark/BookmarkInitializer.php
@@ -1,5 +1,7 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5/** 7/**
@@ -11,6 +13,9 @@ namespace Shaarli\Bookmark;
11 * To prevent data corruption, it does not overwrite existing bookmarks, 13 * To prevent data corruption, it does not overwrite existing bookmarks,
12 * even though there should not be any. 14 * even though there should not be any.
13 * 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 *
14 * @package Shaarli\Bookmark 19 * @package Shaarli\Bookmark
15 */ 20 */
16class BookmarkInitializer 21class BookmarkInitializer
@@ -23,7 +28,7 @@ class BookmarkInitializer
23 * 28 *
24 * @param BookmarkServiceInterface $bookmarkService 29 * @param BookmarkServiceInterface $bookmarkService
25 */ 30 */
26 public function __construct($bookmarkService) 31 public function __construct(BookmarkServiceInterface $bookmarkService)
27 { 32 {
28 $this->bookmarkService = $bookmarkService; 33 $this->bookmarkService = $bookmarkService;
29 } 34 }
@@ -31,13 +36,13 @@ class BookmarkInitializer
31 /** 36 /**
32 * Initialize the data store with default bookmarks 37 * Initialize the data store with default bookmarks
33 */ 38 */
34 public function initialize() 39 public function initialize(): void
35 { 40 {
36 $bookmark = new Bookmark(); 41 $bookmark = new Bookmark();
37 $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)'));
38 $bookmark->setUrl('https://vimeo.com/153493904'); 43 $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c');
39 $bookmark->setDescription(t( 44 $bookmark->setDescription(t(
40'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.
41 46
42Explore your new Shaarli instance by trying out controls and menus. 47Explore your new Shaarli instance by trying out controls and menus.
43Visit 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.
@@ -52,7 +57,7 @@ Now you can edit or delete the default shaares.
52 $bookmark = new Bookmark(); 57 $bookmark = new Bookmark();
53 $bookmark->setTitle(t('Note: Shaare descriptions')); 58 $bookmark->setTitle(t('Note: Shaare descriptions'));
54 $bookmark->setDescription(t( 59 $bookmark->setDescription(t(
55'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.
56This 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.
57 62
58You 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.
@@ -89,7 +94,7 @@ Markdown also supports tables:
89 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') 94 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
90 ); 95 );
91 $bookmark->setDescription(t( 96 $bookmark->setDescription(t(
92'Welcome to Shaarli! 97 'Welcome to Shaarli!
93 98
94Shaarli 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.
95You 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 b9b483eb..08cdbb4e 100644
--- a/application/bookmark/BookmarkServiceInterface.php
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -1,79 +1,73 @@
1<?php 1<?php
2 2
3namespace Shaarli\Bookmark; 3declare(strict_types=1);
4 4
5namespace Shaarli\Bookmark;
5 6
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException; 8use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9use Shaarli\History;
10 9
11/** 10/**
12 * Class BookmarksService 11 * Class BookmarksService
13 * 12 *
14 * This is the entry point to manipulate the bookmark DB. 13 * This is the entry point to manipulate the bookmark DB.
14 *
15 * Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation,
16 * so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added.
15 */ 17 */
16interface BookmarkServiceInterface 18interface BookmarkServiceInterface
17{ 19{
18 /** 20 /**
19 * BookmarksService constructor.
20 *
21 * @param ConfigManager $conf instance
22 * @param History $history instance
23 * @param bool $isLoggedIn true if the current user is logged in
24 */
25 public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
26
27 /**
28 * Find a bookmark by hash 21 * Find a bookmark by hash
29 * 22 *
30 * @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
31 * 25 *
32 * @return mixed 26 * @return Bookmark
33 * 27 *
34 * @throws \Exception 28 * @throws \Exception
35 */ 29 */
36 public function findByHash($hash); 30 public function findByHash(string $hash, string $privateKey = null);
37 31
38 /** 32 /**
39 * @param $url 33 * @param $url
40 * 34 *
41 * @return Bookmark|null 35 * @return Bookmark|null
42 */ 36 */
43 public function findByUrl($url); 37 public function findByUrl(string $url): ?Bookmark;
44 38
45 /** 39 /**
46 * Search bookmarks 40 * Search bookmarks
47 * 41 *
48 * @param mixed $request 42 * @param array $request
49 * @param string $visibility 43 * @param ?string $visibility
50 * @param bool $caseSensitive 44 * @param bool $caseSensitive
51 * @param bool $untaggedOnly 45 * @param bool $untaggedOnly
52 * @param bool $ignoreSticky 46 * @param bool $ignoreSticky
53 * 47 *
54 * @return Bookmark[] 48 * @return Bookmark[]
55 */ 49 */
56 public function search( 50 public function search(
57 $request = [], 51 array $request = [],
58 $visibility = null, 52 string $visibility = null,
59 $caseSensitive = false, 53 bool $caseSensitive = false,
60 $untaggedOnly = false, 54 bool $untaggedOnly = false,
61 bool $ignoreSticky = false 55 bool $ignoreSticky = false
62 ); 56 );
63 57
64 /** 58 /**
65 * Get a single bookmark by its ID. 59 * Get a single bookmark by its ID.
66 * 60 *
67 * @param int $id Bookmark ID 61 * @param int $id Bookmark ID
68 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an 62 * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
69 * exception 63 * exception
70 * 64 *
71 * @return Bookmark 65 * @return Bookmark
72 * 66 *
73 * @throws BookmarkNotFoundException 67 * @throws BookmarkNotFoundException
74 * @throws \Exception 68 * @throws \Exception
75 */ 69 */
76 public function get($id, $visibility = null); 70 public function get(int $id, string $visibility = null);
77 71
78 /** 72 /**
79 * Updates an existing bookmark (depending on its ID). 73 * Updates an existing bookmark (depending on its ID).
@@ -86,7 +80,7 @@ interface BookmarkServiceInterface
86 * @throws BookmarkNotFoundException 80 * @throws BookmarkNotFoundException
87 * @throws \Exception 81 * @throws \Exception
88 */ 82 */
89 public function set($bookmark, $save = true); 83 public function set(Bookmark $bookmark, bool $save = true): Bookmark;
90 84
91 /** 85 /**
92 * Adds a new bookmark (the ID must be empty). 86 * Adds a new bookmark (the ID must be empty).
@@ -98,7 +92,7 @@ interface BookmarkServiceInterface
98 * 92 *
99 * @throws \Exception 93 * @throws \Exception
100 */ 94 */
101 public function add($bookmark, $save = true); 95 public function add(Bookmark $bookmark, bool $save = true): Bookmark;
102 96
103 /** 97 /**
104 * Adds or updates a bookmark depending on its ID: 98 * Adds or updates a bookmark depending on its ID:
@@ -112,7 +106,7 @@ interface BookmarkServiceInterface
112 * 106 *
113 * @throws \Exception 107 * @throws \Exception
114 */ 108 */
115 public function addOrSet($bookmark, $save = true); 109 public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
116 110
117 /** 111 /**
118 * Deletes a bookmark. 112 * Deletes a bookmark.
@@ -122,65 +116,72 @@ interface BookmarkServiceInterface
122 * 116 *
123 * @throws \Exception 117 * @throws \Exception
124 */ 118 */
125 public function remove($bookmark, $save = true); 119 public function remove(Bookmark $bookmark, bool $save = true): void;
126 120
127 /** 121 /**
128 * Get a single bookmark by its ID. 122 * Get a single bookmark by its ID.
129 * 123 *
130 * @param int $id Bookmark ID 124 * @param int $id Bookmark ID
131 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an 125 * @param ?string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
132 * exception 126 * exception
133 * 127 *
134 * @return bool 128 * @return bool
135 */ 129 */
136 public function exists($id, $visibility = null); 130 public function exists(int $id, string $visibility = null): bool;
137 131
138 /** 132 /**
139 * Return the number of available bookmarks for given visibility. 133 * Return the number of available bookmarks for given visibility.
140 * 134 *
141 * @param string $visibility public|private|all 135 * @param ?string $visibility public|private|all
142 * 136 *
143 * @return int Number of bookmarks 137 * @return int Number of bookmarks
144 */ 138 */
145 public function count($visibility = null); 139 public function count(string $visibility = null): int;
146 140
147 /** 141 /**
148 * Write the datastore. 142 * Write the datastore.
149 * 143 *
150 * @throws NotWritableDataStoreException 144 * @throws NotWritableDataStoreException
151 */ 145 */
152 public function save(); 146 public function save(): void;
153 147
154 /** 148 /**
155 * Returns the list tags appearing in the bookmarks with the given tags 149 * Returns the list tags appearing in the bookmarks with the given tags
156 * 150 *
157 * @param array $filteringTags tags selecting the bookmarks to consider 151 * @param array|null $filteringTags tags selecting the bookmarks to consider
158 * @param string $visibility process only all/private/public bookmarks 152 * @param string|null $visibility process only all/private/public bookmarks
159 * 153 *
160 * @return array tag => bookmarksCount 154 * @return array tag => bookmarksCount
161 */ 155 */
162 public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); 156 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
163 157
164 /** 158 /**
165 * 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.
161 *
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 * 166 *
167 * @return array containing days (in format YYYYMMDD). 167 * @return array List of bookmarks matching provided period of time.
168 */ 168 */
169 public function days(); 169 public function findByDate(
170 \DateTimeInterface $from,
171 \DateTimeInterface $to,
172 ?\DateTimeInterface &$previous,
173 ?\DateTimeInterface &$next
174 ): array;
170 175
171 /** 176 /**
172 * Returns the list of articles for a given day. 177 * Returns the latest bookmark by creation date.
173 * 178 *
174 * @param string $request day to filter. Format: YYYYMMDD. 179 * @return Bookmark|null Found Bookmark or null if the datastore is empty.
175 *
176 * @return Bookmark[] list of shaare found.
177 *
178 * @throws BookmarkNotFoundException
179 */ 180 */
180 public function filterDay($request); 181 public function getLatest(): ?Bookmark;
181 182
182 /** 183 /**
183 * Creates the default database after a fresh install. 184 * Creates the default database after a fresh install.
184 */ 185 */
185 public function initialize(); 186 public function initialize(): void;
186} 187}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index e7af4d55..d65e97ed 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -66,16 +66,19 @@ function html_extract_tag($tag, $html)
66{ 66{
67 $propertiesKey = ['property', 'name', 'itemprop']; 67 $propertiesKey = ['property', 'name', 'itemprop'];
68 $properties = implode('|', $propertiesKey); 68 $properties = implode('|', $propertiesKey);
69 // Try to retrieve OpenGraph image. 69 // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"'
70 $ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#'; 70 $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
71 // Try to retrieve OpenGraph tag.
72 $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*content=(["\'])([^\1]*?)\1.*?>#';
71 // If the attributes are not in the order property => content (e.g. Github) 73 // If the attributes are not in the order property => content (e.g. Github)
72 // New regex to keep this readable... more or less. 74 // New regex to keep this readable... more or less.
73 $ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#'; 75 $ogRegexReverse = '#<meta[^>]+content=(["\'])([^\1]*?)\1[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#';
74 76
75 if (preg_match($ogRegex, $html, $matches) > 0 77 if (
78 preg_match($ogRegex, $html, $matches) > 0
76 || preg_match($ogRegexReverse, $html, $matches) > 0 79 || preg_match($ogRegexReverse, $html, $matches) > 0
77 ) { 80 ) {
78 return $matches[1]; 81 return $matches[2];
79 } 82 }
80 83
81 return false; 84 return false;
@@ -114,7 +117,7 @@ function hashtag_autolink($description, $indexUrl = '')
114 * \p{Mn} - any non marking space (accents, umlauts, etc) 117 * \p{Mn} - any non marking space (accents, umlauts, etc)
115 */ 118 */
116 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 119 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
117 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>'; 120 $replacement = '$1<a href="' . $indexUrl . './add-tag/$2" title="Hashtag $2">#$2</a>';
118 return preg_replace($regex, $replacement, $description); 121 return preg_replace($regex, $replacement, $description);
119} 122}
120 123
@@ -136,12 +139,17 @@ function space2nbsp($text)
136 * 139 *
137 * @param string $description shaare's description. 140 * @param string $description shaare's description.
138 * @param string $indexUrl URL to Shaarli's index. 141 * @param string $indexUrl URL to Shaarli's index.
139 142 * @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags
143 *
140 * @return string formatted description. 144 * @return string formatted description.
141 */ 145 */
142function format_description($description, $indexUrl = '') 146function format_description($description, $indexUrl = '', $autolink = true)
143{ 147{
144 return nl2br(space2nbsp(hashtag_autolink(text2clickable($description), $indexUrl))); 148 if ($autolink) {
149 $description = hashtag_autolink(text2clickable($description), $indexUrl);
150 }
151
152 return nl2br(space2nbsp($description));
145} 153}
146 154
147/** 155/**
@@ -169,3 +177,49 @@ function is_note($linkUrl)
169{ 177{
170 return isset($linkUrl[0]) && $linkUrl[0] === '?'; 178 return isset($linkUrl[0]) && $linkUrl[0] === '?';
171} 179}
180
181/**
182 * Extract an array of tags from a given tag string, with provided separator.
183 *
184 * @param string|null $tags String containing a list of tags separated by $separator.
185 * @param string $separator Shaarli's default: ' ' (whitespace)
186 *
187 * @return array List of tags
188 */
189function tags_str2array(?string $tags, string $separator): array
190{
191 // For whitespaces, we use the special \s regex character
192 $separator = $separator === ' ' ? '\s' : $separator;
193
194 return preg_split('/\s*' . $separator . '+\s*/', trim($tags) ?? '', -1, PREG_SPLIT_NO_EMPTY);
195}
196
197/**
198 * Return a tag string with provided separator from a list of tags.
199 * Note that given array is clean up by tags_filter().
200 *
201 * @param array|null $tags List of tags
202 * @param string $separator
203 *
204 * @return string
205 */
206function tags_array2str(?array $tags, string $separator): string
207{
208 return implode($separator, tags_filter($tags, $separator));
209}
210
211/**
212 * Clean an array of tags: trim + remove empty entries
213 *
214 * @param array|null $tags List of tags
215 * @param string $separator
216 *
217 * @return array
218 */
219function tags_filter(?array $tags, string $separator): array
220{
221 $trimDefault = " \t\n\r\0\x0B";
222 return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string {
223 return trim($entry, $trimDefault . $separator);
224 }, $tags ?? [])));
225}
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}