aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/bookmark
diff options
context:
space:
mode:
Diffstat (limited to 'application/bookmark')
-rw-r--r--application/bookmark/Bookmark.php202
-rw-r--r--application/bookmark/BookmarkArray.php23
-rw-r--r--application/bookmark/BookmarkFileService.php157
-rw-r--r--application/bookmark/BookmarkFilter.php175
-rw-r--r--application/bookmark/BookmarkIO.php49
-rw-r--r--application/bookmark/BookmarkInitializer.php87
-rw-r--r--application/bookmark/BookmarkServiceInterface.php109
-rw-r--r--application/bookmark/LinkUtils.php116
-rw-r--r--application/bookmark/exception/DatastoreNotInitializedException.php10
9 files changed, 557 insertions, 371 deletions
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
index f9b21d3d..4810c5e6 100644
--- a/application/bookmark/Bookmark.php
+++ b/application/bookmark/Bookmark.php
@@ -1,8 +1,11 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5use DateTime; 7use DateTime;
8use DateTimeInterface;
6use Shaarli\Bookmark\Exception\InvalidBookmarkException; 9use Shaarli\Bookmark\Exception\InvalidBookmarkException;
7 10
8/** 11/**
@@ -36,21 +39,24 @@ class Bookmark
36 /** @var array List of bookmark's tags */ 39 /** @var array List of bookmark's tags */
37 protected $tags; 40 protected $tags;
38 41
39 /** @var string Thumbnail's URL - false if no thumbnail could be found */ 42 /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
40 protected $thumbnail; 43 protected $thumbnail;
41 44
42 /** @var bool Set to true if the bookmark is set as sticky */ 45 /** @var bool Set to true if the bookmark is set as sticky */
43 protected $sticky; 46 protected $sticky;
44 47
45 /** @var DateTime Creation datetime */ 48 /** @var DateTimeInterface Creation datetime */
46 protected $created; 49 protected $created;
47 50
48 /** @var DateTime Update datetime */ 51 /** @var DateTimeInterface datetime */
49 protected $updated; 52 protected $updated;
50 53
51 /** @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 */
52 protected $private; 55 protected $private;
53 56
57 /** @var mixed[] Available to store any additional content for a bookmark. Currently used for search highlight. */
58 protected $additionalContent = [];
59
54 /** 60 /**
55 * 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.
56 * 62 *
@@ -58,25 +64,25 @@ class Bookmark
58 * 64 *
59 * @return $this 65 * @return $this
60 */ 66 */
61 public function fromArray($data) 67 public function fromArray(array $data): Bookmark
62 { 68 {
63 $this->id = $data['id']; 69 $this->id = $data['id'] ?? null;
64 $this->shortUrl = $data['shorturl']; 70 $this->shortUrl = $data['shorturl'] ?? null;
65 $this->url = $data['url']; 71 $this->url = $data['url'] ?? null;
66 $this->title = $data['title']; 72 $this->title = $data['title'] ?? null;
67 $this->description = $data['description']; 73 $this->description = $data['description'] ?? null;
68 $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; 74 $this->thumbnail = $data['thumbnail'] ?? null;
69 $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; 75 $this->sticky = $data['sticky'] ?? false;
70 $this->created = $data['created']; 76 $this->created = $data['created'] ?? null;
71 if (is_array($data['tags'])) { 77 if (is_array($data['tags'])) {
72 $this->tags = $data['tags']; 78 $this->tags = $data['tags'];
73 } else { 79 } else {
74 $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); 80 $this->tags = preg_split('/\s+/', $data['tags'] ?? '', -1, PREG_SPLIT_NO_EMPTY);
75 } 81 }
76 if (! empty($data['updated'])) { 82 if (! empty($data['updated'])) {
77 $this->updated = $data['updated']; 83 $this->updated = $data['updated'];
78 } 84 }
79 $this->private = $data['private'] ? true : false; 85 $this->private = ($data['private'] ?? false) ? true : false;
80 86
81 return $this; 87 return $this;
82 } 88 }
@@ -92,24 +98,28 @@ class Bookmark
92 * - the URL with the permalink 98 * - the URL with the permalink
93 * - the title with the URL 99 * - the title with the URL
94 * 100 *
101 * Also make sure that we do not save search highlights in the datastore.
102 *
95 * @throws InvalidBookmarkException 103 * @throws InvalidBookmarkException
96 */ 104 */
97 public function validate() 105 public function validate(): void
98 { 106 {
99 if ($this->id === null 107 if ($this->id === null
100 || ! is_int($this->id) 108 || ! is_int($this->id)
101 || empty($this->shortUrl) 109 || empty($this->shortUrl)
102 || empty($this->created) 110 || empty($this->created)
103 || ! $this->created instanceof DateTime
104 ) { 111 ) {
105 throw new InvalidBookmarkException($this); 112 throw new InvalidBookmarkException($this);
106 } 113 }
107 if (empty($this->url)) { 114 if (empty($this->url)) {
108 $this->url = '?'. $this->shortUrl; 115 $this->url = '/shaare/'. $this->shortUrl;
109 } 116 }
110 if (empty($this->title)) { 117 if (empty($this->title)) {
111 $this->title = $this->url; 118 $this->title = $this->url;
112 } 119 }
120 if (array_key_exists('search_highlight', $this->additionalContent)) {
121 unset($this->additionalContent['search_highlight']);
122 }
113 } 123 }
114 124
115 /** 125 /**
@@ -118,11 +128,11 @@ class Bookmark
118 * - created: with the current datetime 128 * - created: with the current datetime
119 * - shortUrl: with a generated small hash from the date and the given ID 129 * - shortUrl: with a generated small hash from the date and the given ID
120 * 130 *
121 * @param int $id 131 * @param int|null $id
122 * 132 *
123 * @return Bookmark 133 * @return Bookmark
124 */ 134 */
125 public function setId($id) 135 public function setId(?int $id): Bookmark
126 { 136 {
127 $this->id = $id; 137 $this->id = $id;
128 if (empty($this->created)) { 138 if (empty($this->created)) {
@@ -138,9 +148,9 @@ class Bookmark
138 /** 148 /**
139 * Get the Id. 149 * Get the Id.
140 * 150 *
141 * @return int 151 * @return int|null
142 */ 152 */
143 public function getId() 153 public function getId(): ?int
144 { 154 {
145 return $this->id; 155 return $this->id;
146 } 156 }
@@ -148,9 +158,9 @@ class Bookmark
148 /** 158 /**
149 * Get the ShortUrl. 159 * Get the ShortUrl.
150 * 160 *
151 * @return string 161 * @return string|null
152 */ 162 */
153 public function getShortUrl() 163 public function getShortUrl(): ?string
154 { 164 {
155 return $this->shortUrl; 165 return $this->shortUrl;
156 } 166 }
@@ -158,9 +168,9 @@ class Bookmark
158 /** 168 /**
159 * Get the Url. 169 * Get the Url.
160 * 170 *
161 * @return string 171 * @return string|null
162 */ 172 */
163 public function getUrl() 173 public function getUrl(): ?string
164 { 174 {
165 return $this->url; 175 return $this->url;
166 } 176 }
@@ -170,7 +180,7 @@ class Bookmark
170 * 180 *
171 * @return string 181 * @return string
172 */ 182 */
173 public function getTitle() 183 public function getTitle(): ?string
174 { 184 {
175 return $this->title; 185 return $this->title;
176 } 186 }
@@ -180,7 +190,7 @@ class Bookmark
180 * 190 *
181 * @return string 191 * @return string
182 */ 192 */
183 public function getDescription() 193 public function getDescription(): string
184 { 194 {
185 return ! empty($this->description) ? $this->description : ''; 195 return ! empty($this->description) ? $this->description : '';
186 } 196 }
@@ -188,9 +198,9 @@ class Bookmark
188 /** 198 /**
189 * Get the Created. 199 * Get the Created.
190 * 200 *
191 * @return DateTime 201 * @return DateTimeInterface
192 */ 202 */
193 public function getCreated() 203 public function getCreated(): ?DateTimeInterface
194 { 204 {
195 return $this->created; 205 return $this->created;
196 } 206 }
@@ -198,9 +208,9 @@ class Bookmark
198 /** 208 /**
199 * Get the Updated. 209 * Get the Updated.
200 * 210 *
201 * @return DateTime 211 * @return DateTimeInterface
202 */ 212 */
203 public function getUpdated() 213 public function getUpdated(): ?DateTimeInterface
204 { 214 {
205 return $this->updated; 215 return $this->updated;
206 } 216 }
@@ -208,11 +218,11 @@ class Bookmark
208 /** 218 /**
209 * Set the ShortUrl. 219 * Set the ShortUrl.
210 * 220 *
211 * @param string $shortUrl 221 * @param string|null $shortUrl
212 * 222 *
213 * @return Bookmark 223 * @return Bookmark
214 */ 224 */
215 public function setShortUrl($shortUrl) 225 public function setShortUrl(?string $shortUrl): Bookmark
216 { 226 {
217 $this->shortUrl = $shortUrl; 227 $this->shortUrl = $shortUrl;
218 228
@@ -222,14 +232,14 @@ class Bookmark
222 /** 232 /**
223 * Set the Url. 233 * Set the Url.
224 * 234 *
225 * @param string $url 235 * @param string|null $url
226 * @param array $allowedProtocols 236 * @param string[] $allowedProtocols
227 * 237 *
228 * @return Bookmark 238 * @return Bookmark
229 */ 239 */
230 public function setUrl($url, $allowedProtocols = []) 240 public function setUrl(?string $url, array $allowedProtocols = []): Bookmark
231 { 241 {
232 $url = trim($url); 242 $url = $url !== null ? trim($url) : '';
233 if (! empty($url)) { 243 if (! empty($url)) {
234 $url = whitelist_protocols($url, $allowedProtocols); 244 $url = whitelist_protocols($url, $allowedProtocols);
235 } 245 }
@@ -241,13 +251,13 @@ class Bookmark
241 /** 251 /**
242 * Set the Title. 252 * Set the Title.
243 * 253 *
244 * @param string $title 254 * @param string|null $title
245 * 255 *
246 * @return Bookmark 256 * @return Bookmark
247 */ 257 */
248 public function setTitle($title) 258 public function setTitle(?string $title): Bookmark
249 { 259 {
250 $this->title = trim($title); 260 $this->title = $title !== null ? trim($title) : '';
251 261
252 return $this; 262 return $this;
253 } 263 }
@@ -255,11 +265,11 @@ class Bookmark
255 /** 265 /**
256 * Set the Description. 266 * Set the Description.
257 * 267 *
258 * @param string $description 268 * @param string|null $description
259 * 269 *
260 * @return Bookmark 270 * @return Bookmark
261 */ 271 */
262 public function setDescription($description) 272 public function setDescription(?string $description): Bookmark
263 { 273 {
264 $this->description = $description; 274 $this->description = $description;
265 275
@@ -270,11 +280,11 @@ class Bookmark
270 * Set the Created. 280 * Set the Created.
271 * Note: you shouldn't set this manually except for special cases (like bookmark import) 281 * Note: you shouldn't set this manually except for special cases (like bookmark import)
272 * 282 *
273 * @param DateTime $created 283 * @param DateTimeInterface|null $created
274 * 284 *
275 * @return Bookmark 285 * @return Bookmark
276 */ 286 */
277 public function setCreated($created) 287 public function setCreated(?DateTimeInterface $created): Bookmark
278 { 288 {
279 $this->created = $created; 289 $this->created = $created;
280 290
@@ -284,11 +294,11 @@ class Bookmark
284 /** 294 /**
285 * Set the Updated. 295 * Set the Updated.
286 * 296 *
287 * @param DateTime $updated 297 * @param DateTimeInterface|null $updated
288 * 298 *
289 * @return Bookmark 299 * @return Bookmark
290 */ 300 */
291 public function setUpdated($updated) 301 public function setUpdated(?DateTimeInterface $updated): Bookmark
292 { 302 {
293 $this->updated = $updated; 303 $this->updated = $updated;
294 304
@@ -300,7 +310,7 @@ class Bookmark
300 * 310 *
301 * @return bool 311 * @return bool
302 */ 312 */
303 public function isPrivate() 313 public function isPrivate(): bool
304 { 314 {
305 return $this->private ? true : false; 315 return $this->private ? true : false;
306 } 316 }
@@ -308,11 +318,11 @@ class Bookmark
308 /** 318 /**
309 * Set the Private. 319 * Set the Private.
310 * 320 *
311 * @param bool $private 321 * @param bool|null $private
312 * 322 *
313 * @return Bookmark 323 * @return Bookmark
314 */ 324 */
315 public function setPrivate($private) 325 public function setPrivate(?bool $private): Bookmark
316 { 326 {
317 $this->private = $private ? true : false; 327 $this->private = $private ? true : false;
318 328
@@ -322,9 +332,9 @@ class Bookmark
322 /** 332 /**
323 * Get the Tags. 333 * Get the Tags.
324 * 334 *
325 * @return array 335 * @return string[]
326 */ 336 */
327 public function getTags() 337 public function getTags(): array
328 { 338 {
329 return is_array($this->tags) ? $this->tags : []; 339 return is_array($this->tags) ? $this->tags : [];
330 } 340 }
@@ -332,13 +342,13 @@ class Bookmark
332 /** 342 /**
333 * Set the Tags. 343 * Set the Tags.
334 * 344 *
335 * @param array $tags 345 * @param string[]|null $tags
336 * 346 *
337 * @return Bookmark 347 * @return Bookmark
338 */ 348 */
339 public function setTags($tags) 349 public function setTags(?array $tags): Bookmark
340 { 350 {
341 $this->setTagsString(implode(' ', $tags)); 351 $this->setTagsString(implode(' ', $tags ?? []));
342 352
343 return $this; 353 return $this;
344 } 354 }
@@ -346,7 +356,7 @@ class Bookmark
346 /** 356 /**
347 * Get the Thumbnail. 357 * Get the Thumbnail.
348 * 358 *
349 * @return string|bool 359 * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
350 */ 360 */
351 public function getThumbnail() 361 public function getThumbnail()
352 { 362 {
@@ -356,11 +366,11 @@ class Bookmark
356 /** 366 /**
357 * Set the Thumbnail. 367 * Set the Thumbnail.
358 * 368 *
359 * @param string|bool $thumbnail 369 * @param string|bool|null $thumbnail Thumbnail's URL - false if no thumbnail could be found
360 * 370 *
361 * @return Bookmark 371 * @return Bookmark
362 */ 372 */
363 public function setThumbnail($thumbnail) 373 public function setThumbnail($thumbnail): Bookmark
364 { 374 {
365 $this->thumbnail = $thumbnail; 375 $this->thumbnail = $thumbnail;
366 376
@@ -368,11 +378,29 @@ class Bookmark
368 } 378 }
369 379
370 /** 380 /**
381 * Return true if:
382 * - the bookmark's thumbnail is not already set to false (= not found)
383 * - it's not a note
384 * - it's an HTTP(S) link
385 * - the thumbnail has not yet be retrieved (null) or its associated cache file doesn't exist anymore
386 *
387 * @return bool True if the bookmark's thumbnail needs to be retrieved.
388 */
389 public function shouldUpdateThumbnail(): bool
390 {
391 return $this->thumbnail !== false
392 && !$this->isNote()
393 && startsWith(strtolower($this->url), 'http')
394 && (null === $this->thumbnail || !is_file($this->thumbnail))
395 ;
396 }
397
398 /**
371 * Get the Sticky. 399 * Get the Sticky.
372 * 400 *
373 * @return bool 401 * @return bool
374 */ 402 */
375 public function isSticky() 403 public function isSticky(): bool
376 { 404 {
377 return $this->sticky ? true : false; 405 return $this->sticky ? true : false;
378 } 406 }
@@ -380,11 +408,11 @@ class Bookmark
380 /** 408 /**
381 * Set the Sticky. 409 * Set the Sticky.
382 * 410 *
383 * @param bool $sticky 411 * @param bool|null $sticky
384 * 412 *
385 * @return Bookmark 413 * @return Bookmark
386 */ 414 */
387 public function setSticky($sticky) 415 public function setSticky(?bool $sticky): Bookmark
388 { 416 {
389 $this->sticky = $sticky ? true : false; 417 $this->sticky = $sticky ? true : false;
390 418
@@ -394,7 +422,7 @@ class Bookmark
394 /** 422 /**
395 * @return string Bookmark's tags as a string, separated by a space 423 * @return string Bookmark's tags as a string, separated by a space
396 */ 424 */
397 public function getTagsString() 425 public function getTagsString(): string
398 { 426 {
399 return implode(' ', $this->getTags()); 427 return implode(' ', $this->getTags());
400 } 428 }
@@ -402,10 +430,10 @@ class Bookmark
402 /** 430 /**
403 * @return bool 431 * @return bool
404 */ 432 */
405 public function isNote() 433 public function isNote(): bool
406 { 434 {
407 // We check empty value to get a valid result if the link has not been saved yet 435 // We check empty value to get a valid result if the link has not been saved yet
408 return empty($this->url) || $this->url[0] === '?'; 436 return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
409 } 437 }
410 438
411 /** 439 /**
@@ -415,14 +443,14 @@ class Bookmark
415 * - multiple spaces will be removed 443 * - multiple spaces will be removed
416 * - trailing dash in tags will be removed 444 * - trailing dash in tags will be removed
417 * 445 *
418 * @param string $tags 446 * @param string|null $tags
419 * 447 *
420 * @return $this 448 * @return $this
421 */ 449 */
422 public function setTagsString($tags) 450 public function setTagsString(?string $tags): Bookmark
423 { 451 {
424 // Remove first '-' char in tags. 452 // Remove first '-' char in tags.
425 $tags = preg_replace('/(^| )\-/', '$1', $tags); 453 $tags = preg_replace('/(^| )\-/', '$1', $tags ?? '');
426 // Explode all tags separted by spaces or commas 454 // Explode all tags separted by spaces or commas
427 $tags = preg_split('/[\s,]+/', $tags); 455 $tags = preg_split('/[\s,]+/', $tags);
428 // Remove eventual empty values 456 // Remove eventual empty values
@@ -434,12 +462,50 @@ class Bookmark
434 } 462 }
435 463
436 /** 464 /**
465 * Get entire additionalContent array.
466 *
467 * @return mixed[]
468 */
469 public function getAdditionalContent(): array
470 {
471 return $this->additionalContent;
472 }
473
474 /**
475 * Set a single entry in additionalContent, by key.
476 *
477 * @param string $key
478 * @param mixed|null $value Any type of value can be set.
479 *
480 * @return $this
481 */
482 public function addAdditionalContentEntry(string $key, $value): self
483 {
484 $this->additionalContent[$key] = $value;
485
486 return $this;
487 }
488
489 /**
490 * Get a single entry in additionalContent, by key.
491 *
492 * @param string $key
493 * @param mixed|null $default
494 *
495 * @return mixed|null can be any type or even null.
496 */
497 public function getAdditionalContentEntry(string $key, $default = null)
498 {
499 return array_key_exists($key, $this->additionalContent) ? $this->additionalContent[$key] : $default;
500 }
501
502 /**
437 * Rename a tag in tags list. 503 * Rename a tag in tags list.
438 * 504 *
439 * @param string $fromTag 505 * @param string $fromTag
440 * @param string $toTag 506 * @param string $toTag
441 */ 507 */
442 public function renameTag($fromTag, $toTag) 508 public function renameTag(string $fromTag, string $toTag): void
443 { 509 {
444 if (($pos = array_search($fromTag, $this->tags)) !== false) { 510 if (($pos = array_search($fromTag, $this->tags)) !== false) {
445 $this->tags[$pos] = trim($toTag); 511 $this->tags[$pos] = trim($toTag);
@@ -451,7 +517,7 @@ class Bookmark
451 * 517 *
452 * @param string $tag 518 * @param string $tag
453 */ 519 */
454 public function deleteTag($tag) 520 public function deleteTag(string $tag): void
455 { 521 {
456 if (($pos = array_search($tag, $this->tags)) !== false) { 522 if (($pos = array_search($tag, $this->tags)) !== false) {
457 unset($this->tags[$pos]); 523 unset($this->tags[$pos]);
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
index d87d43b4..67bb3b73 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;
@@ -187,13 +189,13 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
187 /** 189 /**
188 * Returns a bookmark offset in bookmarks array from its unique ID. 190 * Returns a bookmark offset in bookmarks array from its unique ID.
189 * 191 *
190 * @param int $id Persistent ID of a bookmark. 192 * @param int|null $id Persistent ID of a bookmark.
191 * 193 *
192 * @return int Real offset in local array, or null if doesn't exist. 194 * @return int Real offset in local array, or null if doesn't exist.
193 */ 195 */
194 protected function getBookmarkOffset($id) 196 protected function getBookmarkOffset(?int $id): ?int
195 { 197 {
196 if (isset($this->ids[$id])) { 198 if ($id !== null && isset($this->ids[$id])) {
197 return $this->ids[$id]; 199 return $this->ids[$id];
198 } 200 }
199 return null; 201 return null;
@@ -205,7 +207,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
205 * 207 *
206 * @return int next ID. 208 * @return int next ID.
207 */ 209 */
208 public function getNextId() 210 public function getNextId(): int
209 { 211 {
210 if (!empty($this->ids)) { 212 if (!empty($this->ids)) {
211 return max(array_keys($this->ids)) + 1; 213 return max(array_keys($this->ids)) + 1;
@@ -214,11 +216,11 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
214 } 216 }
215 217
216 /** 218 /**
217 * @param $url 219 * @param string $url
218 * 220 *
219 * @return Bookmark|null 221 * @return Bookmark|null
220 */ 222 */
221 public function getByUrl($url) 223 public function getByUrl(string $url): ?Bookmark
222 { 224 {
223 if (! empty($url) 225 if (! empty($url)
224 && isset($this->urls[$url]) 226 && isset($this->urls[$url])
@@ -234,16 +236,17 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
234 * 236 *
235 * Also update the urls and ids mapping arrays. 237 * Also update the urls and ids mapping arrays.
236 * 238 *
237 * @param string $order ASC|DESC 239 * @param string $order ASC|DESC
240 * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
238 */ 241 */
239 public function reorder($order = 'DESC') 242 public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
240 { 243 {
241 $order = $order === 'ASC' ? -1 : 1; 244 $order = $order === 'ASC' ? -1 : 1;
242 // Reorder array by dates. 245 // Reorder array by dates.
243 usort($this->bookmarks, function ($a, $b) use ($order) { 246 usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
244 /** @var $a Bookmark */ 247 /** @var $a Bookmark */
245 /** @var $b Bookmark */ 248 /** @var $b Bookmark */
246 if ($a->isSticky() !== $b->isSticky()) { 249 if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
247 return $a->isSticky() ? -1 : 1; 250 return $a->isSticky() ? -1 : 1;
248 } 251 }
249 return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; 252 return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index 9c59e139..3ea98a45 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -1,17 +1,21 @@
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;
11use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
9use Shaarli\Bookmark\Exception\EmptyDataStoreException; 12use Shaarli\Bookmark\Exception\EmptyDataStoreException;
10use Shaarli\Config\ConfigManager; 13use Shaarli\Config\ConfigManager;
11use Shaarli\Formatter\BookmarkMarkdownFormatter; 14use Shaarli\Formatter\BookmarkMarkdownFormatter;
12use Shaarli\History; 15use Shaarli\History;
13use Shaarli\Legacy\LegacyLinkDB; 16use Shaarli\Legacy\LegacyLinkDB;
14use Shaarli\Legacy\LegacyUpdater; 17use Shaarli\Legacy\LegacyUpdater;
18use Shaarli\Render\PageCacheManager;
15use Shaarli\Updater\UpdaterUtils; 19use Shaarli\Updater\UpdaterUtils;
16 20
17/** 21/**
@@ -39,17 +43,25 @@ class BookmarkFileService implements BookmarkServiceInterface
39 /** @var History instance */ 43 /** @var History instance */
40 protected $history; 44 protected $history;
41 45
46 /** @var PageCacheManager instance */
47 protected $pageCacheManager;
48
42 /** @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. */
43 protected $isLoggedIn; 50 protected $isLoggedIn;
44 51
52 /** @var Mutex */
53 protected $mutex;
54
45 /** 55 /**
46 * @inheritDoc 56 * @inheritDoc
47 */ 57 */
48 public function __construct(ConfigManager $conf, History $history, $isLoggedIn) 58 public function __construct(ConfigManager $conf, History $history, Mutex $mutex, bool $isLoggedIn)
49 { 59 {
50 $this->conf = $conf; 60 $this->conf = $conf;
51 $this->history = $history; 61 $this->history = $history;
52 $this->bookmarksIO = new BookmarkIO($this->conf); 62 $this->mutex = $mutex;
63 $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
64 $this->bookmarksIO = new BookmarkIO($this->conf, $this->mutex);
53 $this->isLoggedIn = $isLoggedIn; 65 $this->isLoggedIn = $isLoggedIn;
54 66
55 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { 67 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
@@ -57,10 +69,16 @@ class BookmarkFileService implements BookmarkServiceInterface
57 } else { 69 } else {
58 try { 70 try {
59 $this->bookmarks = $this->bookmarksIO->read(); 71 $this->bookmarks = $this->bookmarksIO->read();
60 } catch (EmptyDataStoreException $e) { 72 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
61 $this->bookmarks = new BookmarkArray(); 73 $this->bookmarks = new BookmarkArray();
62 if ($isLoggedIn) { 74
63 $this->save(); 75 if ($this->isLoggedIn) {
76 // Datastore file does not exists, we initialize it with default bookmarks.
77 if ($e instanceof DatastoreNotInitializedException) {
78 $this->initialize();
79 } else {
80 $this->save();
81 }
64 } 82 }
65 } 83 }
66 84
@@ -79,22 +97,25 @@ class BookmarkFileService implements BookmarkServiceInterface
79 /** 97 /**
80 * @inheritDoc 98 * @inheritDoc
81 */ 99 */
82 public function findByHash($hash) 100 public function findByHash(string $hash, string $privateKey = null): Bookmark
83 { 101 {
84 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); 102 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
85 // PHP 7.3 introduced array_key_first() to avoid this hack 103 // PHP 7.3 introduced array_key_first() to avoid this hack
86 $first = reset($bookmark); 104 $first = reset($bookmark);
87 if (! $this->isLoggedIn && $first->isPrivate()) { 105 if (!$this->isLoggedIn
88 throw new Exception('Not authorized'); 106 && $first->isPrivate()
107 && (empty($privateKey) || $privateKey !== $first->getAdditionalContentEntry('private_key'))
108 ) {
109 throw new BookmarkNotFoundException();
89 } 110 }
90 111
91 return $bookmark; 112 return $first;
92 } 113 }
93 114
94 /** 115 /**
95 * @inheritDoc 116 * @inheritDoc
96 */ 117 */
97 public function findByUrl($url) 118 public function findByUrl(string $url): ?Bookmark
98 { 119 {
99 return $this->bookmarks->getByUrl($url); 120 return $this->bookmarks->getByUrl($url);
100 } 121 }
@@ -102,19 +123,28 @@ class BookmarkFileService implements BookmarkServiceInterface
102 /** 123 /**
103 * @inheritDoc 124 * @inheritDoc
104 */ 125 */
105 public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false) 126 public function search(
106 { 127 array $request = [],
128 string $visibility = null,
129 bool $caseSensitive = false,
130 bool $untaggedOnly = false,
131 bool $ignoreSticky = false
132 ) {
107 if ($visibility === null) { 133 if ($visibility === null) {
108 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; 134 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
109 } 135 }
110 136
111 // Filter bookmark database according to parameters. 137 // Filter bookmark database according to parameters.
112 $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; 138 $searchTags = isset($request['searchtags']) ? $request['searchtags'] : '';
113 $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; 139 $searchTerm = isset($request['searchterm']) ? $request['searchterm'] : '';
140
141 if ($ignoreSticky) {
142 $this->bookmarks->reorder('DESC', true);
143 }
114 144
115 return $this->bookmarkFilter->filter( 145 return $this->bookmarkFilter->filter(
116 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, 146 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
117 [$searchtags, $searchterm], 147 [$searchTags, $searchTerm],
118 $caseSensitive, 148 $caseSensitive,
119 $visibility, 149 $visibility,
120 $untaggedOnly 150 $untaggedOnly
@@ -124,7 +154,7 @@ class BookmarkFileService implements BookmarkServiceInterface
124 /** 154 /**
125 * @inheritDoc 155 * @inheritDoc
126 */ 156 */
127 public function get($id, $visibility = null) 157 public function get(int $id, string $visibility = null): Bookmark
128 { 158 {
129 if (! isset($this->bookmarks[$id])) { 159 if (! isset($this->bookmarks[$id])) {
130 throw new BookmarkNotFoundException(); 160 throw new BookmarkNotFoundException();
@@ -147,20 +177,17 @@ class BookmarkFileService implements BookmarkServiceInterface
147 /** 177 /**
148 * @inheritDoc 178 * @inheritDoc
149 */ 179 */
150 public function set($bookmark, $save = true) 180 public function set(Bookmark $bookmark, bool $save = true): Bookmark
151 { 181 {
152 if ($this->isLoggedIn !== true) { 182 if (true !== $this->isLoggedIn) {
153 throw new Exception(t('You\'re not authorized to alter the datastore')); 183 throw new Exception(t('You\'re not authorized to alter the datastore'));
154 } 184 }
155 if (! $bookmark instanceof Bookmark) {
156 throw new Exception(t('Provided data is invalid'));
157 }
158 if (! isset($this->bookmarks[$bookmark->getId()])) { 185 if (! isset($this->bookmarks[$bookmark->getId()])) {
159 throw new BookmarkNotFoundException(); 186 throw new BookmarkNotFoundException();
160 } 187 }
161 $bookmark->validate(); 188 $bookmark->validate();
162 189
163 $bookmark->setUpdated(new \DateTime()); 190 $bookmark->setUpdated(new DateTime());
164 $this->bookmarks[$bookmark->getId()] = $bookmark; 191 $this->bookmarks[$bookmark->getId()] = $bookmark;
165 if ($save === true) { 192 if ($save === true) {
166 $this->save(); 193 $this->save();
@@ -172,15 +199,12 @@ class BookmarkFileService implements BookmarkServiceInterface
172 /** 199 /**
173 * @inheritDoc 200 * @inheritDoc
174 */ 201 */
175 public function add($bookmark, $save = true) 202 public function add(Bookmark $bookmark, bool $save = true): Bookmark
176 { 203 {
177 if ($this->isLoggedIn !== true) { 204 if (true !== $this->isLoggedIn) {
178 throw new Exception(t('You\'re not authorized to alter the datastore')); 205 throw new Exception(t('You\'re not authorized to alter the datastore'));
179 } 206 }
180 if (! $bookmark instanceof Bookmark) { 207 if (!empty($bookmark->getId())) {
181 throw new Exception(t('Provided data is invalid'));
182 }
183 if (! empty($bookmark->getId())) {
184 throw new Exception(t('This bookmarks already exists')); 208 throw new Exception(t('This bookmarks already exists'));
185 } 209 }
186 $bookmark->setId($this->bookmarks->getNextId()); 210 $bookmark->setId($this->bookmarks->getNextId());
@@ -197,14 +221,11 @@ class BookmarkFileService implements BookmarkServiceInterface
197 /** 221 /**
198 * @inheritDoc 222 * @inheritDoc
199 */ 223 */
200 public function addOrSet($bookmark, $save = true) 224 public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark
201 { 225 {
202 if ($this->isLoggedIn !== true) { 226 if (true !== $this->isLoggedIn) {
203 throw new Exception(t('You\'re not authorized to alter the datastore')); 227 throw new Exception(t('You\'re not authorized to alter the datastore'));
204 } 228 }
205 if (! $bookmark instanceof Bookmark) {
206 throw new Exception('Provided data is invalid');
207 }
208 if ($bookmark->getId() === null) { 229 if ($bookmark->getId() === null) {
209 return $this->add($bookmark, $save); 230 return $this->add($bookmark, $save);
210 } 231 }
@@ -214,14 +235,11 @@ class BookmarkFileService implements BookmarkServiceInterface
214 /** 235 /**
215 * @inheritDoc 236 * @inheritDoc
216 */ 237 */
217 public function remove($bookmark, $save = true) 238 public function remove(Bookmark $bookmark, bool $save = true): void
218 { 239 {
219 if ($this->isLoggedIn !== true) { 240 if (true !== $this->isLoggedIn) {
220 throw new Exception(t('You\'re not authorized to alter the datastore')); 241 throw new Exception(t('You\'re not authorized to alter the datastore'));
221 } 242 }
222 if (! $bookmark instanceof Bookmark) {
223 throw new Exception(t('Provided data is invalid'));
224 }
225 if (! isset($this->bookmarks[$bookmark->getId()])) { 243 if (! isset($this->bookmarks[$bookmark->getId()])) {
226 throw new BookmarkNotFoundException(); 244 throw new BookmarkNotFoundException();
227 } 245 }
@@ -236,7 +254,7 @@ class BookmarkFileService implements BookmarkServiceInterface
236 /** 254 /**
237 * @inheritDoc 255 * @inheritDoc
238 */ 256 */
239 public function exists($id, $visibility = null) 257 public function exists(int $id, string $visibility = null): bool
240 { 258 {
241 if (! isset($this->bookmarks[$id])) { 259 if (! isset($this->bookmarks[$id])) {
242 return false; 260 return false;
@@ -259,7 +277,7 @@ class BookmarkFileService implements BookmarkServiceInterface
259 /** 277 /**
260 * @inheritDoc 278 * @inheritDoc
261 */ 279 */
262 public function count($visibility = null) 280 public function count(string $visibility = null): int
263 { 281 {
264 return count($this->search([], $visibility)); 282 return count($this->search([], $visibility));
265 } 283 }
@@ -267,21 +285,22 @@ class BookmarkFileService implements BookmarkServiceInterface
267 /** 285 /**
268 * @inheritDoc 286 * @inheritDoc
269 */ 287 */
270 public function save() 288 public function save(): void
271 { 289 {
272 if (!$this->isLoggedIn) { 290 if (true !== $this->isLoggedIn) {
273 // TODO: raise an Exception instead 291 // TODO: raise an Exception instead
274 die('You are not authorized to change the database.'); 292 die('You are not authorized to change the database.');
275 } 293 }
294
276 $this->bookmarks->reorder(); 295 $this->bookmarks->reorder();
277 $this->bookmarksIO->write($this->bookmarks); 296 $this->bookmarksIO->write($this->bookmarks);
278 invalidateCaches($this->conf->get('resource.page_cache')); 297 $this->pageCacheManager->invalidateCaches();
279 } 298 }
280 299
281 /** 300 /**
282 * @inheritDoc 301 * @inheritDoc
283 */ 302 */
284 public function bookmarksCountPerTag($filteringTags = [], $visibility = null) 303 public function bookmarksCountPerTag(array $filteringTags = [], string $visibility = null): array
285 { 304 {
286 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); 305 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
287 $tags = []; 306 $tags = [];
@@ -291,6 +310,7 @@ class BookmarkFileService implements BookmarkServiceInterface
291 if (empty($tag) 310 if (empty($tag)
292 || (! $this->isLoggedIn && startsWith($tag, '.')) 311 || (! $this->isLoggedIn && startsWith($tag, '.'))
293 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG 312 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
313 || in_array($tag, $filteringTags, true)
294 ) { 314 ) {
295 continue; 315 continue;
296 } 316 }
@@ -316,45 +336,68 @@ class BookmarkFileService implements BookmarkServiceInterface
316 $keys = array_keys($tags); 336 $keys = array_keys($tags);
317 $tmpTags = array_combine($keys, $keys); 337 $tmpTags = array_combine($keys, $keys);
318 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); 338 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
339
319 return $tags; 340 return $tags;
320 } 341 }
321 342
322 /** 343 /**
323 * @inheritDoc 344 * @inheritDoc
324 */ 345 */
325 public function days() 346 public function findByDate(
326 { 347 \DateTimeInterface $from,
327 $bookmarkDays = []; 348 \DateTimeInterface $to,
328 foreach ($this->search() as $bookmark) { 349 ?\DateTimeInterface &$previous,
329 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; 350 ?\DateTimeInterface &$next
351 ): array {
352 $out = [];
353 $previous = null;
354 $next = null;
355
356 foreach ($this->search([], null, false, false, true) as $bookmark) {
357 if ($to < $bookmark->getCreated()) {
358 $next = $bookmark->getCreated();
359 } else if ($from < $bookmark->getCreated() && $to > $bookmark->getCreated()) {
360 $out[] = $bookmark;
361 } else {
362 if ($previous !== null) {
363 break;
364 }
365 $previous = $bookmark->getCreated();
366 }
330 } 367 }
331 $bookmarkDays = array_keys($bookmarkDays);
332 sort($bookmarkDays);
333 368
334 return $bookmarkDays; 369 return $out;
335 } 370 }
336 371
337 /** 372 /**
338 * @inheritDoc 373 * @inheritDoc
339 */ 374 */
340 public function filterDay($request) 375 public function getLatest(): ?Bookmark
341 { 376 {
342 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request); 377 foreach ($this->search([], null, false, false, true) as $bookmark) {
378 return $bookmark;
379 }
380
381 return null;
343 } 382 }
344 383
345 /** 384 /**
346 * @inheritDoc 385 * @inheritDoc
347 */ 386 */
348 public function initialize() 387 public function initialize(): void
349 { 388 {
350 $initializer = new BookmarkInitializer($this); 389 $initializer = new BookmarkInitializer($this);
351 $initializer->initialize(); 390 $initializer->initialize();
391
392 if (true === $this->isLoggedIn) {
393 $this->save();
394 }
352 } 395 }
353 396
354 /** 397 /**
355 * Handles migration to the new database format (BookmarksArray). 398 * Handles migration to the new database format (BookmarksArray).
356 */ 399 */
357 protected function migrate() 400 protected function migrate(): void
358 { 401 {
359 $bookmarkDb = new LegacyLinkDB( 402 $bookmarkDb = new LegacyLinkDB(
360 $this->conf->get('resource.datastore'), 403 $this->conf->get('resource.datastore'),
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index fd556679..c79386ea 100644
--- a/application/bookmark/BookmarkFilter.php
+++ b/application/bookmark/BookmarkFilter.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 Exception; 7use Exception;
@@ -77,8 +79,13 @@ class BookmarkFilter
77 * 79 *
78 * @throws BookmarkNotFoundException 80 * @throws BookmarkNotFoundException
79 */ 81 */
80 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) 82 public function filter(
81 { 83 string $type,
84 $request,
85 bool $casesensitive = false,
86 string $visibility = 'all',
87 bool $untaggedonly = false
88 ) {
82 if (!in_array($visibility, ['all', 'public', 'private'])) { 89 if (!in_array($visibility, ['all', 'public', 'private'])) {
83 $visibility = 'all'; 90 $visibility = 'all';
84 } 91 }
@@ -115,7 +122,7 @@ class BookmarkFilter
115 return $this->filterTags($request, $casesensitive, $visibility); 122 return $this->filterTags($request, $casesensitive, $visibility);
116 } 123 }
117 case self::$FILTER_DAY: 124 case self::$FILTER_DAY:
118 return $this->filterDay($request); 125 return $this->filterDay($request, $visibility);
119 default: 126 default:
120 return $this->noFilter($visibility); 127 return $this->noFilter($visibility);
121 } 128 }
@@ -128,7 +135,7 @@ class BookmarkFilter
128 * 135 *
129 * @return Bookmark[] filtered bookmarks. 136 * @return Bookmark[] filtered bookmarks.
130 */ 137 */
131 private function noFilter($visibility = 'all') 138 private function noFilter(string $visibility = 'all')
132 { 139 {
133 if ($visibility === 'all') { 140 if ($visibility === 'all') {
134 return $this->bookmarks; 141 return $this->bookmarks;
@@ -151,11 +158,11 @@ class BookmarkFilter
151 * 158 *
152 * @param string $smallHash permalink hash. 159 * @param string $smallHash permalink hash.
153 * 160 *
154 * @return array $filtered array containing permalink data. 161 * @return Bookmark[] $filtered array containing permalink data.
155 * 162 *
156 * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. 163 * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
157 */ 164 */
158 private function filterSmallHash($smallHash) 165 private function filterSmallHash(string $smallHash)
159 { 166 {
160 foreach ($this->bookmarks as $key => $l) { 167 foreach ($this->bookmarks as $key => $l) {
161 if ($smallHash == $l->getShortUrl()) { 168 if ($smallHash == $l->getShortUrl()) {
@@ -186,15 +193,15 @@ class BookmarkFilter
186 * @param string $searchterms search query. 193 * @param string $searchterms search query.
187 * @param string $visibility Optional: return only all/private/public bookmarks. 194 * @param string $visibility Optional: return only all/private/public bookmarks.
188 * 195 *
189 * @return array search results. 196 * @return Bookmark[] search results.
190 */ 197 */
191 private function filterFulltext($searchterms, $visibility = 'all') 198 private function filterFulltext(string $searchterms, string $visibility = 'all')
192 { 199 {
193 if (empty($searchterms)) { 200 if (empty($searchterms)) {
194 return $this->noFilter($visibility); 201 return $this->noFilter($visibility);
195 } 202 }
196 203
197 $filtered = array(); 204 $filtered = [];
198 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); 205 $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8');
199 $exactRegex = '/"([^"]+)"/'; 206 $exactRegex = '/"([^"]+)"/';
200 // Retrieve exact search terms. 207 // Retrieve exact search terms.
@@ -206,8 +213,8 @@ class BookmarkFilter
206 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); 213 $explodedSearchAnd = array_values(array_filter($explodedSearchAnd));
207 214
208 // Filter excluding terms and update andSearch. 215 // Filter excluding terms and update andSearch.
209 $excludeSearch = array(); 216 $excludeSearch = [];
210 $andSearch = array(); 217 $andSearch = [];
211 foreach ($explodedSearchAnd as $needle) { 218 foreach ($explodedSearchAnd as $needle) {
212 if ($needle[0] == '-' && strlen($needle) > 1) { 219 if ($needle[0] == '-' && strlen($needle) > 1) {
213 $excludeSearch[] = substr($needle, 1); 220 $excludeSearch[] = substr($needle, 1);
@@ -227,33 +234,38 @@ class BookmarkFilter
227 } 234 }
228 } 235 }
229 236
230 // Concatenate link fields to search across fields. 237 $lengths = [];
231 // Adds a '\' separator for exact search terms. 238 $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 239
237 // Be optimistic 240 // Be optimistic
238 $found = true; 241 $found = true;
242 $foundPositions = [];
239 243
240 // First, we look for exact term search 244 // First, we look for exact term search
241 for ($i = 0; $i < count($exactSearch) && $found; $i++) { 245 // 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. 246 // no need to check for the others. We want all or nothing.
247 for ($i = 0; $i < count($andSearch) && $found; $i++) { 247 foreach ([$exactSearch, $andSearch] as $search) {
248 $found = strpos($content, $andSearch[$i]) !== false; 248 for ($i = 0; $i < count($search) && $found !== false; $i++) {
249 $found = mb_strpos($content, $search[$i]);
250 if ($found === false) {
251 break;
252 }
253
254 $foundPositions[] = ['start' => $found, 'end' => $found + mb_strlen($search[$i])];
255 }
249 } 256 }
250 257
251 // Exclude terms. 258 // Exclude terms.
252 for ($i = 0; $i < count($excludeSearch) && $found; $i++) { 259 for ($i = 0; $i < count($excludeSearch) && $found !== false; $i++) {
253 $found = strpos($content, $excludeSearch[$i]) === false; 260 $found = strpos($content, $excludeSearch[$i]) === false;
254 } 261 }
255 262
256 if ($found) { 263 if ($found !== false) {
264 $link->addAdditionalContentEntry(
265 'search_highlight',
266 $this->postProcessFoundPositions($lengths, $foundPositions)
267 );
268
257 $filtered[$id] = $link; 269 $filtered[$id] = $link;
258 } 270 }
259 } 271 }
@@ -268,7 +280,7 @@ class BookmarkFilter
268 * 280 *
269 * @return string generated regex fragment 281 * @return string generated regex fragment
270 */ 282 */
271 private static function tag2regex($tag) 283 private static function tag2regex(string $tag): string
272 { 284 {
273 $len = strlen($tag); 285 $len = strlen($tag);
274 if (!$len || $tag === "-" || $tag === "*") { 286 if (!$len || $tag === "-" || $tag === "*") {
@@ -314,13 +326,13 @@ class BookmarkFilter
314 * You can specify one or more tags, separated by space or a comma, e.g. 326 * You can specify one or more tags, separated by space or a comma, e.g.
315 * print_r($mydb->filterTags('linux programming')); 327 * print_r($mydb->filterTags('linux programming'));
316 * 328 *
317 * @param string $tags list of tags separated by commas or blank spaces. 329 * @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. 330 * @param bool $casesensitive ignore case if false.
319 * @param string $visibility Optional: return only all/private/public bookmarks. 331 * @param string $visibility Optional: return only all/private/public bookmarks.
320 * 332 *
321 * @return array filtered bookmarks. 333 * @return Bookmark[] filtered bookmarks.
322 */ 334 */
323 public function filterTags($tags, $casesensitive = false, $visibility = 'all') 335 public function filterTags($tags, bool $casesensitive = false, string $visibility = 'all')
324 { 336 {
325 // get single tags (we may get passed an array, even though the docs say different) 337 // get single tags (we may get passed an array, even though the docs say different)
326 $inputTags = $tags; 338 $inputTags = $tags;
@@ -396,9 +408,9 @@ class BookmarkFilter
396 * 408 *
397 * @param string $visibility return only all/private/public bookmarks. 409 * @param string $visibility return only all/private/public bookmarks.
398 * 410 *
399 * @return array filtered bookmarks. 411 * @return Bookmark[] filtered bookmarks.
400 */ 412 */
401 public function filterUntagged($visibility) 413 public function filterUntagged(string $visibility)
402 { 414 {
403 $filtered = []; 415 $filtered = [];
404 foreach ($this->bookmarks as $key => $link) { 416 foreach ($this->bookmarks as $key => $link) {
@@ -425,21 +437,26 @@ class BookmarkFilter
425 * print_r($mydb->filterDay('20120125')); 437 * print_r($mydb->filterDay('20120125'));
426 * 438 *
427 * @param string $day day to filter. 439 * @param string $day day to filter.
428 * 440 * @param string $visibility return only all/private/public bookmarks.
429 * @return array all link matching given day. 441
442 * @return Bookmark[] all link matching given day.
430 * 443 *
431 * @throws Exception if date format is invalid. 444 * @throws Exception if date format is invalid.
432 */ 445 */
433 public function filterDay($day) 446 public function filterDay(string $day, string $visibility)
434 { 447 {
435 if (!checkDateFormat('Ymd', $day)) { 448 if (!checkDateFormat('Ymd', $day)) {
436 throw new Exception('Invalid date format'); 449 throw new Exception('Invalid date format');
437 } 450 }
438 451
439 $filtered = array(); 452 $filtered = [];
440 foreach ($this->bookmarks as $key => $l) { 453 foreach ($this->bookmarks as $key => $bookmark) {
441 if ($l->getCreated()->format('Ymd') == $day) { 454 if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) {
442 $filtered[$key] = $l; 455 continue;
456 }
457
458 if ($bookmark->getCreated()->format('Ymd') == $day) {
459 $filtered[$key] = $bookmark;
443 } 460 }
444 } 461 }
445 462
@@ -455,9 +472,9 @@ class BookmarkFilter
455 * @param string $tags string containing a list of tags. 472 * @param string $tags string containing a list of tags.
456 * @param bool $casesensitive will convert everything to lowercase if false. 473 * @param bool $casesensitive will convert everything to lowercase if false.
457 * 474 *
458 * @return array filtered tags string. 475 * @return string[] filtered tags string.
459 */ 476 */
460 public static function tagsStrToArray($tags, $casesensitive) 477 public static function tagsStrToArray(string $tags, bool $casesensitive): array
461 { 478 {
462 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) 479 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
463 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); 480 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
@@ -465,4 +482,74 @@ class BookmarkFilter
465 482
466 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); 483 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
467 } 484 }
485
486 /**
487 * This method finalize the content of the foundPositions array,
488 * by associated all search results to their associated bookmark field,
489 * making sure that there is no overlapping results, etc.
490 *
491 * @param array $fieldLengths Start and end positions of every bookmark fields in the aggregated bookmark content.
492 * @param array $foundPositions Positions where the search results were found in the aggregated content.
493 *
494 * @return array Updated $foundPositions, by bookmark field.
495 */
496 protected function postProcessFoundPositions(array $fieldLengths, array $foundPositions): array
497 {
498 // Sort results by starting position ASC.
499 usort($foundPositions, function (array $entryA, array $entryB): int {
500 return $entryA['start'] > $entryB['start'] ? 1 : -1;
501 });
502
503 $out = [];
504 $currentMax = -1;
505 foreach ($foundPositions as $foundPosition) {
506 // we do not allow overlapping highlights
507 if ($foundPosition['start'] < $currentMax) {
508 continue;
509 }
510
511 $currentMax = $foundPosition['end'];
512 foreach ($fieldLengths as $part => $length) {
513 if ($foundPosition['start'] < $length['start'] || $foundPosition['start'] > $length['end']) {
514 continue;
515 }
516
517 $out[$part][] = [
518 'start' => $foundPosition['start'] - $length['start'],
519 'end' => $foundPosition['end'] - $length['start'],
520 ];
521 break;
522 }
523 }
524
525 return $out;
526 }
527
528 /**
529 * Concatenate link fields to search across fields. Adds a '\' separator for exact search terms.
530 * Also populate $length array with starting and ending positions of every bookmark field
531 * inside concatenated content.
532 *
533 * @param Bookmark $link
534 * @param array $lengths (by reference)
535 *
536 * @return string Lowercase concatenated fields content.
537 */
538 protected function buildFullTextSearchableLink(Bookmark $link, array &$lengths): string
539 {
540 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
541 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
542 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
543 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
544
545 $lengths['title'] = ['start' => 0, 'end' => mb_strlen($link->getTitle())];
546 $nextField = $lengths['title']['end'] + 1;
547 $lengths['description'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getDescription())];
548 $nextField = $lengths['description']['end'] + 1;
549 $lengths['url'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getUrl())];
550 $nextField = $lengths['url']['end'] + 1;
551 $lengths['tags'] = ['start' => $nextField, 'end' => $nextField + mb_strlen($link->getTagsString())];
552
553 return $content;
554 }
468} 555}
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
index ae9ffcb4..f40fa476 100644
--- a/application/bookmark/BookmarkIO.php
+++ b/application/bookmark/BookmarkIO.php
@@ -1,7 +1,12 @@
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;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
5use Shaarli\Bookmark\Exception\EmptyDataStoreException; 10use Shaarli\Bookmark\Exception\EmptyDataStoreException;
6use Shaarli\Bookmark\Exception\NotWritableDataStoreException; 11use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
7use Shaarli\Config\ConfigManager; 12use Shaarli\Config\ConfigManager;
@@ -26,11 +31,14 @@ class BookmarkIO
26 */ 31 */
27 protected $conf; 32 protected $conf;
28 33
34
35 /** @var Mutex */
36 protected $mutex;
37
29 /** 38 /**
30 * string Datastore PHP prefix 39 * string Datastore PHP prefix
31 */ 40 */
32 protected static $phpPrefix = '<?php /* '; 41 protected static $phpPrefix = '<?php /* ';
33
34 /** 42 /**
35 * string Datastore PHP suffix 43 * string Datastore PHP suffix
36 */ 44 */
@@ -41,35 +49,46 @@ class BookmarkIO
41 * 49 *
42 * @param ConfigManager $conf instance 50 * @param ConfigManager $conf instance
43 */ 51 */
44 public function __construct($conf) 52 public function __construct(ConfigManager $conf, Mutex $mutex = null)
45 { 53 {
54 if ($mutex === null) {
55 // This should only happen with legacy classes
56 $mutex = new NoMutex();
57 }
46 $this->conf = $conf; 58 $this->conf = $conf;
47 $this->datastore = $conf->get('resource.datastore'); 59 $this->datastore = $conf->get('resource.datastore');
60 $this->mutex = $mutex;
48 } 61 }
49 62
50 /** 63 /**
51 * Reads database from disk to memory 64 * Reads database from disk to memory
52 * 65 *
53 * @return BookmarkArray instance 66 * @return Bookmark[]
54 * 67 *
55 * @throws NotWritableDataStoreException Data couldn't be loaded 68 * @throws NotWritableDataStoreException Data couldn't be loaded
56 * @throws EmptyDataStoreException Datastore doesn't exist 69 * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
70 * @throws DatastoreNotInitializedException File does not exists
57 */ 71 */
58 public function read() 72 public function read()
59 { 73 {
60 if (! file_exists($this->datastore)) { 74 if (! file_exists($this->datastore)) {
61 throw new EmptyDataStoreException(); 75 throw new DatastoreNotInitializedException();
62 } 76 }
63 77
64 if (!is_writable($this->datastore)) { 78 if (!is_writable($this->datastore)) {
65 throw new NotWritableDataStoreException($this->datastore); 79 throw new NotWritableDataStoreException($this->datastore);
66 } 80 }
67 81
82 $content = null;
83 $this->mutex->synchronized(function () use (&$content) {
84 $content = file_get_contents($this->datastore);
85 });
86
68 // Note that gzinflate is faster than gzuncompress. 87 // Note that gzinflate is faster than gzuncompress.
69 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 88 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
70 $links = unserialize(gzinflate(base64_decode( 89 $links = unserialize(gzinflate(base64_decode(
71 substr(file_get_contents($this->datastore), 90 substr($content, strlen(self::$phpPrefix), -strlen(self::$phpSuffix))
72 strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); 91 )));
73 92
74 if (empty($links)) { 93 if (empty($links)) {
75 if (filesize($this->datastore) > 100) { 94 if (filesize($this->datastore) > 100) {
@@ -84,7 +103,7 @@ class BookmarkIO
84 /** 103 /**
85 * Saves the database from memory to disk 104 * Saves the database from memory to disk
86 * 105 *
87 * @param BookmarkArray $links instance. 106 * @param Bookmark[] $links
88 * 107 *
89 * @throws NotWritableDataStoreException the datastore is not writable 108 * @throws NotWritableDataStoreException the datastore is not writable
90 */ 109 */
@@ -98,11 +117,13 @@ class BookmarkIO
98 throw new NotWritableDataStoreException(dirname($this->datastore)); 117 throw new NotWritableDataStoreException(dirname($this->datastore));
99 } 118 }
100 119
101 file_put_contents( 120 $data = self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix;
102 $this->datastore,
103 self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
104 );
105 121
106 invalidateCaches($this->conf->get('resource.page_cache')); 122 $this->mutex->synchronized(function () use ($data) {
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 9eee9a35..04b996f3 100644
--- a/application/bookmark/BookmarkInitializer.php
+++ b/application/bookmark/BookmarkInitializer.php
@@ -1,13 +1,14 @@
1<?php 1<?php
2 2
3declare(strict_types=1);
4
3namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
4 6
5/** 7/**
6 * Class BookmarkInitializer 8 * Class BookmarkInitializer
7 * 9 *
8 * This class is used to initialized default bookmarks after a fresh install of Shaarli. 10 * This class is used to initialized default bookmarks after a fresh install of Shaarli.
9 * It is no longer call when the data store is empty, 11 * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
10 * because user might want to delete default bookmarks after the install.
11 * 12 *
12 * To prevent data corruption, it does not overwrite existing bookmarks, 13 * To prevent data corruption, it does not overwrite existing bookmarks,
13 * even though there should not be any. 14 * even though there should not be any.
@@ -24,7 +25,7 @@ class BookmarkInitializer
24 * 25 *
25 * @param BookmarkServiceInterface $bookmarkService 26 * @param BookmarkServiceInterface $bookmarkService
26 */ 27 */
27 public function __construct($bookmarkService) 28 public function __construct(BookmarkServiceInterface $bookmarkService)
28 { 29 {
29 $this->bookmarkService = $bookmarkService; 30 $this->bookmarkService = $bookmarkService;
30 } 31 }
@@ -32,28 +33,80 @@ class BookmarkInitializer
32 /** 33 /**
33 * Initialize the data store with default bookmarks 34 * Initialize the data store with default bookmarks
34 */ 35 */
35 public function initialize() 36 public function initialize(): void
36 { 37 {
37 $bookmark = new Bookmark(); 38 $bookmark = new Bookmark();
38 $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); 39 $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
39 $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []); 40 $bookmark->setUrl('https://vimeo.com/153493904');
40 $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.')); 41 $bookmark->setDescription(t(
41 $bookmark->setTagsString('secretstuff'); 42'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
43
44Explore 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.
46
47Now you can edit or delete the default shaares.
48'
49 ));
50 $bookmark->setTagsString('shaarli help thumbnail');
51 $bookmark->setPrivate(true);
52 $this->bookmarkService->add($bookmark, false);
53
54 $bookmark = new Bookmark();
55 $bookmark->setTitle(t('Note: Shaare descriptions'));
56 $bookmark->setDescription(t(
57'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.
59
60You can use this to keep notes, post articles, code snippets, and much more.
61
62The Markdown formatting setting allows you to format your notes and bookmark description:
63
64### Title headings
65
66#### Multiple headings levels
67 * bullet lists
68 * _italic_ text
69 * **bold** text
70 * ~~strike through~~ text
71 * `code` blocks
72 * images
73 * [links](https://en.wikipedia.org/wiki/Markdown)
74
75Markdown also supports tables:
76
77| Name | Type | Color | Qty |
78| ------- | --------- | ------ | ----- |
79| Orange | Fruit | Orange | 126 |
80| Apple | Fruit | Any | 62 |
81| Lemon | Fruit | Yellow | 30 |
82| Carrot | Vegetable | Red | 14 |
83'
84 ));
85 $bookmark->setTagsString('shaarli help');
42 $bookmark->setPrivate(true); 86 $bookmark->setPrivate(true);
43 $this->bookmarkService->add($bookmark); 87 $this->bookmarkService->add($bookmark, false);
44 88
45 $bookmark = new Bookmark(); 89 $bookmark = new Bookmark();
46 $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service')); 90 $bookmark->setTitle(
47 $bookmark->setUrl('https://shaarli.readthedocs.io', []); 91 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
92 );
48 $bookmark->setDescription(t( 93 $bookmark->setDescription(t(
49 'Welcome to Shaarli! This is your first public bookmark. ' 94'Welcome to Shaarli!
50 . 'To edit or delete me, you must first login. 95
96Shaarli 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.
98
99Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
51 100
52To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. 101You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
102Hashtags such as #shaarli #help are also supported.
103You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
53 104
54You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' 105We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
106Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
107'
55 )); 108 ));
56 $bookmark->setTagsString('opensource software'); 109 $bookmark->setTagsString('shaarli help');
57 $this->bookmarkService->add($bookmark); 110 $this->bookmarkService->add($bookmark, false);
58 } 111 }
59} 112}
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
index 7b7a4f09..08cdbb4e 100644
--- a/application/bookmark/BookmarkServiceInterface.php
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -1,73 +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\Exceptions\IOException;
10use Shaarli\History;
11 9
12/** 10/**
13 * Class BookmarksService 11 * Class BookmarksService
14 * 12 *
15 * 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.
16 */ 17 */
17interface BookmarkServiceInterface 18interface BookmarkServiceInterface
18{ 19{
19 /** 20 /**
20 * BookmarksService constructor.
21 *
22 * @param ConfigManager $conf instance
23 * @param History $history instance
24 * @param bool $isLoggedIn true if the current user is logged in
25 */
26 public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
27
28 /**
29 * Find a bookmark by hash 21 * Find a bookmark by hash
30 * 22 *
31 * @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
32 * 25 *
33 * @return mixed 26 * @return Bookmark
34 * 27 *
35 * @throws \Exception 28 * @throws \Exception
36 */ 29 */
37 public function findByHash($hash); 30 public function findByHash(string $hash, string $privateKey = null);
38 31
39 /** 32 /**
40 * @param $url 33 * @param $url
41 * 34 *
42 * @return Bookmark|null 35 * @return Bookmark|null
43 */ 36 */
44 public function findByUrl($url); 37 public function findByUrl(string $url): ?Bookmark;
45 38
46 /** 39 /**
47 * Search bookmarks 40 * Search bookmarks
48 * 41 *
49 * @param mixed $request 42 * @param array $request
50 * @param string $visibility 43 * @param ?string $visibility
51 * @param bool $caseSensitive 44 * @param bool $caseSensitive
52 * @param bool $untaggedOnly 45 * @param bool $untaggedOnly
46 * @param bool $ignoreSticky
53 * 47 *
54 * @return Bookmark[] 48 * @return Bookmark[]
55 */ 49 */
56 public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false); 50 public function search(
51 array $request = [],
52 string $visibility = null,
53 bool $caseSensitive = false,
54 bool $untaggedOnly = false,
55 bool $ignoreSticky = false
56 );
57 57
58 /** 58 /**
59 * Get a single bookmark by its ID. 59 * Get a single bookmark by its ID.
60 * 60 *
61 * @param int $id Bookmark ID 61 * @param int $id Bookmark ID
62 * @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
63 * exception 63 * exception
64 * 64 *
65 * @return Bookmark 65 * @return Bookmark
66 * 66 *
67 * @throws BookmarkNotFoundException 67 * @throws BookmarkNotFoundException
68 * @throws \Exception 68 * @throws \Exception
69 */ 69 */
70 public function get($id, $visibility = null); 70 public function get(int $id, string $visibility = null);
71 71
72 /** 72 /**
73 * Updates an existing bookmark (depending on its ID). 73 * Updates an existing bookmark (depending on its ID).
@@ -80,7 +80,7 @@ interface BookmarkServiceInterface
80 * @throws BookmarkNotFoundException 80 * @throws BookmarkNotFoundException
81 * @throws \Exception 81 * @throws \Exception
82 */ 82 */
83 public function set($bookmark, $save = true); 83 public function set(Bookmark $bookmark, bool $save = true): Bookmark;
84 84
85 /** 85 /**
86 * Adds a new bookmark (the ID must be empty). 86 * Adds a new bookmark (the ID must be empty).
@@ -92,7 +92,7 @@ interface BookmarkServiceInterface
92 * 92 *
93 * @throws \Exception 93 * @throws \Exception
94 */ 94 */
95 public function add($bookmark, $save = true); 95 public function add(Bookmark $bookmark, bool $save = true): Bookmark;
96 96
97 /** 97 /**
98 * Adds or updates a bookmark depending on its ID: 98 * Adds or updates a bookmark depending on its ID:
@@ -106,7 +106,7 @@ interface BookmarkServiceInterface
106 * 106 *
107 * @throws \Exception 107 * @throws \Exception
108 */ 108 */
109 public function addOrSet($bookmark, $save = true); 109 public function addOrSet(Bookmark $bookmark, bool $save = true): Bookmark;
110 110
111 /** 111 /**
112 * Deletes a bookmark. 112 * Deletes a bookmark.
@@ -116,65 +116,72 @@ interface BookmarkServiceInterface
116 * 116 *
117 * @throws \Exception 117 * @throws \Exception
118 */ 118 */
119 public function remove($bookmark, $save = true); 119 public function remove(Bookmark $bookmark, bool $save = true): void;
120 120
121 /** 121 /**
122 * Get a single bookmark by its ID. 122 * Get a single bookmark by its ID.
123 * 123 *
124 * @param int $id Bookmark ID 124 * @param int $id Bookmark ID
125 * @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
126 * exception 126 * exception
127 * 127 *
128 * @return bool 128 * @return bool
129 */ 129 */
130 public function exists($id, $visibility = null); 130 public function exists(int $id, string $visibility = null): bool;
131 131
132 /** 132 /**
133 * Return the number of available bookmarks for given visibility. 133 * Return the number of available bookmarks for given visibility.
134 * 134 *
135 * @param string $visibility public|private|all 135 * @param ?string $visibility public|private|all
136 * 136 *
137 * @return int Number of bookmarks 137 * @return int Number of bookmarks
138 */ 138 */
139 public function count($visibility = null); 139 public function count(string $visibility = null): int;
140 140
141 /** 141 /**
142 * Write the datastore. 142 * Write the datastore.
143 * 143 *
144 * @throws NotWritableDataStoreException 144 * @throws NotWritableDataStoreException
145 */ 145 */
146 public function save(); 146 public function save(): void;
147 147
148 /** 148 /**
149 * 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
150 * 150 *
151 * @param array $filteringTags tags selecting the bookmarks to consider 151 * @param array|null $filteringTags tags selecting the bookmarks to consider
152 * @param string $visibility process only all/private/public bookmarks 152 * @param string|null $visibility process only all/private/public bookmarks
153 * 153 *
154 * @return array tag => bookmarksCount 154 * @return array tag => bookmarksCount
155 */ 155 */
156 public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); 156 public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array;
157 157
158 /** 158 /**
159 * 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.
160 * 166 *
161 * @return array containing days (in format YYYYMMDD). 167 * @return array List of bookmarks matching provided period of time.
162 */ 168 */
163 public function days(); 169 public function findByDate(
170 \DateTimeInterface $from,
171 \DateTimeInterface $to,
172 ?\DateTimeInterface &$previous,
173 ?\DateTimeInterface &$next
174 ): array;
164 175
165 /** 176 /**
166 * Returns the list of articles for a given day. 177 * Returns the latest bookmark by creation date.
167 * 178 *
168 * @param string $request day to filter. Format: YYYYMMDD. 179 * @return Bookmark|null Found Bookmark or null if the datastore is empty.
169 *
170 * @return Bookmark[] list of shaare found.
171 *
172 * @throws BookmarkNotFoundException
173 */ 180 */
174 public function filterDay($request); 181 public function getLatest(): ?Bookmark;
175 182
176 /** 183 /**
177 * Creates the default database after a fresh install. 184 * Creates the default database after a fresh install.
178 */ 185 */
179 public function initialize(); 186 public function initialize(): void;
180} 187}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index 88379430..faf5dbfd 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -3,112 +3,6 @@
3use Shaarli\Bookmark\Bookmark; 3use Shaarli\Bookmark\Bookmark;
4 4
5/** 5/**
6 * Get cURL callback function for CURLOPT_WRITEFUNCTION
7 *
8 * @param string $charset to extract from the downloaded page (reference)
9 * @param string $title to extract from the downloaded page (reference)
10 * @param string $description to extract from the downloaded page (reference)
11 * @param string $keywords to extract from the downloaded page (reference)
12 * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
13 * @param string $curlGetInfo Optionally overrides curl_getinfo function
14 *
15 * @return Closure
16 */
17function get_curl_download_callback(
18 &$charset,
19 &$title,
20 &$description,
21 &$keywords,
22 $retrieveDescription,
23 $curlGetInfo = 'curl_getinfo'
24) {
25 $isRedirected = false;
26 $currentChunk = 0;
27 $foundChunk = null;
28
29 /**
30 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
31 *
32 * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
33 * Then we extract the title and the charset and stop the download when it's done.
34 *
35 * @param resource $ch cURL resource
36 * @param string $data chunk of data being downloaded
37 *
38 * @return int|bool length of $data or false if we need to stop the download
39 */
40 return function (&$ch, $data) use (
41 $retrieveDescription,
42 $curlGetInfo,
43 &$charset,
44 &$title,
45 &$description,
46 &$keywords,
47 &$isRedirected,
48 &$currentChunk,
49 &$foundChunk
50 ) {
51 $currentChunk++;
52 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
53 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
54 $isRedirected = true;
55 return strlen($data);
56 }
57 if (!empty($responseCode) && $responseCode !== 200) {
58 return false;
59 }
60 // After a redirection, the content type will keep the previous request value
61 // until it finds the next content-type header.
62 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
63 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
64 }
65 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
66 return false;
67 }
68 if (!empty($contentType) && empty($charset)) {
69 $charset = header_extract_charset($contentType);
70 }
71 if (empty($charset)) {
72 $charset = html_extract_charset($data);
73 }
74 if (empty($title)) {
75 $title = html_extract_title($data);
76 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
77 }
78 if ($retrieveDescription && empty($description)) {
79 $description = html_extract_tag('description', $data);
80 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
81 }
82 if ($retrieveDescription && empty($keywords)) {
83 $keywords = html_extract_tag('keywords', $data);
84 if (! empty($keywords)) {
85 $foundChunk = $currentChunk;
86 // Keywords use the format tag1, tag2 multiple words, tag
87 // So we format them to match Shaarli's separator and glue multiple words with '-'
88 $keywords = implode(' ', array_map(function($keyword) {
89 return implode('-', preg_split('/\s+/', trim($keyword)));
90 }, explode(',', $keywords)));
91 }
92 }
93
94 // We got everything we want, stop the download.
95 // If we already found either the title, description or keywords,
96 // it's highly unlikely that we'll found the other metas further than
97 // in the same chunk of data or the next one. So we also stop the download after that.
98 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
99 && (! $retrieveDescription
100 || $foundChunk < $currentChunk
101 || (!empty($title) && !empty($description) && !empty($keywords))
102 )
103 ) {
104 return false;
105 }
106
107 return strlen($data);
108 };
109}
110
111/**
112 * Extract title from an HTML document. 6 * Extract title from an HTML document.
113 * 7 *
114 * @param string $html HTML content where to look for a title. 8 * @param string $html HTML content where to look for a title.
@@ -132,7 +26,7 @@ function html_extract_title($html)
132 */ 26 */
133function header_extract_charset($header) 27function header_extract_charset($header)
134{ 28{
135 preg_match('/charset="?([^; ]+)/i', $header, $match); 29 preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
136 if (! empty($match[1])) { 30 if (! empty($match[1])) {
137 return strtolower(trim($match[1])); 31 return strtolower(trim($match[1]));
138 } 32 }
@@ -172,11 +66,13 @@ function html_extract_tag($tag, $html)
172{ 66{
173 $propertiesKey = ['property', 'name', 'itemprop']; 67 $propertiesKey = ['property', 'name', 'itemprop'];
174 $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"'
70 $orCondition = '["\']?(?:og:)?'. $tag .'["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]';
175 // Try to retrieve OpenGraph image. 71 // Try to retrieve OpenGraph image.
176 $ogRegex = '#<meta[^>]+(?:'. $properties .')=["\']?(?:og:)?'. $tag .'["\'\s][^>]*content=["\']?(.*?)["\'/>]#'; 72 $ogRegex = '#<meta[^>]+(?:'. $properties .')=(?:'. $orCondition .')[^>]*content=["\'](.*?)["\'].*?>#';
177 // 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)
178 // New regex to keep this readable... more or less. 74 // New regex to keep this readable... more or less.
179 $ogRegexReverse = '#<meta[^>]+content=["\']([^"\']+)[^>]+(?:'. $properties .')=["\']?(?:og)?:'. $tag .'["\'\s/>]#'; 75 $ogRegexReverse = '#<meta[^>]+content=["\'](.*?)["\'][^>]+(?:'. $properties .')=(?:'. $orCondition .').*?>#';
180 76
181 if (preg_match($ogRegex, $html, $matches) > 0 77 if (preg_match($ogRegex, $html, $matches) > 0
182 || preg_match($ogRegexReverse, $html, $matches) > 0 78 || preg_match($ogRegexReverse, $html, $matches) > 0
@@ -220,7 +116,7 @@ function hashtag_autolink($description, $indexUrl = '')
220 * \p{Mn} - any non marking space (accents, umlauts, etc) 116 * \p{Mn} - any non marking space (accents, umlauts, etc)
221 */ 117 */
222 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 118 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
223 $replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>'; 119 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
224 return preg_replace($regex, $replacement, $description); 120 return preg_replace($regex, $replacement, $description);
225} 121}
226 122
diff --git a/application/bookmark/exception/DatastoreNotInitializedException.php b/application/bookmark/exception/DatastoreNotInitializedException.php
new file mode 100644
index 00000000..f495049d
--- /dev/null
+++ b/application/bookmark/exception/DatastoreNotInitializedException.php
@@ -0,0 +1,10 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Bookmark\Exception;
6
7class DatastoreNotInitializedException extends \Exception
8{
9
10}