diff options
Diffstat (limited to 'application/bookmark')
-rw-r--r-- | application/bookmark/Bookmark.php | 462 | ||||
-rw-r--r-- | application/bookmark/BookmarkArray.php | 260 | ||||
-rw-r--r-- | application/bookmark/BookmarkFileService.php | 407 | ||||
-rw-r--r-- | application/bookmark/BookmarkFilter.php (renamed from application/bookmark/LinkFilter.php) | 160 | ||||
-rw-r--r-- | application/bookmark/BookmarkIO.php | 108 | ||||
-rw-r--r-- | application/bookmark/BookmarkInitializer.php | 110 | ||||
-rw-r--r-- | application/bookmark/BookmarkServiceInterface.php | 186 | ||||
-rw-r--r-- | application/bookmark/LinkDB.php | 575 | ||||
-rw-r--r-- | application/bookmark/LinkUtils.php | 137 | ||||
-rw-r--r-- | application/bookmark/exception/BookmarkNotFoundException.php (renamed from application/bookmark/exception/LinkNotFoundException.php) | 2 | ||||
-rw-r--r-- | application/bookmark/exception/DatastoreNotInitializedException.php | 10 | ||||
-rw-r--r-- | application/bookmark/exception/EmptyDataStoreException.php | 7 | ||||
-rw-r--r-- | application/bookmark/exception/InvalidBookmarkException.php | 30 | ||||
-rw-r--r-- | application/bookmark/exception/NotWritableDataStoreException.php | 19 |
14 files changed, 1698 insertions, 775 deletions
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php new file mode 100644 index 00000000..1beb8be2 --- /dev/null +++ b/application/bookmark/Bookmark.php | |||
@@ -0,0 +1,462 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use DateTime; | ||
6 | use DateTimeInterface; | ||
7 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; | ||
8 | |||
9 | /** | ||
10 | * Class Bookmark | ||
11 | * | ||
12 | * This class represent a single Bookmark with all its attributes. | ||
13 | * Every bookmark should manipulated using this, before being formatted. | ||
14 | * | ||
15 | * @package Shaarli\Bookmark | ||
16 | */ | ||
17 | class Bookmark | ||
18 | { | ||
19 | /** @var string Date format used in string (former ID format) */ | ||
20 | const LINK_DATE_FORMAT = 'Ymd_His'; | ||
21 | |||
22 | /** @var int Bookmark ID */ | ||
23 | protected $id; | ||
24 | |||
25 | /** @var string Permalink identifier */ | ||
26 | protected $shortUrl; | ||
27 | |||
28 | /** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */ | ||
29 | protected $url; | ||
30 | |||
31 | /** @var string Bookmark's title */ | ||
32 | protected $title; | ||
33 | |||
34 | /** @var string Raw bookmark's description */ | ||
35 | protected $description; | ||
36 | |||
37 | /** @var array List of bookmark's tags */ | ||
38 | protected $tags; | ||
39 | |||
40 | /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */ | ||
41 | protected $thumbnail; | ||
42 | |||
43 | /** @var bool Set to true if the bookmark is set as sticky */ | ||
44 | protected $sticky; | ||
45 | |||
46 | /** @var DateTimeInterface Creation datetime */ | ||
47 | protected $created; | ||
48 | |||
49 | /** @var DateTimeInterface datetime */ | ||
50 | protected $updated; | ||
51 | |||
52 | /** @var bool True if the bookmark can only be seen while logged in */ | ||
53 | protected $private; | ||
54 | |||
55 | /** | ||
56 | * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format. | ||
57 | * | ||
58 | * @param array $data | ||
59 | * | ||
60 | * @return $this | ||
61 | */ | ||
62 | public function fromArray($data) | ||
63 | { | ||
64 | $this->id = $data['id']; | ||
65 | $this->shortUrl = $data['shorturl']; | ||
66 | $this->url = $data['url']; | ||
67 | $this->title = $data['title']; | ||
68 | $this->description = $data['description']; | ||
69 | $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null; | ||
70 | $this->sticky = isset($data['sticky']) ? $data['sticky'] : false; | ||
71 | $this->created = $data['created']; | ||
72 | if (is_array($data['tags'])) { | ||
73 | $this->tags = $data['tags']; | ||
74 | } else { | ||
75 | $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); | ||
76 | } | ||
77 | if (! empty($data['updated'])) { | ||
78 | $this->updated = $data['updated']; | ||
79 | } | ||
80 | $this->private = $data['private'] ? true : false; | ||
81 | |||
82 | return $this; | ||
83 | } | ||
84 | |||
85 | /** | ||
86 | * Make sure that the current instance of Bookmark is valid and can be saved into the data store. | ||
87 | * A valid link requires: | ||
88 | * - an integer ID | ||
89 | * - a short URL (for permalinks) | ||
90 | * - a creation date | ||
91 | * | ||
92 | * This function also initialize optional empty fields: | ||
93 | * - the URL with the permalink | ||
94 | * - the title with the URL | ||
95 | * | ||
96 | * @throws InvalidBookmarkException | ||
97 | */ | ||
98 | public function validate() | ||
99 | { | ||
100 | if ($this->id === null | ||
101 | || ! is_int($this->id) | ||
102 | || empty($this->shortUrl) | ||
103 | || empty($this->created) | ||
104 | || ! $this->created instanceof DateTimeInterface | ||
105 | ) { | ||
106 | throw new InvalidBookmarkException($this); | ||
107 | } | ||
108 | if (empty($this->url)) { | ||
109 | $this->url = '/shaare/'. $this->shortUrl; | ||
110 | } | ||
111 | if (empty($this->title)) { | ||
112 | $this->title = $this->url; | ||
113 | } | ||
114 | } | ||
115 | |||
116 | /** | ||
117 | * Set the Id. | ||
118 | * If they're not already initialized, this function also set: | ||
119 | * - created: with the current datetime | ||
120 | * - shortUrl: with a generated small hash from the date and the given ID | ||
121 | * | ||
122 | * @param int $id | ||
123 | * | ||
124 | * @return Bookmark | ||
125 | */ | ||
126 | public function setId($id) | ||
127 | { | ||
128 | $this->id = $id; | ||
129 | if (empty($this->created)) { | ||
130 | $this->created = new DateTime(); | ||
131 | } | ||
132 | if (empty($this->shortUrl)) { | ||
133 | $this->shortUrl = link_small_hash($this->created, $this->id); | ||
134 | } | ||
135 | |||
136 | return $this; | ||
137 | } | ||
138 | |||
139 | /** | ||
140 | * Get the Id. | ||
141 | * | ||
142 | * @return int | ||
143 | */ | ||
144 | public function getId() | ||
145 | { | ||
146 | return $this->id; | ||
147 | } | ||
148 | |||
149 | /** | ||
150 | * Get the ShortUrl. | ||
151 | * | ||
152 | * @return string | ||
153 | */ | ||
154 | public function getShortUrl() | ||
155 | { | ||
156 | return $this->shortUrl; | ||
157 | } | ||
158 | |||
159 | /** | ||
160 | * Get the Url. | ||
161 | * | ||
162 | * @return string | ||
163 | */ | ||
164 | public function getUrl() | ||
165 | { | ||
166 | return $this->url; | ||
167 | } | ||
168 | |||
169 | /** | ||
170 | * Get the Title. | ||
171 | * | ||
172 | * @return string | ||
173 | */ | ||
174 | public function getTitle() | ||
175 | { | ||
176 | return $this->title; | ||
177 | } | ||
178 | |||
179 | /** | ||
180 | * Get the Description. | ||
181 | * | ||
182 | * @return string | ||
183 | */ | ||
184 | public function getDescription() | ||
185 | { | ||
186 | return ! empty($this->description) ? $this->description : ''; | ||
187 | } | ||
188 | |||
189 | /** | ||
190 | * Get the Created. | ||
191 | * | ||
192 | * @return DateTimeInterface | ||
193 | */ | ||
194 | public function getCreated() | ||
195 | { | ||
196 | return $this->created; | ||
197 | } | ||
198 | |||
199 | /** | ||
200 | * Get the Updated. | ||
201 | * | ||
202 | * @return DateTimeInterface | ||
203 | */ | ||
204 | public function getUpdated() | ||
205 | { | ||
206 | return $this->updated; | ||
207 | } | ||
208 | |||
209 | /** | ||
210 | * Set the ShortUrl. | ||
211 | * | ||
212 | * @param string $shortUrl | ||
213 | * | ||
214 | * @return Bookmark | ||
215 | */ | ||
216 | public function setShortUrl($shortUrl) | ||
217 | { | ||
218 | $this->shortUrl = $shortUrl; | ||
219 | |||
220 | return $this; | ||
221 | } | ||
222 | |||
223 | /** | ||
224 | * Set the Url. | ||
225 | * | ||
226 | * @param string $url | ||
227 | * @param array $allowedProtocols | ||
228 | * | ||
229 | * @return Bookmark | ||
230 | */ | ||
231 | public function setUrl($url, $allowedProtocols = []) | ||
232 | { | ||
233 | $url = trim($url); | ||
234 | if (! empty($url)) { | ||
235 | $url = whitelist_protocols($url, $allowedProtocols); | ||
236 | } | ||
237 | $this->url = $url; | ||
238 | |||
239 | return $this; | ||
240 | } | ||
241 | |||
242 | /** | ||
243 | * Set the Title. | ||
244 | * | ||
245 | * @param string $title | ||
246 | * | ||
247 | * @return Bookmark | ||
248 | */ | ||
249 | public function setTitle($title) | ||
250 | { | ||
251 | $this->title = trim($title); | ||
252 | |||
253 | return $this; | ||
254 | } | ||
255 | |||
256 | /** | ||
257 | * Set the Description. | ||
258 | * | ||
259 | * @param string $description | ||
260 | * | ||
261 | * @return Bookmark | ||
262 | */ | ||
263 | public function setDescription($description) | ||
264 | { | ||
265 | $this->description = $description; | ||
266 | |||
267 | return $this; | ||
268 | } | ||
269 | |||
270 | /** | ||
271 | * Set the Created. | ||
272 | * Note: you shouldn't set this manually except for special cases (like bookmark import) | ||
273 | * | ||
274 | * @param DateTimeInterface $created | ||
275 | * | ||
276 | * @return Bookmark | ||
277 | */ | ||
278 | public function setCreated($created) | ||
279 | { | ||
280 | $this->created = $created; | ||
281 | |||
282 | return $this; | ||
283 | } | ||
284 | |||
285 | /** | ||
286 | * Set the Updated. | ||
287 | * | ||
288 | * @param DateTimeInterface $updated | ||
289 | * | ||
290 | * @return Bookmark | ||
291 | */ | ||
292 | public function setUpdated($updated) | ||
293 | { | ||
294 | $this->updated = $updated; | ||
295 | |||
296 | return $this; | ||
297 | } | ||
298 | |||
299 | /** | ||
300 | * Get the Private. | ||
301 | * | ||
302 | * @return bool | ||
303 | */ | ||
304 | public function isPrivate() | ||
305 | { | ||
306 | return $this->private ? true : false; | ||
307 | } | ||
308 | |||
309 | /** | ||
310 | * Set the Private. | ||
311 | * | ||
312 | * @param bool $private | ||
313 | * | ||
314 | * @return Bookmark | ||
315 | */ | ||
316 | public function setPrivate($private) | ||
317 | { | ||
318 | $this->private = $private ? true : false; | ||
319 | |||
320 | return $this; | ||
321 | } | ||
322 | |||
323 | /** | ||
324 | * Get the Tags. | ||
325 | * | ||
326 | * @return array | ||
327 | */ | ||
328 | public function getTags() | ||
329 | { | ||
330 | return is_array($this->tags) ? $this->tags : []; | ||
331 | } | ||
332 | |||
333 | /** | ||
334 | * Set the Tags. | ||
335 | * | ||
336 | * @param array $tags | ||
337 | * | ||
338 | * @return Bookmark | ||
339 | */ | ||
340 | public function setTags($tags) | ||
341 | { | ||
342 | $this->setTagsString(implode(' ', $tags)); | ||
343 | |||
344 | return $this; | ||
345 | } | ||
346 | |||
347 | /** | ||
348 | * Get the Thumbnail. | ||
349 | * | ||
350 | * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found | ||
351 | */ | ||
352 | public function getThumbnail() | ||
353 | { | ||
354 | return !$this->isNote() ? $this->thumbnail : false; | ||
355 | } | ||
356 | |||
357 | /** | ||
358 | * Set the Thumbnail. | ||
359 | * | ||
360 | * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found | ||
361 | * | ||
362 | * @return Bookmark | ||
363 | */ | ||
364 | public function setThumbnail($thumbnail) | ||
365 | { | ||
366 | $this->thumbnail = $thumbnail; | ||
367 | |||
368 | return $this; | ||
369 | } | ||
370 | |||
371 | /** | ||
372 | * Get the Sticky. | ||
373 | * | ||
374 | * @return bool | ||
375 | */ | ||
376 | public function isSticky() | ||
377 | { | ||
378 | return $this->sticky ? true : false; | ||
379 | } | ||
380 | |||
381 | /** | ||
382 | * Set the Sticky. | ||
383 | * | ||
384 | * @param bool $sticky | ||
385 | * | ||
386 | * @return Bookmark | ||
387 | */ | ||
388 | public function setSticky($sticky) | ||
389 | { | ||
390 | $this->sticky = $sticky ? true : false; | ||
391 | |||
392 | return $this; | ||
393 | } | ||
394 | |||
395 | /** | ||
396 | * @return string Bookmark's tags as a string, separated by a space | ||
397 | */ | ||
398 | public function getTagsString() | ||
399 | { | ||
400 | return implode(' ', $this->getTags()); | ||
401 | } | ||
402 | |||
403 | /** | ||
404 | * @return bool | ||
405 | */ | ||
406 | public function isNote() | ||
407 | { | ||
408 | // 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] === '?'; | ||
410 | } | ||
411 | |||
412 | /** | ||
413 | * Set tags from a string. | ||
414 | * Note: | ||
415 | * - tags must be separated whether by a space or a comma | ||
416 | * - multiple spaces will be removed | ||
417 | * - trailing dash in tags will be removed | ||
418 | * | ||
419 | * @param string $tags | ||
420 | * | ||
421 | * @return $this | ||
422 | */ | ||
423 | public function setTagsString($tags) | ||
424 | { | ||
425 | // Remove first '-' char in tags. | ||
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 | |||
432 | $this->tags = $tags; | ||
433 | |||
434 | return $this; | ||
435 | } | ||
436 | |||
437 | /** | ||
438 | * Rename a tag in tags list. | ||
439 | * | ||
440 | * @param string $fromTag | ||
441 | * @param string $toTag | ||
442 | */ | ||
443 | public function renameTag($fromTag, $toTag) | ||
444 | { | ||
445 | if (($pos = array_search($fromTag, $this->tags)) !== false) { | ||
446 | $this->tags[$pos] = trim($toTag); | ||
447 | } | ||
448 | } | ||
449 | |||
450 | /** | ||
451 | * Delete a tag from tags list. | ||
452 | * | ||
453 | * @param string $tag | ||
454 | */ | ||
455 | public function deleteTag($tag) | ||
456 | { | ||
457 | if (($pos = array_search($tag, $this->tags)) !== false) { | ||
458 | unset($this->tags[$pos]); | ||
459 | $this->tags = array_values($this->tags); | ||
460 | } | ||
461 | } | ||
462 | } | ||
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php new file mode 100644 index 00000000..3bd5eb20 --- /dev/null +++ b/application/bookmark/BookmarkArray.php | |||
@@ -0,0 +1,260 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use Shaarli\Bookmark\Exception\InvalidBookmarkException; | ||
6 | |||
7 | /** | ||
8 | * Class BookmarkArray | ||
9 | * | ||
10 | * Implementing ArrayAccess, this allows us to use the bookmark list | ||
11 | * as an array and iterate over it. | ||
12 | * | ||
13 | * @package Shaarli\Bookmark | ||
14 | */ | ||
15 | class BookmarkArray implements \Iterator, \Countable, \ArrayAccess | ||
16 | { | ||
17 | /** | ||
18 | * @var Bookmark[] | ||
19 | */ | ||
20 | protected $bookmarks; | ||
21 | |||
22 | /** | ||
23 | * @var array List of all bookmarks IDS mapped with their array offset. | ||
24 | * Map: id->offset. | ||
25 | */ | ||
26 | protected $ids; | ||
27 | |||
28 | /** | ||
29 | * @var int Position in the $this->keys array (for the Iterator interface) | ||
30 | */ | ||
31 | protected $position; | ||
32 | |||
33 | /** | ||
34 | * @var array List of offset keys (for the Iterator interface implementation) | ||
35 | */ | ||
36 | protected $keys; | ||
37 | |||
38 | /** | ||
39 | * @var array List of all recorded URLs (key=url, value=bookmark offset) | ||
40 | * for fast reserve search (url-->bookmark offset) | ||
41 | */ | ||
42 | protected $urls; | ||
43 | |||
44 | public function __construct() | ||
45 | { | ||
46 | $this->ids = []; | ||
47 | $this->bookmarks = []; | ||
48 | $this->keys = []; | ||
49 | $this->urls = []; | ||
50 | $this->position = 0; | ||
51 | } | ||
52 | |||
53 | /** | ||
54 | * Countable - Counts elements of an object | ||
55 | * | ||
56 | * @return int Number of bookmarks | ||
57 | */ | ||
58 | public function count() | ||
59 | { | ||
60 | return count($this->bookmarks); | ||
61 | } | ||
62 | |||
63 | /** | ||
64 | * ArrayAccess - Assigns a value to the specified offset | ||
65 | * | ||
66 | * @param int $offset Bookmark ID | ||
67 | * @param Bookmark $value instance | ||
68 | * | ||
69 | * @throws InvalidBookmarkException | ||
70 | */ | ||
71 | public function offsetSet($offset, $value) | ||
72 | { | ||
73 | if (! $value instanceof Bookmark | ||
74 | || $value->getId() === null || empty($value->getUrl()) | ||
75 | || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) | ||
76 | || $offset !== null && $offset !== $value->getId() | ||
77 | ) { | ||
78 | throw new InvalidBookmarkException($value); | ||
79 | } | ||
80 | |||
81 | // If the bookmark exists, we reuse the real offset, otherwise new entry | ||
82 | if ($offset !== null) { | ||
83 | $existing = $this->getBookmarkOffset($offset); | ||
84 | } else { | ||
85 | $existing = $this->getBookmarkOffset($value->getId()); | ||
86 | } | ||
87 | |||
88 | if ($existing !== null) { | ||
89 | $offset = $existing; | ||
90 | } else { | ||
91 | $offset = count($this->bookmarks); | ||
92 | } | ||
93 | |||
94 | $this->bookmarks[$offset] = $value; | ||
95 | $this->urls[$value->getUrl()] = $offset; | ||
96 | $this->ids[$value->getId()] = $offset; | ||
97 | } | ||
98 | |||
99 | /** | ||
100 | * ArrayAccess - Whether or not an offset exists | ||
101 | * | ||
102 | * @param int $offset Bookmark ID | ||
103 | * | ||
104 | * @return bool true if it exists, false otherwise | ||
105 | */ | ||
106 | public function offsetExists($offset) | ||
107 | { | ||
108 | return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks); | ||
109 | } | ||
110 | |||
111 | /** | ||
112 | * ArrayAccess - Unsets an offset | ||
113 | * | ||
114 | * @param int $offset Bookmark ID | ||
115 | */ | ||
116 | public function offsetUnset($offset) | ||
117 | { | ||
118 | $realOffset = $this->getBookmarkOffset($offset); | ||
119 | $url = $this->bookmarks[$realOffset]->getUrl(); | ||
120 | unset($this->urls[$url]); | ||
121 | unset($this->ids[$offset]); | ||
122 | unset($this->bookmarks[$realOffset]); | ||
123 | } | ||
124 | |||
125 | /** | ||
126 | * ArrayAccess - Returns the value at specified offset | ||
127 | * | ||
128 | * @param int $offset Bookmark ID | ||
129 | * | ||
130 | * @return Bookmark|null The Bookmark if found, null otherwise | ||
131 | */ | ||
132 | public function offsetGet($offset) | ||
133 | { | ||
134 | $realOffset = $this->getBookmarkOffset($offset); | ||
135 | return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null; | ||
136 | } | ||
137 | |||
138 | /** | ||
139 | * Iterator - Returns the current element | ||
140 | * | ||
141 | * @return Bookmark corresponding to the current position | ||
142 | */ | ||
143 | public function current() | ||
144 | { | ||
145 | return $this[$this->keys[$this->position]]; | ||
146 | } | ||
147 | |||
148 | /** | ||
149 | * Iterator - Returns the key of the current element | ||
150 | * | ||
151 | * @return int Bookmark ID corresponding to the current position | ||
152 | */ | ||
153 | public function key() | ||
154 | { | ||
155 | return $this->keys[$this->position]; | ||
156 | } | ||
157 | |||
158 | /** | ||
159 | * Iterator - Moves forward to next element | ||
160 | */ | ||
161 | public function next() | ||
162 | { | ||
163 | ++$this->position; | ||
164 | } | ||
165 | |||
166 | /** | ||
167 | * Iterator - Rewinds the Iterator to the first element | ||
168 | * | ||
169 | * Entries are sorted by date (latest first) | ||
170 | */ | ||
171 | public function rewind() | ||
172 | { | ||
173 | $this->keys = array_keys($this->ids); | ||
174 | $this->position = 0; | ||
175 | } | ||
176 | |||
177 | /** | ||
178 | * Iterator - Checks if current position is valid | ||
179 | * | ||
180 | * @return bool true if the current Bookmark ID exists, false otherwise | ||
181 | */ | ||
182 | public function valid() | ||
183 | { | ||
184 | return isset($this->keys[$this->position]); | ||
185 | } | ||
186 | |||
187 | /** | ||
188 | * Returns a bookmark offset in bookmarks array from its unique ID. | ||
189 | * | ||
190 | * @param int $id Persistent ID of a bookmark. | ||
191 | * | ||
192 | * @return int Real offset in local array, or null if doesn't exist. | ||
193 | */ | ||
194 | protected function getBookmarkOffset($id) | ||
195 | { | ||
196 | if (isset($this->ids[$id])) { | ||
197 | return $this->ids[$id]; | ||
198 | } | ||
199 | return null; | ||
200 | } | ||
201 | |||
202 | /** | ||
203 | * Return the next key for bookmark creation. | ||
204 | * E.g. If the last ID is 597, the next will be 598. | ||
205 | * | ||
206 | * @return int next ID. | ||
207 | */ | ||
208 | public function getNextId() | ||
209 | { | ||
210 | if (!empty($this->ids)) { | ||
211 | return max(array_keys($this->ids)) + 1; | ||
212 | } | ||
213 | return 0; | ||
214 | } | ||
215 | |||
216 | /** | ||
217 | * @param $url | ||
218 | * | ||
219 | * @return Bookmark|null | ||
220 | */ | ||
221 | public function getByUrl($url) | ||
222 | { | ||
223 | if (! empty($url) | ||
224 | && isset($this->urls[$url]) | ||
225 | && isset($this->bookmarks[$this->urls[$url]]) | ||
226 | ) { | ||
227 | return $this->bookmarks[$this->urls[$url]]; | ||
228 | } | ||
229 | return null; | ||
230 | } | ||
231 | |||
232 | /** | ||
233 | * Reorder links by creation date (newest first). | ||
234 | * | ||
235 | * Also update the urls and ids mapping arrays. | ||
236 | * | ||
237 | * @param string $order ASC|DESC | ||
238 | * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first | ||
239 | */ | ||
240 | public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void | ||
241 | { | ||
242 | $order = $order === 'ASC' ? -1 : 1; | ||
243 | // Reorder array by dates. | ||
244 | usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) { | ||
245 | /** @var $a Bookmark */ | ||
246 | /** @var $b Bookmark */ | ||
247 | if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) { | ||
248 | return $a->isSticky() ? -1 : 1; | ||
249 | } | ||
250 | return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; | ||
251 | }); | ||
252 | |||
253 | $this->urls = []; | ||
254 | $this->ids = []; | ||
255 | foreach ($this->bookmarks as $key => $bookmark) { | ||
256 | $this->urls[$bookmark->getUrl()] = $key; | ||
257 | $this->ids[$bookmark->getId()] = $key; | ||
258 | } | ||
259 | } | ||
260 | } | ||
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php new file mode 100644 index 00000000..c9ec2609 --- /dev/null +++ b/application/bookmark/BookmarkFileService.php | |||
@@ -0,0 +1,407 @@ | |||
1 | <?php | ||
2 | |||
3 | |||
4 | namespace Shaarli\Bookmark; | ||
5 | |||
6 | |||
7 | use Exception; | ||
8 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
9 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | ||
10 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | ||
11 | use Shaarli\Config\ConfigManager; | ||
12 | use Shaarli\Formatter\BookmarkMarkdownFormatter; | ||
13 | use Shaarli\History; | ||
14 | use Shaarli\Legacy\LegacyLinkDB; | ||
15 | use Shaarli\Legacy\LegacyUpdater; | ||
16 | use Shaarli\Render\PageCacheManager; | ||
17 | use Shaarli\Updater\UpdaterUtils; | ||
18 | |||
19 | /** | ||
20 | * Class BookmarksService | ||
21 | * | ||
22 | * This is the entry point to manipulate the bookmark DB. | ||
23 | * It manipulates loads links from a file data store containing all bookmarks. | ||
24 | * | ||
25 | * It also triggers the legacy format (bookmarks as arrays) migration. | ||
26 | */ | ||
27 | class BookmarkFileService implements BookmarkServiceInterface | ||
28 | { | ||
29 | /** @var Bookmark[] instance */ | ||
30 | protected $bookmarks; | ||
31 | |||
32 | /** @var BookmarkIO instance */ | ||
33 | protected $bookmarksIO; | ||
34 | |||
35 | /** @var BookmarkFilter */ | ||
36 | protected $bookmarkFilter; | ||
37 | |||
38 | /** @var ConfigManager instance */ | ||
39 | protected $conf; | ||
40 | |||
41 | /** @var History instance */ | ||
42 | protected $history; | ||
43 | |||
44 | /** @var PageCacheManager instance */ | ||
45 | protected $pageCacheManager; | ||
46 | |||
47 | /** @var bool true for logged in users. Default value to retrieve private bookmarks. */ | ||
48 | protected $isLoggedIn; | ||
49 | |||
50 | /** | ||
51 | * @inheritDoc | ||
52 | */ | ||
53 | public function __construct(ConfigManager $conf, History $history, $isLoggedIn) | ||
54 | { | ||
55 | $this->conf = $conf; | ||
56 | $this->history = $history; | ||
57 | $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn); | ||
58 | $this->bookmarksIO = new BookmarkIO($this->conf); | ||
59 | $this->isLoggedIn = $isLoggedIn; | ||
60 | |||
61 | if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { | ||
62 | $this->bookmarks = []; | ||
63 | } else { | ||
64 | try { | ||
65 | $this->bookmarks = $this->bookmarksIO->read(); | ||
66 | } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) { | ||
67 | $this->bookmarks = new BookmarkArray(); | ||
68 | |||
69 | if ($this->isLoggedIn) { | ||
70 | // Datastore file does not exists, we initialize it with default bookmarks. | ||
71 | if ($e instanceof DatastoreNotInitializedException) { | ||
72 | $this->initialize(); | ||
73 | } else { | ||
74 | $this->save(); | ||
75 | } | ||
76 | } | ||
77 | } | ||
78 | |||
79 | if (! $this->bookmarks instanceof BookmarkArray) { | ||
80 | $this->migrate(); | ||
81 | exit( | ||
82 | '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.' | ||
84 | ); | ||
85 | } | ||
86 | } | ||
87 | |||
88 | $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); | ||
89 | } | ||
90 | |||
91 | /** | ||
92 | * @inheritDoc | ||
93 | */ | ||
94 | public function findByHash($hash) | ||
95 | { | ||
96 | $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); | ||
97 | // PHP 7.3 introduced array_key_first() to avoid this hack | ||
98 | $first = reset($bookmark); | ||
99 | if (! $this->isLoggedIn && $first->isPrivate()) { | ||
100 | throw new Exception('Not authorized'); | ||
101 | } | ||
102 | |||
103 | return $first; | ||
104 | } | ||
105 | |||
106 | /** | ||
107 | * @inheritDoc | ||
108 | */ | ||
109 | public function findByUrl($url) | ||
110 | { | ||
111 | return $this->bookmarks->getByUrl($url); | ||
112 | } | ||
113 | |||
114 | /** | ||
115 | * @inheritDoc | ||
116 | */ | ||
117 | public function search( | ||
118 | $request = [], | ||
119 | $visibility = null, | ||
120 | $caseSensitive = false, | ||
121 | $untaggedOnly = false, | ||
122 | bool $ignoreSticky = false | ||
123 | ) { | ||
124 | if ($visibility === null) { | ||
125 | $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; | ||
126 | } | ||
127 | |||
128 | // Filter bookmark database according to parameters. | ||
129 | $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; | ||
130 | $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; | ||
131 | |||
132 | if ($ignoreSticky) { | ||
133 | $this->bookmarks->reorder('DESC', true); | ||
134 | } | ||
135 | |||
136 | return $this->bookmarkFilter->filter( | ||
137 | BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, | ||
138 | [$searchtags, $searchterm], | ||
139 | $caseSensitive, | ||
140 | $visibility, | ||
141 | $untaggedOnly | ||
142 | ); | ||
143 | } | ||
144 | |||
145 | /** | ||
146 | * @inheritDoc | ||
147 | */ | ||
148 | public function get($id, $visibility = null) | ||
149 | { | ||
150 | if (! isset($this->bookmarks[$id])) { | ||
151 | throw new BookmarkNotFoundException(); | ||
152 | } | ||
153 | |||
154 | if ($visibility === null) { | ||
155 | $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; | ||
156 | } | ||
157 | |||
158 | $bookmark = $this->bookmarks[$id]; | ||
159 | if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') | ||
160 | || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') | ||
161 | ) { | ||
162 | throw new Exception('Unauthorized'); | ||
163 | } | ||
164 | |||
165 | return $bookmark; | ||
166 | } | ||
167 | |||
168 | /** | ||
169 | * @inheritDoc | ||
170 | */ | ||
171 | public function set($bookmark, $save = true) | ||
172 | { | ||
173 | if (true !== $this->isLoggedIn) { | ||
174 | throw new Exception(t('You\'re not authorized to alter the datastore')); | ||
175 | } | ||
176 | if (! $bookmark instanceof Bookmark) { | ||
177 | throw new Exception(t('Provided data is invalid')); | ||
178 | } | ||
179 | if (! isset($this->bookmarks[$bookmark->getId()])) { | ||
180 | throw new BookmarkNotFoundException(); | ||
181 | } | ||
182 | $bookmark->validate(); | ||
183 | |||
184 | $bookmark->setUpdated(new \DateTime()); | ||
185 | $this->bookmarks[$bookmark->getId()] = $bookmark; | ||
186 | if ($save === true) { | ||
187 | $this->save(); | ||
188 | $this->history->updateLink($bookmark); | ||
189 | } | ||
190 | return $this->bookmarks[$bookmark->getId()]; | ||
191 | } | ||
192 | |||
193 | /** | ||
194 | * @inheritDoc | ||
195 | */ | ||
196 | public function add($bookmark, $save = true) | ||
197 | { | ||
198 | if (true !== $this->isLoggedIn) { | ||
199 | throw new Exception(t('You\'re not authorized to alter the datastore')); | ||
200 | } | ||
201 | if (! $bookmark instanceof Bookmark) { | ||
202 | throw new Exception(t('Provided data is invalid')); | ||
203 | } | ||
204 | if (! empty($bookmark->getId())) { | ||
205 | throw new Exception(t('This bookmarks already exists')); | ||
206 | } | ||
207 | $bookmark->setId($this->bookmarks->getNextId()); | ||
208 | $bookmark->validate(); | ||
209 | |||
210 | $this->bookmarks[$bookmark->getId()] = $bookmark; | ||
211 | if ($save === true) { | ||
212 | $this->save(); | ||
213 | $this->history->addLink($bookmark); | ||
214 | } | ||
215 | return $this->bookmarks[$bookmark->getId()]; | ||
216 | } | ||
217 | |||
218 | /** | ||
219 | * @inheritDoc | ||
220 | */ | ||
221 | public function addOrSet($bookmark, $save = true) | ||
222 | { | ||
223 | if (true !== $this->isLoggedIn) { | ||
224 | throw new Exception(t('You\'re not authorized to alter the datastore')); | ||
225 | } | ||
226 | if (! $bookmark instanceof Bookmark) { | ||
227 | throw new Exception('Provided data is invalid'); | ||
228 | } | ||
229 | if ($bookmark->getId() === null) { | ||
230 | return $this->add($bookmark, $save); | ||
231 | } | ||
232 | return $this->set($bookmark, $save); | ||
233 | } | ||
234 | |||
235 | /** | ||
236 | * @inheritDoc | ||
237 | */ | ||
238 | public function remove($bookmark, $save = true) | ||
239 | { | ||
240 | if (true !== $this->isLoggedIn) { | ||
241 | throw new Exception(t('You\'re not authorized to alter the datastore')); | ||
242 | } | ||
243 | if (! $bookmark instanceof Bookmark) { | ||
244 | throw new Exception(t('Provided data is invalid')); | ||
245 | } | ||
246 | if (! isset($this->bookmarks[$bookmark->getId()])) { | ||
247 | throw new BookmarkNotFoundException(); | ||
248 | } | ||
249 | |||
250 | unset($this->bookmarks[$bookmark->getId()]); | ||
251 | if ($save === true) { | ||
252 | $this->save(); | ||
253 | $this->history->deleteLink($bookmark); | ||
254 | } | ||
255 | } | ||
256 | |||
257 | /** | ||
258 | * @inheritDoc | ||
259 | */ | ||
260 | public function exists($id, $visibility = null) | ||
261 | { | ||
262 | if (! isset($this->bookmarks[$id])) { | ||
263 | return false; | ||
264 | } | ||
265 | |||
266 | if ($visibility === null) { | ||
267 | $visibility = $this->isLoggedIn ? 'all' : 'public'; | ||
268 | } | ||
269 | |||
270 | $bookmark = $this->bookmarks[$id]; | ||
271 | if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') | ||
272 | || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') | ||
273 | ) { | ||
274 | return false; | ||
275 | } | ||
276 | |||
277 | return true; | ||
278 | } | ||
279 | |||
280 | /** | ||
281 | * @inheritDoc | ||
282 | */ | ||
283 | public function count($visibility = null) | ||
284 | { | ||
285 | return count($this->search([], $visibility)); | ||
286 | } | ||
287 | |||
288 | /** | ||
289 | * @inheritDoc | ||
290 | */ | ||
291 | public function save() | ||
292 | { | ||
293 | if (true !== $this->isLoggedIn) { | ||
294 | // TODO: raise an Exception instead | ||
295 | die('You are not authorized to change the database.'); | ||
296 | } | ||
297 | |||
298 | $this->bookmarks->reorder(); | ||
299 | $this->bookmarksIO->write($this->bookmarks); | ||
300 | $this->pageCacheManager->invalidateCaches(); | ||
301 | } | ||
302 | |||
303 | /** | ||
304 | * @inheritDoc | ||
305 | */ | ||
306 | public function bookmarksCountPerTag($filteringTags = [], $visibility = null) | ||
307 | { | ||
308 | $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); | ||
309 | $tags = []; | ||
310 | $caseMapping = []; | ||
311 | foreach ($bookmarks as $bookmark) { | ||
312 | foreach ($bookmark->getTags() as $tag) { | ||
313 | if (empty($tag) | ||
314 | || (! $this->isLoggedIn && startsWith($tag, '.')) | ||
315 | || $tag === BookmarkMarkdownFormatter::NO_MD_TAG | ||
316 | || in_array($tag, $filteringTags, true) | ||
317 | ) { | ||
318 | continue; | ||
319 | } | ||
320 | |||
321 | // The first case found will be displayed. | ||
322 | if (!isset($caseMapping[strtolower($tag)])) { | ||
323 | $caseMapping[strtolower($tag)] = $tag; | ||
324 | $tags[$caseMapping[strtolower($tag)]] = 0; | ||
325 | } | ||
326 | $tags[$caseMapping[strtolower($tag)]]++; | ||
327 | } | ||
328 | } | ||
329 | |||
330 | /* | ||
331 | * Formerly used arsort(), which doesn't define the sort behaviour for equal values. | ||
332 | * Also, this function doesn't produce the same result between PHP 5.6 and 7. | ||
333 | * | ||
334 | * So we now use array_multisort() to sort tags by DESC occurrences, | ||
335 | * then ASC alphabetically for equal values. | ||
336 | * | ||
337 | * @see https://github.com/shaarli/Shaarli/issues/1142 | ||
338 | */ | ||
339 | $keys = array_keys($tags); | ||
340 | $tmpTags = array_combine($keys, $keys); | ||
341 | array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); | ||
342 | return $tags; | ||
343 | } | ||
344 | |||
345 | /** | ||
346 | * @inheritDoc | ||
347 | */ | ||
348 | public function days() | ||
349 | { | ||
350 | $bookmarkDays = []; | ||
351 | foreach ($this->search() as $bookmark) { | ||
352 | $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; | ||
353 | } | ||
354 | $bookmarkDays = array_keys($bookmarkDays); | ||
355 | sort($bookmarkDays); | ||
356 | |||
357 | return $bookmarkDays; | ||
358 | } | ||
359 | |||
360 | /** | ||
361 | * @inheritDoc | ||
362 | */ | ||
363 | public function filterDay($request) | ||
364 | { | ||
365 | $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; | ||
366 | |||
367 | return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility); | ||
368 | } | ||
369 | |||
370 | /** | ||
371 | * @inheritDoc | ||
372 | */ | ||
373 | public function initialize() | ||
374 | { | ||
375 | $initializer = new BookmarkInitializer($this); | ||
376 | $initializer->initialize(); | ||
377 | |||
378 | if (true === $this->isLoggedIn) { | ||
379 | $this->save(); | ||
380 | } | ||
381 | } | ||
382 | |||
383 | /** | ||
384 | * Handles migration to the new database format (BookmarksArray). | ||
385 | */ | ||
386 | protected function migrate() | ||
387 | { | ||
388 | $bookmarkDb = new LegacyLinkDB( | ||
389 | $this->conf->get('resource.datastore'), | ||
390 | true, | ||
391 | false | ||
392 | ); | ||
393 | $updater = new LegacyUpdater( | ||
394 | UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), | ||
395 | $bookmarkDb, | ||
396 | $this->conf, | ||
397 | true | ||
398 | ); | ||
399 | $newUpdates = $updater->update(); | ||
400 | if (! empty($newUpdates)) { | ||
401 | UpdaterUtils::write_updates_file( | ||
402 | $this->conf->get('resource.updates'), | ||
403 | $updater->getDoneUpdates() | ||
404 | ); | ||
405 | } | ||
406 | } | ||
407 | } | ||
diff --git a/application/bookmark/LinkFilter.php b/application/bookmark/BookmarkFilter.php index 9b966307..6636bbfe 100644 --- a/application/bookmark/LinkFilter.php +++ b/application/bookmark/BookmarkFilter.php | |||
@@ -3,14 +3,14 @@ | |||
3 | namespace Shaarli\Bookmark; | 3 | namespace Shaarli\Bookmark; |
4 | 4 | ||
5 | use Exception; | 5 | use Exception; |
6 | use Shaarli\Bookmark\Exception\LinkNotFoundException; | 6 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; |
7 | 7 | ||
8 | /** | 8 | /** |
9 | * Class LinkFilter. | 9 | * Class LinkFilter. |
10 | * | 10 | * |
11 | * Perform search and filter operation on link data list. | 11 | * Perform search and filter operation on link data list. |
12 | */ | 12 | */ |
13 | class LinkFilter | 13 | class BookmarkFilter |
14 | { | 14 | { |
15 | /** | 15 | /** |
16 | * @var string permalinks. | 16 | * @var string permalinks. |
@@ -33,33 +33,49 @@ class LinkFilter | |||
33 | public static $FILTER_DAY = 'FILTER_DAY'; | 33 | public static $FILTER_DAY = 'FILTER_DAY'; |
34 | 34 | ||
35 | /** | 35 | /** |
36 | * @var string filter by day. | ||
37 | */ | ||
38 | public static $DEFAULT = 'NO_FILTER'; | ||
39 | |||
40 | /** @var string Visibility: all */ | ||
41 | public static $ALL = 'all'; | ||
42 | |||
43 | /** @var string Visibility: public */ | ||
44 | public static $PUBLIC = 'public'; | ||
45 | |||
46 | /** @var string Visibility: private */ | ||
47 | public static $PRIVATE = 'private'; | ||
48 | |||
49 | /** | ||
36 | * @var string Allowed characters for hashtags (regex syntax). | 50 | * @var string Allowed characters for hashtags (regex syntax). |
37 | */ | 51 | */ |
38 | public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}'; | 52 | public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}'; |
39 | 53 | ||
40 | /** | 54 | /** |
41 | * @var LinkDB all available links. | 55 | * @var Bookmark[] all available bookmarks. |
42 | */ | 56 | */ |
43 | private $links; | 57 | private $bookmarks; |
44 | 58 | ||
45 | /** | 59 | /** |
46 | * @param LinkDB $links initialization. | 60 | * @param Bookmark[] $bookmarks initialization. |
47 | */ | 61 | */ |
48 | public function __construct($links) | 62 | public function __construct($bookmarks) |
49 | { | 63 | { |
50 | $this->links = $links; | 64 | $this->bookmarks = $bookmarks; |
51 | } | 65 | } |
52 | 66 | ||
53 | /** | 67 | /** |
54 | * Filter links according to parameters. | 68 | * Filter bookmarks according to parameters. |
55 | * | 69 | * |
56 | * @param string $type Type of filter (eg. tags, permalink, etc.). | 70 | * @param string $type Type of filter (eg. tags, permalink, etc.). |
57 | * @param mixed $request Filter content. | 71 | * @param mixed $request Filter content. |
58 | * @param bool $casesensitive Optional: Perform case sensitive filter if true. | 72 | * @param bool $casesensitive Optional: Perform case sensitive filter if true. |
59 | * @param string $visibility Optional: return only all/private/public links | 73 | * @param string $visibility Optional: return only all/private/public bookmarks |
60 | * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG | 74 | * @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG |
61 | * | 75 | * |
62 | * @return array filtered link list. | 76 | * @return Bookmark[] filtered bookmark list. |
77 | * | ||
78 | * @throws BookmarkNotFoundException | ||
63 | */ | 79 | */ |
64 | public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) | 80 | public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) |
65 | { | 81 | { |
@@ -81,13 +97,13 @@ class LinkFilter | |||
81 | if ($untaggedonly) { | 97 | if ($untaggedonly) { |
82 | $filtered = $this->filterUntagged($visibility); | 98 | $filtered = $this->filterUntagged($visibility); |
83 | } else { | 99 | } else { |
84 | $filtered = $this->links; | 100 | $filtered = $this->bookmarks; |
85 | } | 101 | } |
86 | if (!empty($request[0])) { | 102 | if (!empty($request[0])) { |
87 | $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); | 103 | $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); |
88 | } | 104 | } |
89 | if (!empty($request[1])) { | 105 | if (!empty($request[1])) { |
90 | $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); | 106 | $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); |
91 | } | 107 | } |
92 | return $filtered; | 108 | return $filtered; |
93 | case self::$FILTER_TEXT: | 109 | case self::$FILTER_TEXT: |
@@ -99,7 +115,7 @@ class LinkFilter | |||
99 | return $this->filterTags($request, $casesensitive, $visibility); | 115 | return $this->filterTags($request, $casesensitive, $visibility); |
100 | } | 116 | } |
101 | case self::$FILTER_DAY: | 117 | case self::$FILTER_DAY: |
102 | return $this->filterDay($request); | 118 | return $this->filterDay($request, $visibility); |
103 | default: | 119 | default: |
104 | return $this->noFilter($visibility); | 120 | return $this->noFilter($visibility); |
105 | } | 121 | } |
@@ -108,21 +124,21 @@ class LinkFilter | |||
108 | /** | 124 | /** |
109 | * Unknown filter, but handle private only. | 125 | * Unknown filter, but handle private only. |
110 | * | 126 | * |
111 | * @param string $visibility Optional: return only all/private/public links | 127 | * @param string $visibility Optional: return only all/private/public bookmarks |
112 | * | 128 | * |
113 | * @return array filtered links. | 129 | * @return Bookmark[] filtered bookmarks. |
114 | */ | 130 | */ |
115 | private function noFilter($visibility = 'all') | 131 | private function noFilter($visibility = 'all') |
116 | { | 132 | { |
117 | if ($visibility === 'all') { | 133 | if ($visibility === 'all') { |
118 | return $this->links; | 134 | return $this->bookmarks; |
119 | } | 135 | } |
120 | 136 | ||
121 | $out = array(); | 137 | $out = array(); |
122 | foreach ($this->links as $key => $value) { | 138 | foreach ($this->bookmarks as $key => $value) { |
123 | if ($value['private'] && $visibility === 'private') { | 139 | if ($value->isPrivate() && $visibility === 'private') { |
124 | $out[$key] = $value; | 140 | $out[$key] = $value; |
125 | } elseif (!$value['private'] && $visibility === 'public') { | 141 | } elseif (!$value->isPrivate() && $visibility === 'public') { |
126 | $out[$key] = $value; | 142 | $out[$key] = $value; |
127 | } | 143 | } |
128 | } | 144 | } |
@@ -137,28 +153,22 @@ class LinkFilter | |||
137 | * | 153 | * |
138 | * @return array $filtered array containing permalink data. | 154 | * @return array $filtered array containing permalink data. |
139 | * | 155 | * |
140 | * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link. | 156 | * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. |
141 | */ | 157 | */ |
142 | private function filterSmallHash($smallHash) | 158 | private function filterSmallHash($smallHash) |
143 | { | 159 | { |
144 | $filtered = array(); | 160 | foreach ($this->bookmarks as $key => $l) { |
145 | foreach ($this->links as $key => $l) { | 161 | if ($smallHash == $l->getShortUrl()) { |
146 | if ($smallHash == $l['shorturl']) { | ||
147 | // Yes, this is ugly and slow | 162 | // Yes, this is ugly and slow |
148 | $filtered[$key] = $l; | 163 | return [$key => $l]; |
149 | return $filtered; | ||
150 | } | 164 | } |
151 | } | 165 | } |
152 | 166 | ||
153 | if (empty($filtered)) { | 167 | throw new BookmarkNotFoundException(); |
154 | throw new LinkNotFoundException(); | ||
155 | } | ||
156 | |||
157 | return $filtered; | ||
158 | } | 168 | } |
159 | 169 | ||
160 | /** | 170 | /** |
161 | * Returns the list of links corresponding to a full-text search | 171 | * Returns the list of bookmarks corresponding to a full-text search |
162 | * | 172 | * |
163 | * Searches: | 173 | * Searches: |
164 | * - in the URLs, title and description; | 174 | * - in the URLs, title and description; |
@@ -174,7 +184,7 @@ class LinkFilter | |||
174 | * - see https://github.com/shaarli/Shaarli/issues/75 for examples | 184 | * - see https://github.com/shaarli/Shaarli/issues/75 for examples |
175 | * | 185 | * |
176 | * @param string $searchterms search query. | 186 | * @param string $searchterms search query. |
177 | * @param string $visibility Optional: return only all/private/public links. | 187 | * @param string $visibility Optional: return only all/private/public bookmarks. |
178 | * | 188 | * |
179 | * @return array search results. | 189 | * @return array search results. |
180 | */ | 190 | */ |
@@ -206,25 +216,23 @@ class LinkFilter | |||
206 | } | 216 | } |
207 | } | 217 | } |
208 | 218 | ||
209 | $keys = array('title', 'description', 'url', 'tags'); | ||
210 | |||
211 | // Iterate over every stored link. | 219 | // Iterate over every stored link. |
212 | foreach ($this->links as $id => $link) { | 220 | foreach ($this->bookmarks as $id => $link) { |
213 | // ignore non private links when 'privatonly' is on. | 221 | // ignore non private bookmarks when 'privatonly' is on. |
214 | if ($visibility !== 'all') { | 222 | if ($visibility !== 'all') { |
215 | if (!$link['private'] && $visibility === 'private') { | 223 | if (!$link->isPrivate() && $visibility === 'private') { |
216 | continue; | 224 | continue; |
217 | } elseif ($link['private'] && $visibility === 'public') { | 225 | } elseif ($link->isPrivate() && $visibility === 'public') { |
218 | continue; | 226 | continue; |
219 | } | 227 | } |
220 | } | 228 | } |
221 | 229 | ||
222 | // Concatenate link fields to search across fields. | 230 | // Concatenate link fields to search across fields. |
223 | // Adds a '\' separator for exact search terms. | 231 | // Adds a '\' separator for exact search terms. |
224 | $content = ''; | 232 | $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; |
225 | foreach ($keys as $key) { | 233 | $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; |
226 | $content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\'; | 234 | $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; |
227 | } | 235 | $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; |
228 | 236 | ||
229 | // Be optimistic | 237 | // Be optimistic |
230 | $found = true; | 238 | $found = true; |
@@ -301,16 +309,16 @@ class LinkFilter | |||
301 | } | 309 | } |
302 | 310 | ||
303 | /** | 311 | /** |
304 | * Returns the list of links associated with a given list of tags | 312 | * Returns the list of bookmarks associated with a given list of tags |
305 | * | 313 | * |
306 | * You can specify one or more tags, separated by space or a comma, e.g. | 314 | * You can specify one or more tags, separated by space or a comma, e.g. |
307 | * print_r($mydb->filterTags('linux programming')); | 315 | * print_r($mydb->filterTags('linux programming')); |
308 | * | 316 | * |
309 | * @param string $tags list of tags separated by commas or blank spaces. | 317 | * @param string $tags list of tags separated by commas or blank spaces. |
310 | * @param bool $casesensitive ignore case if false. | 318 | * @param bool $casesensitive ignore case if false. |
311 | * @param string $visibility Optional: return only all/private/public links. | 319 | * @param string $visibility Optional: return only all/private/public bookmarks. |
312 | * | 320 | * |
313 | * @return array filtered links. | 321 | * @return array filtered bookmarks. |
314 | */ | 322 | */ |
315 | public function filterTags($tags, $casesensitive = false, $visibility = 'all') | 323 | public function filterTags($tags, $casesensitive = false, $visibility = 'all') |
316 | { | 324 | { |
@@ -326,6 +334,17 @@ class LinkFilter | |||
326 | return $this->noFilter($visibility); | 334 | return $this->noFilter($visibility); |
327 | } | 335 | } |
328 | 336 | ||
337 | // If we only have public visibility, we can't look for hidden tags | ||
338 | if ($visibility === self::$PUBLIC) { | ||
339 | $inputTags = array_values(array_filter($inputTags, function ($tag) { | ||
340 | return ! startsWith($tag, '.'); | ||
341 | })); | ||
342 | |||
343 | if (empty($inputTags)) { | ||
344 | return []; | ||
345 | } | ||
346 | } | ||
347 | |||
329 | // build regex from all tags | 348 | // build regex from all tags |
330 | $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; | 349 | $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; |
331 | if (!$casesensitive) { | 350 | if (!$casesensitive) { |
@@ -334,27 +353,27 @@ class LinkFilter | |||
334 | } | 353 | } |
335 | 354 | ||
336 | // create resulting array | 355 | // create resulting array |
337 | $filtered = array(); | 356 | $filtered = []; |
338 | 357 | ||
339 | // iterate over each link | 358 | // iterate over each link |
340 | foreach ($this->links as $key => $link) { | 359 | foreach ($this->bookmarks as $key => $link) { |
341 | // check level of visibility | 360 | // check level of visibility |
342 | // ignore non private links when 'privateonly' is on. | 361 | // ignore non private bookmarks when 'privateonly' is on. |
343 | if ($visibility !== 'all') { | 362 | if ($visibility !== 'all') { |
344 | if (!$link['private'] && $visibility === 'private') { | 363 | if (!$link->isPrivate() && $visibility === 'private') { |
345 | continue; | 364 | continue; |
346 | } elseif ($link['private'] && $visibility === 'public') { | 365 | } elseif ($link->isPrivate() && $visibility === 'public') { |
347 | continue; | 366 | continue; |
348 | } | 367 | } |
349 | } | 368 | } |
350 | $search = $link['tags']; // build search string, start with tags of current link | 369 | $search = $link->getTagsString(); // build search string, start with tags of current link |
351 | if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { | 370 | if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { |
352 | // description given and at least one possible tag found | 371 | // description given and at least one possible tag found |
353 | $descTags = array(); | 372 | $descTags = array(); |
354 | // find all tags in the form of #tag in the description | 373 | // find all tags in the form of #tag in the description |
355 | preg_match_all( | 374 | preg_match_all( |
356 | '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', | 375 | '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', |
357 | $link['description'], | 376 | $link->getDescription(), |
358 | $descTags | 377 | $descTags |
359 | ); | 378 | ); |
360 | if (count($descTags[1])) { | 379 | if (count($descTags[1])) { |
@@ -373,25 +392,25 @@ class LinkFilter | |||
373 | } | 392 | } |
374 | 393 | ||
375 | /** | 394 | /** |
376 | * Return only links without any tag. | 395 | * Return only bookmarks without any tag. |
377 | * | 396 | * |
378 | * @param string $visibility return only all/private/public links. | 397 | * @param string $visibility return only all/private/public bookmarks. |
379 | * | 398 | * |
380 | * @return array filtered links. | 399 | * @return array filtered bookmarks. |
381 | */ | 400 | */ |
382 | public function filterUntagged($visibility) | 401 | public function filterUntagged($visibility) |
383 | { | 402 | { |
384 | $filtered = []; | 403 | $filtered = []; |
385 | foreach ($this->links as $key => $link) { | 404 | foreach ($this->bookmarks as $key => $link) { |
386 | if ($visibility !== 'all') { | 405 | if ($visibility !== 'all') { |
387 | if (!$link['private'] && $visibility === 'private') { | 406 | if (!$link->isPrivate() && $visibility === 'private') { |
388 | continue; | 407 | continue; |
389 | } elseif ($link['private'] && $visibility === 'public') { | 408 | } elseif ($link->isPrivate() && $visibility === 'public') { |
390 | continue; | 409 | continue; |
391 | } | 410 | } |
392 | } | 411 | } |
393 | 412 | ||
394 | if (empty(trim($link['tags']))) { | 413 | if (empty(trim($link->getTagsString()))) { |
395 | $filtered[$key] = $link; | 414 | $filtered[$key] = $link; |
396 | } | 415 | } |
397 | } | 416 | } |
@@ -406,21 +425,26 @@ class LinkFilter | |||
406 | * print_r($mydb->filterDay('20120125')); | 425 | * print_r($mydb->filterDay('20120125')); |
407 | * | 426 | * |
408 | * @param string $day day to filter. | 427 | * @param string $day day to filter. |
409 | * | 428 | * @param string $visibility return only all/private/public bookmarks. |
429 | |||
410 | * @return array all link matching given day. | 430 | * @return array all link matching given day. |
411 | * | 431 | * |
412 | * @throws Exception if date format is invalid. | 432 | * @throws Exception if date format is invalid. |
413 | */ | 433 | */ |
414 | public function filterDay($day) | 434 | public function filterDay($day, $visibility) |
415 | { | 435 | { |
416 | if (!checkDateFormat('Ymd', $day)) { | 436 | if (!checkDateFormat('Ymd', $day)) { |
417 | throw new Exception('Invalid date format'); | 437 | throw new Exception('Invalid date format'); |
418 | } | 438 | } |
419 | 439 | ||
420 | $filtered = array(); | 440 | $filtered = []; |
421 | foreach ($this->links as $key => $l) { | 441 | foreach ($this->bookmarks as $key => $bookmark) { |
422 | if ($l['created']->format('Ymd') == $day) { | 442 | if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) { |
423 | $filtered[$key] = $l; | 443 | continue; |
444 | } | ||
445 | |||
446 | if ($bookmark->getCreated()->format('Ymd') == $day) { | ||
447 | $filtered[$key] = $bookmark; | ||
424 | } | 448 | } |
425 | } | 449 | } |
426 | 450 | ||
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php new file mode 100644 index 00000000..6bf7f365 --- /dev/null +++ b/application/bookmark/BookmarkIO.php | |||
@@ -0,0 +1,108 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | ||
6 | use Shaarli\Bookmark\Exception\EmptyDataStoreException; | ||
7 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | ||
8 | use Shaarli\Config\ConfigManager; | ||
9 | |||
10 | /** | ||
11 | * Class BookmarkIO | ||
12 | * | ||
13 | * This class performs read/write operation to the file data store. | ||
14 | * Used by BookmarkFileService. | ||
15 | * | ||
16 | * @package Shaarli\Bookmark | ||
17 | */ | ||
18 | class BookmarkIO | ||
19 | { | ||
20 | /** | ||
21 | * @var string Datastore file path | ||
22 | */ | ||
23 | protected $datastore; | ||
24 | |||
25 | /** | ||
26 | * @var ConfigManager instance | ||
27 | */ | ||
28 | protected $conf; | ||
29 | |||
30 | /** | ||
31 | * string Datastore PHP prefix | ||
32 | */ | ||
33 | protected static $phpPrefix = '<?php /* '; | ||
34 | |||
35 | /** | ||
36 | * string Datastore PHP suffix | ||
37 | */ | ||
38 | protected static $phpSuffix = ' */ ?>'; | ||
39 | |||
40 | /** | ||
41 | * LinksIO constructor. | ||
42 | * | ||
43 | * @param ConfigManager $conf instance | ||
44 | */ | ||
45 | public function __construct($conf) | ||
46 | { | ||
47 | $this->conf = $conf; | ||
48 | $this->datastore = $conf->get('resource.datastore'); | ||
49 | } | ||
50 | |||
51 | /** | ||
52 | * Reads database from disk to memory | ||
53 | * | ||
54 | * @return BookmarkArray instance | ||
55 | * | ||
56 | * @throws NotWritableDataStoreException Data couldn't be loaded | ||
57 | * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark | ||
58 | * @throws DatastoreNotInitializedException File does not exists | ||
59 | */ | ||
60 | public function read() | ||
61 | { | ||
62 | if (! file_exists($this->datastore)) { | ||
63 | throw new DatastoreNotInitializedException(); | ||
64 | } | ||
65 | |||
66 | if (!is_writable($this->datastore)) { | ||
67 | throw new NotWritableDataStoreException($this->datastore); | ||
68 | } | ||
69 | |||
70 | // Note that gzinflate is faster than gzuncompress. | ||
71 | // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 | ||
72 | $links = unserialize(gzinflate(base64_decode( | ||
73 | substr(file_get_contents($this->datastore), | ||
74 | strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); | ||
75 | |||
76 | if (empty($links)) { | ||
77 | if (filesize($this->datastore) > 100) { | ||
78 | throw new NotWritableDataStoreException($this->datastore); | ||
79 | } | ||
80 | throw new EmptyDataStoreException(); | ||
81 | } | ||
82 | |||
83 | return $links; | ||
84 | } | ||
85 | |||
86 | /** | ||
87 | * Saves the database from memory to disk | ||
88 | * | ||
89 | * @param BookmarkArray $links instance. | ||
90 | * | ||
91 | * @throws NotWritableDataStoreException the datastore is not writable | ||
92 | */ | ||
93 | public function write($links) | ||
94 | { | ||
95 | if (is_file($this->datastore) && !is_writeable($this->datastore)) { | ||
96 | // The datastore exists but is not writeable | ||
97 | throw new NotWritableDataStoreException($this->datastore); | ||
98 | } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { | ||
99 | // The datastore does not exist and its parent directory is not writeable | ||
100 | throw new NotWritableDataStoreException(dirname($this->datastore)); | ||
101 | } | ||
102 | |||
103 | file_put_contents( | ||
104 | $this->datastore, | ||
105 | self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix | ||
106 | ); | ||
107 | } | ||
108 | } | ||
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php new file mode 100644 index 00000000..815047e3 --- /dev/null +++ b/application/bookmark/BookmarkInitializer.php | |||
@@ -0,0 +1,110 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | /** | ||
6 | * Class BookmarkInitializer | ||
7 | * | ||
8 | * This class is used to initialized default bookmarks after a fresh install of Shaarli. | ||
9 | * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks). | ||
10 | * | ||
11 | * To prevent data corruption, it does not overwrite existing bookmarks, | ||
12 | * even though there should not be any. | ||
13 | * | ||
14 | * @package Shaarli\Bookmark | ||
15 | */ | ||
16 | class BookmarkInitializer | ||
17 | { | ||
18 | /** @var BookmarkServiceInterface */ | ||
19 | protected $bookmarkService; | ||
20 | |||
21 | /** | ||
22 | * BookmarkInitializer constructor. | ||
23 | * | ||
24 | * @param BookmarkServiceInterface $bookmarkService | ||
25 | */ | ||
26 | public function __construct($bookmarkService) | ||
27 | { | ||
28 | $this->bookmarkService = $bookmarkService; | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * Initialize the data store with default bookmarks | ||
33 | */ | ||
34 | public function initialize() | ||
35 | { | ||
36 | $bookmark = new Bookmark(); | ||
37 | $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)')); | ||
38 | $bookmark->setUrl('https://vimeo.com/153493904'); | ||
39 | $bookmark->setDescription(t( | ||
40 | 'Shaarli will automatically pick up the thumbnail for links to a variety of websites. | ||
41 | |||
42 | Explore your new Shaarli instance by trying out controls and menus. | ||
43 | Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. | ||
44 | |||
45 | Now you can edit or delete the default shaares. | ||
46 | ' | ||
47 | )); | ||
48 | $bookmark->setTagsString('shaarli help thumbnail'); | ||
49 | $bookmark->setPrivate(true); | ||
50 | $this->bookmarkService->add($bookmark, false); | ||
51 | |||
52 | $bookmark = new Bookmark(); | ||
53 | $bookmark->setTitle(t('Note: Shaare descriptions')); | ||
54 | $bookmark->setDescription(t( | ||
55 | 'Adding a shaare without entering a URL creates a text-only "note" post such as this one. | ||
56 | This note is private, so you are the only one able to see it while logged in. | ||
57 | |||
58 | You can use this to keep notes, post articles, code snippets, and much more. | ||
59 | |||
60 | The Markdown formatting setting allows you to format your notes and bookmark description: | ||
61 | |||
62 | ### Title headings | ||
63 | |||
64 | #### Multiple headings levels | ||
65 | * bullet lists | ||
66 | * _italic_ text | ||
67 | * **bold** text | ||
68 | * ~~strike through~~ text | ||
69 | * `code` blocks | ||
70 | * images | ||
71 | * [links](https://en.wikipedia.org/wiki/Markdown) | ||
72 | |||
73 | Markdown also supports tables: | ||
74 | |||
75 | | Name | Type | Color | Qty | | ||
76 | | ------- | --------- | ------ | ----- | | ||
77 | | Orange | Fruit | Orange | 126 | | ||
78 | | Apple | Fruit | Any | 62 | | ||
79 | | Lemon | Fruit | Yellow | 30 | | ||
80 | | Carrot | Vegetable | Red | 14 | | ||
81 | ' | ||
82 | )); | ||
83 | $bookmark->setTagsString('shaarli help'); | ||
84 | $bookmark->setPrivate(true); | ||
85 | $this->bookmarkService->add($bookmark, false); | ||
86 | |||
87 | $bookmark = new Bookmark(); | ||
88 | $bookmark->setTitle( | ||
89 | 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service') | ||
90 | ); | ||
91 | $bookmark->setDescription(t( | ||
92 | 'Welcome to Shaarli! | ||
93 | |||
94 | Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. | ||
95 | You can add a description to your bookmarks, such as this one, and tag them. | ||
96 | |||
97 | Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.). | ||
98 | |||
99 | You 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`). | ||
100 | Hashtags such as #shaarli #help are also supported. | ||
101 | You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search. | ||
102 | |||
103 | We hope that you will enjoy using Shaarli, maintained with ❤️ by the community! | ||
104 | Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue. | ||
105 | ' | ||
106 | )); | ||
107 | $bookmark->setTagsString('shaarli help'); | ||
108 | $this->bookmarkService->add($bookmark, false); | ||
109 | } | ||
110 | } | ||
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php new file mode 100644 index 00000000..b9b483eb --- /dev/null +++ b/application/bookmark/BookmarkServiceInterface.php | |||
@@ -0,0 +1,186 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | |||
6 | use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
7 | use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | ||
8 | use Shaarli\Config\ConfigManager; | ||
9 | use Shaarli\History; | ||
10 | |||
11 | /** | ||
12 | * Class BookmarksService | ||
13 | * | ||
14 | * This is the entry point to manipulate the bookmark DB. | ||
15 | */ | ||
16 | interface BookmarkServiceInterface | ||
17 | { | ||
18 | /** | ||
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 | ||
29 | * | ||
30 | * @param string $hash | ||
31 | * | ||
32 | * @return mixed | ||
33 | * | ||
34 | * @throws \Exception | ||
35 | */ | ||
36 | public function findByHash($hash); | ||
37 | |||
38 | /** | ||
39 | * @param $url | ||
40 | * | ||
41 | * @return Bookmark|null | ||
42 | */ | ||
43 | public function findByUrl($url); | ||
44 | |||
45 | /** | ||
46 | * Search bookmarks | ||
47 | * | ||
48 | * @param mixed $request | ||
49 | * @param string $visibility | ||
50 | * @param bool $caseSensitive | ||
51 | * @param bool $untaggedOnly | ||
52 | * @param bool $ignoreSticky | ||
53 | * | ||
54 | * @return Bookmark[] | ||
55 | */ | ||
56 | public function search( | ||
57 | $request = [], | ||
58 | $visibility = null, | ||
59 | $caseSensitive = false, | ||
60 | $untaggedOnly = false, | ||
61 | bool $ignoreSticky = false | ||
62 | ); | ||
63 | |||
64 | /** | ||
65 | * Get a single bookmark by its ID. | ||
66 | * | ||
67 | * @param int $id Bookmark ID | ||
68 | * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an | ||
69 | * exception | ||
70 | * | ||
71 | * @return Bookmark | ||
72 | * | ||
73 | * @throws BookmarkNotFoundException | ||
74 | * @throws \Exception | ||
75 | */ | ||
76 | public function get($id, $visibility = null); | ||
77 | |||
78 | /** | ||
79 | * Updates an existing bookmark (depending on its ID). | ||
80 | * | ||
81 | * @param Bookmark $bookmark | ||
82 | * @param bool $save Writes to the datastore if set to true | ||
83 | * | ||
84 | * @return Bookmark Updated bookmark | ||
85 | * | ||
86 | * @throws BookmarkNotFoundException | ||
87 | * @throws \Exception | ||
88 | */ | ||
89 | public function set($bookmark, $save = true); | ||
90 | |||
91 | /** | ||
92 | * Adds a new bookmark (the ID must be empty). | ||
93 | * | ||
94 | * @param Bookmark $bookmark | ||
95 | * @param bool $save Writes to the datastore if set to true | ||
96 | * | ||
97 | * @return Bookmark new bookmark | ||
98 | * | ||
99 | * @throws \Exception | ||
100 | */ | ||
101 | public function add($bookmark, $save = true); | ||
102 | |||
103 | /** | ||
104 | * Adds or updates a bookmark depending on its ID: | ||
105 | * - a Bookmark without ID will be added | ||
106 | * - a Bookmark with an existing ID will be updated | ||
107 | * | ||
108 | * @param Bookmark $bookmark | ||
109 | * @param bool $save | ||
110 | * | ||
111 | * @return Bookmark | ||
112 | * | ||
113 | * @throws \Exception | ||
114 | */ | ||
115 | public function addOrSet($bookmark, $save = true); | ||
116 | |||
117 | /** | ||
118 | * Deletes a bookmark. | ||
119 | * | ||
120 | * @param Bookmark $bookmark | ||
121 | * @param bool $save | ||
122 | * | ||
123 | * @throws \Exception | ||
124 | */ | ||
125 | public function remove($bookmark, $save = true); | ||
126 | |||
127 | /** | ||
128 | * Get a single bookmark by its ID. | ||
129 | * | ||
130 | * @param int $id Bookmark ID | ||
131 | * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an | ||
132 | * exception | ||
133 | * | ||
134 | * @return bool | ||
135 | */ | ||
136 | public function exists($id, $visibility = null); | ||
137 | |||
138 | /** | ||
139 | * Return the number of available bookmarks for given visibility. | ||
140 | * | ||
141 | * @param string $visibility public|private|all | ||
142 | * | ||
143 | * @return int Number of bookmarks | ||
144 | */ | ||
145 | public function count($visibility = null); | ||
146 | |||
147 | /** | ||
148 | * Write the datastore. | ||
149 | * | ||
150 | * @throws NotWritableDataStoreException | ||
151 | */ | ||
152 | public function save(); | ||
153 | |||
154 | /** | ||
155 | * Returns the list tags appearing in the bookmarks with the given tags | ||
156 | * | ||
157 | * @param array $filteringTags tags selecting the bookmarks to consider | ||
158 | * @param string $visibility process only all/private/public bookmarks | ||
159 | * | ||
160 | * @return array tag => bookmarksCount | ||
161 | */ | ||
162 | public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); | ||
163 | |||
164 | /** | ||
165 | * Returns the list of days containing articles (oldest first) | ||
166 | * | ||
167 | * @return array containing days (in format YYYYMMDD). | ||
168 | */ | ||
169 | public function days(); | ||
170 | |||
171 | /** | ||
172 | * Returns the list of articles for a given day. | ||
173 | * | ||
174 | * @param string $request day to filter. Format: YYYYMMDD. | ||
175 | * | ||
176 | * @return Bookmark[] list of shaare found. | ||
177 | * | ||
178 | * @throws BookmarkNotFoundException | ||
179 | */ | ||
180 | public function filterDay($request); | ||
181 | |||
182 | /** | ||
183 | * Creates the default database after a fresh install. | ||
184 | */ | ||
185 | public function initialize(); | ||
186 | } | ||
diff --git a/application/bookmark/LinkDB.php b/application/bookmark/LinkDB.php deleted file mode 100644 index 76ba95f0..00000000 --- a/application/bookmark/LinkDB.php +++ /dev/null | |||
@@ -1,575 +0,0 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Bookmark; | ||
4 | |||
5 | use ArrayAccess; | ||
6 | use Countable; | ||
7 | use DateTime; | ||
8 | use Iterator; | ||
9 | use Shaarli\Bookmark\Exception\LinkNotFoundException; | ||
10 | use Shaarli\Exceptions\IOException; | ||
11 | use Shaarli\FileUtils; | ||
12 | |||
13 | /** | ||
14 | * Data storage for links. | ||
15 | * | ||
16 | * This object behaves like an associative array. | ||
17 | * | ||
18 | * Example: | ||
19 | * $myLinks = new LinkDB(); | ||
20 | * echo $myLinks[350]['title']; | ||
21 | * foreach ($myLinks as $link) | ||
22 | * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description']; | ||
23 | * | ||
24 | * Available keys: | ||
25 | * - id: primary key, incremental integer identifier (persistent) | ||
26 | * - description: description of the entry | ||
27 | * - created: creation date of this entry, DateTime object. | ||
28 | * - updated: last modification date of this entry, DateTime object. | ||
29 | * - private: Is this link private? 0=no, other value=yes | ||
30 | * - tags: tags attached to this entry (separated by spaces) | ||
31 | * - title Title of the link | ||
32 | * - url URL of the link. Used for displayable links. | ||
33 | * Can be absolute or relative in the database but the relative links | ||
34 | * will be converted to absolute ones in templates. | ||
35 | * - real_url Raw URL in stored in the DB (absolute or relative). | ||
36 | * - shorturl Permalink smallhash | ||
37 | * | ||
38 | * Implements 3 interfaces: | ||
39 | * - ArrayAccess: behaves like an associative array; | ||
40 | * - Countable: there is a count() method; | ||
41 | * - Iterator: usable in foreach () loops. | ||
42 | * | ||
43 | * ID mechanism: | ||
44 | * ArrayAccess is implemented in a way that will allow to access a link | ||
45 | * with the unique identifier ID directly with $link[ID]. | ||
46 | * Note that it's not the real key of the link array attribute. | ||
47 | * This mechanism is in place to have persistent link IDs, | ||
48 | * even though the internal array is reordered by date. | ||
49 | * Example: | ||
50 | * - DB: link #1 (2010-01-01) link #2 (2016-01-01) | ||
51 | * - Order: #2 #1 | ||
52 | * - Import links containing: link #3 (2013-01-01) | ||
53 | * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01) | ||
54 | * - Real order: #2 #3 #1 | ||
55 | */ | ||
56 | class LinkDB implements Iterator, Countable, ArrayAccess | ||
57 | { | ||
58 | // Links are stored as a PHP serialized string | ||
59 | private $datastore; | ||
60 | |||
61 | // Link date storage format | ||
62 | const LINK_DATE_FORMAT = 'Ymd_His'; | ||
63 | |||
64 | // List of links (associative array) | ||
65 | // - key: link date (e.g. "20110823_124546"), | ||
66 | // - value: associative array (keys: title, description...) | ||
67 | private $links; | ||
68 | |||
69 | // List of all recorded URLs (key=url, value=link offset) | ||
70 | // for fast reserve search (url-->link offset) | ||
71 | private $urls; | ||
72 | |||
73 | /** | ||
74 | * @var array List of all links IDS mapped with their array offset. | ||
75 | * Map: id->offset. | ||
76 | */ | ||
77 | protected $ids; | ||
78 | |||
79 | // List of offset keys (for the Iterator interface implementation) | ||
80 | private $keys; | ||
81 | |||
82 | // Position in the $this->keys array (for the Iterator interface) | ||
83 | private $position; | ||
84 | |||
85 | // Is the user logged in? (used to filter private links) | ||
86 | private $loggedIn; | ||
87 | |||
88 | // Hide public links | ||
89 | private $hidePublicLinks; | ||
90 | |||
91 | /** | ||
92 | * Creates a new LinkDB | ||
93 | * | ||
94 | * Checks if the datastore exists; else, attempts to create a dummy one. | ||
95 | * | ||
96 | * @param string $datastore datastore file path. | ||
97 | * @param boolean $isLoggedIn is the user logged in? | ||
98 | * @param boolean $hidePublicLinks if true all links are private. | ||
99 | */ | ||
100 | public function __construct( | ||
101 | $datastore, | ||
102 | $isLoggedIn, | ||
103 | $hidePublicLinks | ||
104 | ) { | ||
105 | |||
106 | $this->datastore = $datastore; | ||
107 | $this->loggedIn = $isLoggedIn; | ||
108 | $this->hidePublicLinks = $hidePublicLinks; | ||
109 | $this->check(); | ||
110 | $this->read(); | ||
111 | } | ||
112 | |||
113 | /** | ||
114 | * Countable - Counts elements of an object | ||
115 | */ | ||
116 | public function count() | ||
117 | { | ||
118 | return count($this->links); | ||
119 | } | ||
120 | |||
121 | /** | ||
122 | * ArrayAccess - Assigns a value to the specified offset | ||
123 | */ | ||
124 | public function offsetSet($offset, $value) | ||
125 | { | ||
126 | // TODO: use exceptions instead of "die" | ||
127 | if (!$this->loggedIn) { | ||
128 | die(t('You are not authorized to add a link.')); | ||
129 | } | ||
130 | if (!isset($value['id']) || empty($value['url'])) { | ||
131 | die(t('Internal Error: A link should always have an id and URL.')); | ||
132 | } | ||
133 | if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) { | ||
134 | die(t('You must specify an integer as a key.')); | ||
135 | } | ||
136 | if ($offset !== null && $offset !== $value['id']) { | ||
137 | die(t('Array offset and link ID must be equal.')); | ||
138 | } | ||
139 | |||
140 | // If the link exists, we reuse the real offset, otherwise new entry | ||
141 | $existing = $this->getLinkOffset($offset); | ||
142 | if ($existing !== null) { | ||
143 | $offset = $existing; | ||
144 | } else { | ||
145 | $offset = count($this->links); | ||
146 | } | ||
147 | $this->links[$offset] = $value; | ||
148 | $this->urls[$value['url']] = $offset; | ||
149 | $this->ids[$value['id']] = $offset; | ||
150 | } | ||
151 | |||
152 | /** | ||
153 | * ArrayAccess - Whether or not an offset exists | ||
154 | */ | ||
155 | public function offsetExists($offset) | ||
156 | { | ||
157 | return array_key_exists($this->getLinkOffset($offset), $this->links); | ||
158 | } | ||
159 | |||
160 | /** | ||
161 | * ArrayAccess - Unsets an offset | ||
162 | */ | ||
163 | public function offsetUnset($offset) | ||
164 | { | ||
165 | if (!$this->loggedIn) { | ||
166 | // TODO: raise an exception | ||
167 | die('You are not authorized to delete a link.'); | ||
168 | } | ||
169 | $realOffset = $this->getLinkOffset($offset); | ||
170 | $url = $this->links[$realOffset]['url']; | ||
171 | unset($this->urls[$url]); | ||
172 | unset($this->ids[$realOffset]); | ||
173 | unset($this->links[$realOffset]); | ||
174 | } | ||
175 | |||
176 | /** | ||
177 | * ArrayAccess - Returns the value at specified offset | ||
178 | */ | ||
179 | public function offsetGet($offset) | ||
180 | { | ||
181 | $realOffset = $this->getLinkOffset($offset); | ||
182 | return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null; | ||
183 | } | ||
184 | |||
185 | /** | ||
186 | * Iterator - Returns the current element | ||
187 | */ | ||
188 | public function current() | ||
189 | { | ||
190 | return $this[$this->keys[$this->position]]; | ||
191 | } | ||
192 | |||
193 | /** | ||
194 | * Iterator - Returns the key of the current element | ||
195 | */ | ||
196 | public function key() | ||
197 | { | ||
198 | return $this->keys[$this->position]; | ||
199 | } | ||
200 | |||
201 | /** | ||
202 | * Iterator - Moves forward to next element | ||
203 | */ | ||
204 | public function next() | ||
205 | { | ||
206 | ++$this->position; | ||
207 | } | ||
208 | |||
209 | /** | ||
210 | * Iterator - Rewinds the Iterator to the first element | ||
211 | * | ||
212 | * Entries are sorted by date (latest first) | ||
213 | */ | ||
214 | public function rewind() | ||
215 | { | ||
216 | $this->keys = array_keys($this->ids); | ||
217 | $this->position = 0; | ||
218 | } | ||
219 | |||
220 | /** | ||
221 | * Iterator - Checks if current position is valid | ||
222 | */ | ||
223 | public function valid() | ||
224 | { | ||
225 | return isset($this->keys[$this->position]); | ||
226 | } | ||
227 | |||
228 | /** | ||
229 | * Checks if the DB directory and file exist | ||
230 | * | ||
231 | * If no DB file is found, creates a dummy DB. | ||
232 | */ | ||
233 | private function check() | ||
234 | { | ||
235 | if (file_exists($this->datastore)) { | ||
236 | return; | ||
237 | } | ||
238 | |||
239 | // Create a dummy database for example | ||
240 | $this->links = array(); | ||
241 | $link = array( | ||
242 | 'id' => 1, | ||
243 | 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), | ||
244 | 'url' => 'https://shaarli.readthedocs.io', | ||
245 | 'description' => t( | ||
246 | 'Welcome to Shaarli! This is your first public bookmark. ' | ||
247 | . 'To edit or delete me, you must first login. | ||
248 | |||
249 | To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. | ||
250 | |||
251 | You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' | ||
252 | ), | ||
253 | 'private' => 0, | ||
254 | 'created' => new DateTime(), | ||
255 | 'tags' => 'opensource software', | ||
256 | 'sticky' => false, | ||
257 | ); | ||
258 | $link['shorturl'] = link_small_hash($link['created'], $link['id']); | ||
259 | $this->links[1] = $link; | ||
260 | |||
261 | $link = array( | ||
262 | 'id' => 0, | ||
263 | 'title' => t('My secret stuff... - Pastebin.com'), | ||
264 | 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', | ||
265 | 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'), | ||
266 | 'private' => 1, | ||
267 | 'created' => new DateTime('1 minute ago'), | ||
268 | 'tags' => 'secretstuff', | ||
269 | 'sticky' => false, | ||
270 | ); | ||
271 | $link['shorturl'] = link_small_hash($link['created'], $link['id']); | ||
272 | $this->links[0] = $link; | ||
273 | |||
274 | // Write database to disk | ||
275 | $this->write(); | ||
276 | } | ||
277 | |||
278 | /** | ||
279 | * Reads database from disk to memory | ||
280 | */ | ||
281 | private function read() | ||
282 | { | ||
283 | // Public links are hidden and user not logged in => nothing to show | ||
284 | if ($this->hidePublicLinks && !$this->loggedIn) { | ||
285 | $this->links = array(); | ||
286 | return; | ||
287 | } | ||
288 | |||
289 | $this->urls = []; | ||
290 | $this->ids = []; | ||
291 | $this->links = FileUtils::readFlatDB($this->datastore, []); | ||
292 | |||
293 | $toremove = array(); | ||
294 | foreach ($this->links as $key => &$link) { | ||
295 | if (!$this->loggedIn && $link['private'] != 0) { | ||
296 | // Transition for not upgraded databases. | ||
297 | unset($this->links[$key]); | ||
298 | continue; | ||
299 | } | ||
300 | |||
301 | // Sanitize data fields. | ||
302 | sanitizeLink($link); | ||
303 | |||
304 | // Remove private tags if the user is not logged in. | ||
305 | if (!$this->loggedIn) { | ||
306 | $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']); | ||
307 | } | ||
308 | |||
309 | $link['real_url'] = $link['url']; | ||
310 | |||
311 | $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; | ||
312 | |||
313 | // To be able to load links before running the update, and prepare the update | ||
314 | if (!isset($link['created'])) { | ||
315 | $link['id'] = $link['linkdate']; | ||
316 | $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); | ||
317 | if (!empty($link['updated'])) { | ||
318 | $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']); | ||
319 | } | ||
320 | $link['shorturl'] = smallHash($link['linkdate']); | ||
321 | } | ||
322 | |||
323 | $this->urls[$link['url']] = $key; | ||
324 | $this->ids[$link['id']] = $key; | ||
325 | } | ||
326 | } | ||
327 | |||
328 | /** | ||
329 | * Saves the database from memory to disk | ||
330 | * | ||
331 | * @throws IOException the datastore is not writable | ||
332 | */ | ||
333 | private function write() | ||
334 | { | ||
335 | $this->reorder(); | ||
336 | FileUtils::writeFlatDB($this->datastore, $this->links); | ||
337 | } | ||
338 | |||
339 | /** | ||
340 | * Saves the database from memory to disk | ||
341 | * | ||
342 | * @param string $pageCacheDir page cache directory | ||
343 | */ | ||
344 | public function save($pageCacheDir) | ||
345 | { | ||
346 | if (!$this->loggedIn) { | ||
347 | // TODO: raise an Exception instead | ||
348 | die('You are not authorized to change the database.'); | ||
349 | } | ||
350 | |||
351 | $this->write(); | ||
352 | |||
353 | invalidateCaches($pageCacheDir); | ||
354 | } | ||
355 | |||
356 | /** | ||
357 | * Returns the link for a given URL, or False if it does not exist. | ||
358 | * | ||
359 | * @param string $url URL to search for | ||
360 | * | ||
361 | * @return mixed the existing link if it exists, else 'false' | ||
362 | */ | ||
363 | public function getLinkFromUrl($url) | ||
364 | { | ||
365 | if (isset($this->urls[$url])) { | ||
366 | return $this->links[$this->urls[$url]]; | ||
367 | } | ||
368 | return false; | ||
369 | } | ||
370 | |||
371 | /** | ||
372 | * Returns the shaare corresponding to a smallHash. | ||
373 | * | ||
374 | * @param string $request QUERY_STRING server parameter. | ||
375 | * | ||
376 | * @return array $filtered array containing permalink data. | ||
377 | * | ||
378 | * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link. | ||
379 | */ | ||
380 | public function filterHash($request) | ||
381 | { | ||
382 | $request = substr($request, 0, 6); | ||
383 | $linkFilter = new LinkFilter($this->links); | ||
384 | return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); | ||
385 | } | ||
386 | |||
387 | /** | ||
388 | * Returns the list of articles for a given day. | ||
389 | * | ||
390 | * @param string $request day to filter. Format: YYYYMMDD. | ||
391 | * | ||
392 | * @return array list of shaare found. | ||
393 | */ | ||
394 | public function filterDay($request) | ||
395 | { | ||
396 | $linkFilter = new LinkFilter($this->links); | ||
397 | return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); | ||
398 | } | ||
399 | |||
400 | /** | ||
401 | * Filter links according to search parameters. | ||
402 | * | ||
403 | * @param array $filterRequest Search request content. Supported keys: | ||
404 | * - searchtags: list of tags | ||
405 | * - searchterm: term search | ||
406 | * @param bool $casesensitive Optional: Perform case sensitive filter | ||
407 | * @param string $visibility return only all/private/public links | ||
408 | * @param bool $untaggedonly return only untagged links | ||
409 | * | ||
410 | * @return array filtered links, all links if no suitable filter was provided. | ||
411 | */ | ||
412 | public function filterSearch( | ||
413 | $filterRequest = array(), | ||
414 | $casesensitive = false, | ||
415 | $visibility = 'all', | ||
416 | $untaggedonly = false | ||
417 | ) { | ||
418 | |||
419 | // Filter link database according to parameters. | ||
420 | $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; | ||
421 | $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; | ||
422 | |||
423 | // Search tags + fullsearch - blank string parameter will return all links. | ||
424 | $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" | ||
425 | $request = [$searchtags, $searchterm]; | ||
426 | |||
427 | $linkFilter = new LinkFilter($this); | ||
428 | return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); | ||
429 | } | ||
430 | |||
431 | /** | ||
432 | * Returns the list tags appearing in the links with the given tags | ||
433 | * | ||
434 | * @param array $filteringTags tags selecting the links to consider | ||
435 | * @param string $visibility process only all/private/public links | ||
436 | * | ||
437 | * @return array tag => linksCount | ||
438 | */ | ||
439 | public function linksCountPerTag($filteringTags = [], $visibility = 'all') | ||
440 | { | ||
441 | $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); | ||
442 | $tags = []; | ||
443 | $caseMapping = []; | ||
444 | foreach ($links as $link) { | ||
445 | foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { | ||
446 | if (empty($tag)) { | ||
447 | continue; | ||
448 | } | ||
449 | // The first case found will be displayed. | ||
450 | if (!isset($caseMapping[strtolower($tag)])) { | ||
451 | $caseMapping[strtolower($tag)] = $tag; | ||
452 | $tags[$caseMapping[strtolower($tag)]] = 0; | ||
453 | } | ||
454 | $tags[$caseMapping[strtolower($tag)]]++; | ||
455 | } | ||
456 | } | ||
457 | |||
458 | /* | ||
459 | * Formerly used arsort(), which doesn't define the sort behaviour for equal values. | ||
460 | * Also, this function doesn't produce the same result between PHP 5.6 and 7. | ||
461 | * | ||
462 | * So we now use array_multisort() to sort tags by DESC occurrences, | ||
463 | * then ASC alphabetically for equal values. | ||
464 | * | ||
465 | * @see https://github.com/shaarli/Shaarli/issues/1142 | ||
466 | */ | ||
467 | $keys = array_keys($tags); | ||
468 | $tmpTags = array_combine($keys, $keys); | ||
469 | array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); | ||
470 | return $tags; | ||
471 | } | ||
472 | |||
473 | /** | ||
474 | * Rename or delete a tag across all links. | ||
475 | * | ||
476 | * @param string $from Tag to rename | ||
477 | * @param string $to New tag. If none is provided, the from tag will be deleted | ||
478 | * | ||
479 | * @return array|bool List of altered links or false on error | ||
480 | */ | ||
481 | public function renameTag($from, $to) | ||
482 | { | ||
483 | if (empty($from)) { | ||
484 | return false; | ||
485 | } | ||
486 | $delete = empty($to); | ||
487 | // True for case-sensitive tag search. | ||
488 | $linksToAlter = $this->filterSearch(['searchtags' => $from], true); | ||
489 | foreach ($linksToAlter as $key => &$value) { | ||
490 | $tags = preg_split('/\s+/', trim($value['tags'])); | ||
491 | if (($pos = array_search($from, $tags)) !== false) { | ||
492 | if ($delete) { | ||
493 | unset($tags[$pos]); // Remove tag. | ||
494 | } else { | ||
495 | $tags[$pos] = trim($to); | ||
496 | } | ||
497 | $value['tags'] = trim(implode(' ', array_unique($tags))); | ||
498 | $this[$value['id']] = $value; | ||
499 | } | ||
500 | } | ||
501 | |||
502 | return $linksToAlter; | ||
503 | } | ||
504 | |||
505 | /** | ||
506 | * Returns the list of days containing articles (oldest first) | ||
507 | * Output: An array containing days (in format YYYYMMDD). | ||
508 | */ | ||
509 | public function days() | ||
510 | { | ||
511 | $linkDays = array(); | ||
512 | foreach ($this->links as $link) { | ||
513 | $linkDays[$link['created']->format('Ymd')] = 0; | ||
514 | } | ||
515 | $linkDays = array_keys($linkDays); | ||
516 | sort($linkDays); | ||
517 | |||
518 | return $linkDays; | ||
519 | } | ||
520 | |||
521 | /** | ||
522 | * Reorder links by creation date (newest first). | ||
523 | * | ||
524 | * Also update the urls and ids mapping arrays. | ||
525 | * | ||
526 | * @param string $order ASC|DESC | ||
527 | */ | ||
528 | public function reorder($order = 'DESC') | ||
529 | { | ||
530 | $order = $order === 'ASC' ? -1 : 1; | ||
531 | // Reorder array by dates. | ||
532 | usort($this->links, function ($a, $b) use ($order) { | ||
533 | if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { | ||
534 | return $a['sticky'] ? -1 : 1; | ||
535 | } | ||
536 | return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; | ||
537 | }); | ||
538 | |||
539 | $this->urls = []; | ||
540 | $this->ids = []; | ||
541 | foreach ($this->links as $key => $link) { | ||
542 | $this->urls[$link['url']] = $key; | ||
543 | $this->ids[$link['id']] = $key; | ||
544 | } | ||
545 | } | ||
546 | |||
547 | /** | ||
548 | * Return the next key for link creation. | ||
549 | * E.g. If the last ID is 597, the next will be 598. | ||
550 | * | ||
551 | * @return int next ID. | ||
552 | */ | ||
553 | public function getNextId() | ||
554 | { | ||
555 | if (!empty($this->ids)) { | ||
556 | return max(array_keys($this->ids)) + 1; | ||
557 | } | ||
558 | return 0; | ||
559 | } | ||
560 | |||
561 | /** | ||
562 | * Returns a link offset in links array from its unique ID. | ||
563 | * | ||
564 | * @param int $id Persistent ID of a link. | ||
565 | * | ||
566 | * @return int Real offset in local array, or null if doesn't exist. | ||
567 | */ | ||
568 | protected function getLinkOffset($id) | ||
569 | { | ||
570 | if (isset($this->ids[$id])) { | ||
571 | return $this->ids[$id]; | ||
572 | } | ||
573 | return null; | ||
574 | } | ||
575 | } | ||
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index 77eb2d95..e7af4d55 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php | |||
@@ -1,112 +1,6 @@ | |||
1 | <?php | 1 | <?php |
2 | 2 | ||
3 | use Shaarli\Bookmark\LinkDB; | 3 | use Shaarli\Bookmark\Bookmark; |
4 | |||
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 | */ | ||
17 | function 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 | 4 | ||
111 | /** | 5 | /** |
112 | * Extract title from an HTML document. | 6 | * Extract title from an HTML document. |
@@ -132,7 +26,7 @@ function html_extract_title($html) | |||
132 | */ | 26 | */ |
133 | function header_extract_charset($header) | 27 | function 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 | } |
@@ -188,30 +82,11 @@ function html_extract_tag($tag, $html) | |||
188 | } | 82 | } |
189 | 83 | ||
190 | /** | 84 | /** |
191 | * Count private links in given linklist. | 85 | * In a string, converts URLs to clickable bookmarks. |
192 | * | ||
193 | * @param array|Countable $links Linklist. | ||
194 | * | ||
195 | * @return int Number of private links. | ||
196 | */ | ||
197 | function count_private($links) | ||
198 | { | ||
199 | $cpt = 0; | ||
200 | foreach ($links as $link) { | ||
201 | if ($link['private']) { | ||
202 | $cpt += 1; | ||
203 | } | ||
204 | } | ||
205 | |||
206 | return $cpt; | ||
207 | } | ||
208 | |||
209 | /** | ||
210 | * In a string, converts URLs to clickable links. | ||
211 | * | 86 | * |
212 | * @param string $text input string. | 87 | * @param string $text input string. |
213 | * | 88 | * |
214 | * @return string returns $text with all links converted to HTML links. | 89 | * @return string returns $text with all bookmarks converted to HTML bookmarks. |
215 | * | 90 | * |
216 | * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 | 91 | * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 |
217 | */ | 92 | */ |
@@ -239,7 +114,7 @@ function hashtag_autolink($description, $indexUrl = '') | |||
239 | * \p{Mn} - any non marking space (accents, umlauts, etc) | 114 | * \p{Mn} - any non marking space (accents, umlauts, etc) |
240 | */ | 115 | */ |
241 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; | 116 | $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; |
242 | $replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>'; | 117 | $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>'; |
243 | return preg_replace($regex, $replacement, $description); | 118 | return preg_replace($regex, $replacement, $description); |
244 | } | 119 | } |
245 | 120 | ||
@@ -279,7 +154,7 @@ function format_description($description, $indexUrl = '') | |||
279 | */ | 154 | */ |
280 | function link_small_hash($date, $id) | 155 | function link_small_hash($date, $id) |
281 | { | 156 | { |
282 | return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id); | 157 | return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id); |
283 | } | 158 | } |
284 | 159 | ||
285 | /** | 160 | /** |
diff --git a/application/bookmark/exception/LinkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php index f9414428..827a3d35 100644 --- a/application/bookmark/exception/LinkNotFoundException.php +++ b/application/bookmark/exception/BookmarkNotFoundException.php | |||
@@ -3,7 +3,7 @@ namespace Shaarli\Bookmark\Exception; | |||
3 | 3 | ||
4 | use Exception; | 4 | use Exception; |
5 | 5 | ||
6 | class LinkNotFoundException extends Exception | 6 | class BookmarkNotFoundException extends Exception |
7 | { | 7 | { |
8 | /** | 8 | /** |
9 | * LinkNotFoundException constructor. | 9 | * LinkNotFoundException constructor. |
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 | |||
3 | declare(strict_types=1); | ||
4 | |||
5 | namespace Shaarli\Bookmark\Exception; | ||
6 | |||
7 | class DatastoreNotInitializedException extends \Exception | ||
8 | { | ||
9 | |||
10 | } | ||
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php new file mode 100644 index 00000000..cd48c1e6 --- /dev/null +++ b/application/bookmark/exception/EmptyDataStoreException.php | |||
@@ -0,0 +1,7 @@ | |||
1 | <?php | ||
2 | |||
3 | |||
4 | namespace Shaarli\Bookmark\Exception; | ||
5 | |||
6 | |||
7 | class EmptyDataStoreException extends \Exception {} | ||
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php new file mode 100644 index 00000000..10c84a6d --- /dev/null +++ b/application/bookmark/exception/InvalidBookmarkException.php | |||
@@ -0,0 +1,30 @@ | |||
1 | <?php | ||
2 | |||
3 | namespace Shaarli\Bookmark\Exception; | ||
4 | |||
5 | use Shaarli\Bookmark\Bookmark; | ||
6 | |||
7 | class InvalidBookmarkException extends \Exception | ||
8 | { | ||
9 | public function __construct($bookmark) | ||
10 | { | ||
11 | if ($bookmark instanceof Bookmark) { | ||
12 | if ($bookmark->getCreated() instanceof \DateTime) { | ||
13 | $created = $bookmark->getCreated()->format(\DateTime::ATOM); | ||
14 | } elseif (empty($bookmark->getCreated())) { | ||
15 | $created = ''; | ||
16 | } else { | ||
17 | $created = 'Not a DateTime object'; | ||
18 | } | ||
19 | $this->message = 'This bookmark is not valid'. PHP_EOL; | ||
20 | $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; | ||
21 | $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; | ||
22 | $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; | ||
23 | $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; | ||
24 | $this->message .= ' - Created: '. $created . PHP_EOL; | ||
25 | } else { | ||
26 | $this->message = 'The provided data is not a bookmark'. PHP_EOL; | ||
27 | $this->message .= var_export($bookmark, true); | ||
28 | } | ||
29 | } | ||
30 | } | ||
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php new file mode 100644 index 00000000..95f34b50 --- /dev/null +++ b/application/bookmark/exception/NotWritableDataStoreException.php | |||
@@ -0,0 +1,19 @@ | |||
1 | <?php | ||
2 | |||
3 | |||
4 | namespace Shaarli\Bookmark\Exception; | ||
5 | |||
6 | |||
7 | class NotWritableDataStoreException extends \Exception | ||
8 | { | ||
9 | /** | ||
10 | * NotReadableDataStore constructor. | ||
11 | * | ||
12 | * @param string $dataStore file path | ||
13 | */ | ||
14 | public function __construct($dataStore) | ||
15 | { | ||
16 | $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. | ||
17 | 'Your data might be corrupted, or your file isn\'t readable.'; | ||
18 | } | ||
19 | } | ||