]> git.immae.eu Git - github/shaarli/Shaarli.git/blame - application/legacy/LegacyLinkDB.php
Introduce Bookmark object and Service layer to retrieve them
[github/shaarli/Shaarli.git] / application / legacy / LegacyLinkDB.php
CommitLineData
ca74886f 1<?php
f3d2f257 2
336a28fa 3namespace Shaarli\Legacy;
f24896b2
V
4
5use ArrayAccess;
6use Countable;
7use DateTime;
8use Iterator;
336a28fa 9use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
f3d2f257 10use Shaarli\Exceptions\IOException;
a0c4dbd9 11use Shaarli\FileUtils;
f3d2f257 12
ca74886f 13/**
336a28fa 14 * Data storage for bookmarks.
ca74886f
V
15 *
16 * This object behaves like an associative array.
17 *
18 * Example:
19 * $myLinks = new LinkDB();
29d10882 20 * echo $myLinks[350]['title'];
ca74886f
V
21 * foreach ($myLinks as $link)
22 * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
23 *
24 * Available keys:
29d10882 25 * - id: primary key, incremental integer identifier (persistent)
ca74886f 26 * - description: description of the entry
29d10882
A
27 * - created: creation date of this entry, DateTime object.
28 * - updated: last modification date of this entry, DateTime object.
ca74886f
V
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
336a28fa
A
32 * - url URL of the link. Used for displayable bookmarks.
33 * Can be absolute or relative in the database but the relative bookmarks
520d2957
A
34 * will be converted to absolute ones in templates.
35 * - real_url Raw URL in stored in the DB (absolute or relative).
d592daea 36 * - shorturl Permalink smallhash
ca74886f
V
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.
29d10882
A
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
336a28fa 52 * - Import bookmarks containing: link #3 (2013-01-01)
29d10882
A
53 * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
54 * - Real order: #2 #3 #1
336a28fa
A
55 *
56 * @deprecated
ca74886f 57 */
336a28fa 58class LegacyLinkDB implements Iterator, Countable, ArrayAccess
ca74886f 59{
9c8752a2 60 // Links are stored as a PHP serialized string
628b97cb 61 private $datastore;
9c8752a2 62
205a4277
V
63 // Link date storage format
64 const LINK_DATE_FORMAT = 'Ymd_His';
65
336a28fa 66 // List of bookmarks (associative array)
ca74886f
V
67 // - key: link date (e.g. "20110823_124546"),
68 // - value: associative array (keys: title, description...)
628b97cb 69 private $links;
ca74886f 70
29d10882
A
71 // List of all recorded URLs (key=url, value=link offset)
72 // for fast reserve search (url-->link offset)
628b97cb 73 private $urls;
ca74886f 74
29d10882 75 /**
336a28fa 76 * @var array List of all bookmarks IDS mapped with their array offset.
29d10882
A
77 * Map: id->offset.
78 */
79 protected $ids;
80
81 // List of offset keys (for the Iterator interface implementation)
628b97cb 82 private $keys;
ca74886f 83
628b97cb
V
84 // Position in the $this->keys array (for the Iterator interface)
85 private $position;
ca74886f 86
336a28fa 87 // Is the user logged in? (used to filter private bookmarks)
628b97cb 88 private $loggedIn;
ca74886f 89
336a28fa 90 // Hide public bookmarks
628b97cb 91 private $hidePublicLinks;
9f15ca9e 92
ca74886f
V
93 /**
94 * Creates a new LinkDB
95 *
96 * Checks if the datastore exists; else, attempts to create a dummy one.
97 *
6696729b
V
98 * @param string $datastore datastore file path.
99 * @param boolean $isLoggedIn is the user logged in?
336a28fa 100 * @param boolean $hidePublicLinks if true all bookmarks are private.
ca74886f 101 */
735ed4a9 102 public function __construct(
043eae70
A
103 $datastore,
104 $isLoggedIn,
520d2957 105 $hidePublicLinks
f211e417 106 ) {
9f962705 107
628b97cb
V
108 $this->datastore = $datastore;
109 $this->loggedIn = $isLoggedIn;
110 $this->hidePublicLinks = $hidePublicLinks;
f21abf32
V
111 $this->check();
112 $this->read();
ca74886f
V
113 }
114
115 /**
116 * Countable - Counts elements of an object
117 */
118 public function count()
119 {
628b97cb 120 return count($this->links);
ca74886f
V
121 }
122
123 /**
124 * ArrayAccess - Assigns a value to the specified offset
125 */
126 public function offsetSet($offset, $value)
127 {
128 // TODO: use exceptions instead of "die"
628b97cb 129 if (!$this->loggedIn) {
12266213 130 die(t('You are not authorized to add a link.'));
ca74886f 131 }
29d10882 132 if (!isset($value['id']) || empty($value['url'])) {
12266213 133 die(t('Internal Error: A link should always have an id and URL.'));
ca74886f 134 }
f24896b2 135 if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) {
12266213 136 die(t('You must specify an integer as a key.'));
29d10882 137 }
bc5f1597 138 if ($offset !== null && $offset !== $value['id']) {
12266213 139 die(t('Array offset and link ID must be equal.'));
29d10882
A
140 }
141
142 // If the link exists, we reuse the real offset, otherwise new entry
143 $existing = $this->getLinkOffset($offset);
144 if ($existing !== null) {
145 $offset = $existing;
146 } else {
147 $offset = count($this->links);
ca74886f 148 }
628b97cb 149 $this->links[$offset] = $value;
29d10882
A
150 $this->urls[$value['url']] = $offset;
151 $this->ids[$value['id']] = $offset;
ca74886f
V
152 }
153
154 /**
155 * ArrayAccess - Whether or not an offset exists
156 */
157 public function offsetExists($offset)
158 {
29d10882 159 return array_key_exists($this->getLinkOffset($offset), $this->links);
ca74886f
V
160 }
161
162 /**
163 * ArrayAccess - Unsets an offset
164 */
165 public function offsetUnset($offset)
166 {
628b97cb 167 if (!$this->loggedIn) {
ca74886f
V
168 // TODO: raise an exception
169 die('You are not authorized to delete a link.');
170 }
29d10882
A
171 $realOffset = $this->getLinkOffset($offset);
172 $url = $this->links[$realOffset]['url'];
628b97cb 173 unset($this->urls[$url]);
29d10882
A
174 unset($this->ids[$realOffset]);
175 unset($this->links[$realOffset]);
ca74886f
V
176 }
177
178 /**
179 * ArrayAccess - Returns the value at specified offset
180 */
181 public function offsetGet($offset)
182 {
29d10882
A
183 $realOffset = $this->getLinkOffset($offset);
184 return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
ca74886f
V
185 }
186
187 /**
188 * Iterator - Returns the current element
189 */
735ed4a9 190 public function current()
ca74886f 191 {
29d10882 192 return $this[$this->keys[$this->position]];
ca74886f
V
193 }
194
195 /**
196 * Iterator - Returns the key of the current element
197 */
735ed4a9 198 public function key()
ca74886f 199 {
628b97cb 200 return $this->keys[$this->position];
ca74886f
V
201 }
202
203 /**
204 * Iterator - Moves forward to next element
205 */
735ed4a9 206 public function next()
ca74886f 207 {
628b97cb 208 ++$this->position;
ca74886f
V
209 }
210
211 /**
212 * Iterator - Rewinds the Iterator to the first element
213 *
214 * Entries are sorted by date (latest first)
215 */
735ed4a9 216 public function rewind()
ca74886f 217 {
29d10882 218 $this->keys = array_keys($this->ids);
628b97cb 219 $this->position = 0;
ca74886f
V
220 }
221
222 /**
223 * Iterator - Checks if current position is valid
224 */
735ed4a9 225 public function valid()
ca74886f 226 {
628b97cb 227 return isset($this->keys[$this->position]);
ca74886f
V
228 }
229
230 /**
231 * Checks if the DB directory and file exist
232 *
233 * If no DB file is found, creates a dummy DB.
234 */
f21abf32 235 private function check()
ca74886f 236 {
628b97cb 237 if (file_exists($this->datastore)) {
ca74886f
V
238 return;
239 }
240
241 // Create a dummy database for example
628b97cb 242 $this->links = array();
ca74886f 243 $link = array(
29d10882 244 'id' => 1,
f24896b2
V
245 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
246 'url' => 'https://shaarli.readthedocs.io',
247 'description' => t(
9d9f6d75 248 'Welcome to Shaarli! This is your first public bookmark. '
f24896b2 249 . 'To edit or delete me, you must first login.
598376d4 250
12266213 251To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
598376d4 252
9d9f6d75
V
253You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
254 ),
f24896b2
V
255 'private' => 0,
256 'created' => new DateTime(),
b790f900
A
257 'tags' => 'opensource software',
258 'sticky' => false,
ca74886f 259 );
d592daea 260 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 261 $this->links[1] = $link;
ca74886f
V
262
263 $link = array(
29d10882 264 'id' => 0,
f24896b2
V
265 'title' => t('My secret stuff... - Pastebin.com'),
266 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
267 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
268 'private' => 1,
269 'created' => new DateTime('1 minute ago'),
270 'tags' => 'secretstuff',
b790f900 271 'sticky' => false,
ca74886f 272 );
d592daea 273 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 274 $this->links[0] = $link;
ca74886f
V
275
276 // Write database to disk
f21abf32 277 $this->write();
ca74886f
V
278 }
279
280 /**
281 * Reads database from disk to memory
282 */
f21abf32 283 private function read()
ca74886f 284 {
336a28fa 285 // Public bookmarks are hidden and user not logged in => nothing to show
628b97cb
V
286 if ($this->hidePublicLinks && !$this->loggedIn) {
287 $this->links = array();
578a84bd 288 return;
289 }
290
9ec0a611
A
291 $this->urls = [];
292 $this->ids = [];
b2306b0c 293 $this->links = FileUtils::readFlatDB($this->datastore, []);
ca74886f 294
29d10882
A
295 $toremove = array();
296 foreach ($this->links as $key => &$link) {
f24896b2 297 if (!$this->loggedIn && $link['private'] != 0) {
29d10882 298 // Transition for not upgraded databases.
9ec0a611 299 unset($this->links[$key]);
29d10882 300 continue;
ca74886f 301 }
195acf9f 302
510377d2 303 // Sanitize data fields.
90e5bd65 304 sanitizeLink($link);
195acf9f
A
305
306 // Remove private tags if the user is not logged in.
f24896b2 307 if (!$this->loggedIn) {
9866b408 308 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
195acf9f
A
309 }
310
520d2957 311 $link['real_url'] = $link['url'];
29d10882 312
b790f900
A
313 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
314
336a28fa 315 // To be able to load bookmarks before running the update, and prepare the update
f24896b2 316 if (!isset($link['created'])) {
29d10882 317 $link['id'] = $link['linkdate'];
d592daea 318 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
f24896b2 319 if (!empty($link['updated'])) {
d592daea 320 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
29d10882 321 }
d592daea 322 $link['shorturl'] = smallHash($link['linkdate']);
29d10882 323 }
29d10882 324
9ec0a611
A
325 $this->urls[$link['url']] = $key;
326 $this->ids[$link['id']] = $key;
5f85fcd8 327 }
ca74886f
V
328 }
329
2e28269b
V
330 /**
331 * Saves the database from memory to disk
332 *
333 * @throws IOException the datastore is not writable
334 */
f21abf32 335 private function write()
2e28269b 336 {
9ec0a611 337 $this->reorder();
b2306b0c 338 FileUtils::writeFlatDB($this->datastore, $this->links);
2e28269b
V
339 }
340
ca74886f
V
341 /**
342 * Saves the database from memory to disk
01e48f26
V
343 *
344 * @param string $pageCacheDir page cache directory
ca74886f 345 */
f21abf32 346 public function save($pageCacheDir)
ca74886f 347 {
628b97cb 348 if (!$this->loggedIn) {
ca74886f
V
349 // TODO: raise an Exception instead
350 die('You are not authorized to change the database.');
351 }
2e28269b 352
f21abf32 353 $this->write();
2e28269b 354
01e48f26 355 invalidateCaches($pageCacheDir);
ca74886f
V
356 }
357
358 /**
359 * Returns the link for a given URL, or False if it does not exist.
ef591e7e
GV
360 *
361 * @param string $url URL to search for
362 *
363 * @return mixed the existing link if it exists, else 'false'
ca74886f
V
364 */
365 public function getLinkFromUrl($url)
366 {
628b97cb
V
367 if (isset($this->urls[$url])) {
368 return $this->links[$this->urls[$url]];
ca74886f
V
369 }
370 return false;
371 }
372
373 /**
528a6f8a 374 * Returns the shaare corresponding to a smallHash.
ca74886f 375 *
528a6f8a
A
376 * @param string $request QUERY_STRING server parameter.
377 *
378 * @return array $filtered array containing permalink data.
379 *
336a28fa 380 * @throws BookmarkNotFoundException if the smallhash is malformed or doesn't match any link.
528a6f8a
A
381 */
382 public function filterHash($request)
383 {
384 $request = substr($request, 0, 6);
336a28fa
A
385 $linkFilter = new LegacyLinkFilter($this->links);
386 return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request);
528a6f8a
A
387 }
388
389 /**
390 * Returns the list of articles for a given day.
391 *
392 * @param string $request day to filter. Format: YYYYMMDD.
393 *
394 * @return array list of shaare found.
395 */
f211e417
V
396 public function filterDay($request)
397 {
336a28fa
A
398 $linkFilter = new LegacyLinkFilter($this->links);
399 return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request);
528a6f8a
A
400 }
401
402 /**
336a28fa 403 * Filter bookmarks according to search parameters.
528a6f8a 404 *
6696729b 405 * @param array $filterRequest Search request content. Supported keys:
528a6f8a
A
406 * - searchtags: list of tags
407 * - searchterm: term search
6696729b 408 * @param bool $casesensitive Optional: Perform case sensitive filter
336a28fa
A
409 * @param string $visibility return only all/private/public bookmarks
410 * @param bool $untaggedonly return only untagged bookmarks
ca74886f 411 *
336a28fa 412 * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
ca74886f 413 */
9d9f6d75
V
414 public function filterSearch(
415 $filterRequest = array(),
416 $casesensitive = false,
417 $visibility = 'all',
418 $untaggedonly = false
419 ) {
9f962705 420
528a6f8a 421 // Filter link database according to parameters.
7d86f40b
A
422 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
423 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
528a6f8a 424
336a28fa
A
425 // Search tags + fullsearch - blank string parameter will return all bookmarks.
426 $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext"
7d86f40b 427 $request = [$searchtags, $searchterm];
528a6f8a 428
336a28fa 429 $linkFilter = new LegacyLinkFilter($this);
f210d94f 430 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
ca74886f
V
431 }
432
433 /**
336a28fa 434 * Returns the list tags appearing in the bookmarks with the given tags
f8c5660d 435 *
336a28fa
A
436 * @param array $filteringTags tags selecting the bookmarks to consider
437 * @param string $visibility process only all/private/public bookmarks
f8c5660d
A
438 *
439 * @return array tag => linksCount
ca74886f 440 */
6ccd0b21 441 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
ca74886f 442 {
f8c5660d
A
443 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
444 $tags = [];
445 $caseMapping = [];
6ccd0b21 446 foreach ($links as $link) {
4b35853d 447 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
b1eb5d1d
A
448 if (empty($tag)) {
449 continue;
ca74886f 450 }
b1eb5d1d
A
451 // The first case found will be displayed.
452 if (!isset($caseMapping[strtolower($tag)])) {
453 $caseMapping[strtolower($tag)] = $tag;
454 $tags[$caseMapping[strtolower($tag)]] = 0;
455 }
456 $tags[$caseMapping[strtolower($tag)]]++;
ca74886f
V
457 }
458 }
f8c5660d
A
459
460 /*
461 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
462 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
463 *
464 * So we now use array_multisort() to sort tags by DESC occurrences,
465 * then ASC alphabetically for equal values.
466 *
467 * @see https://github.com/shaarli/Shaarli/issues/1142
468 */
f28396a2
A
469 $keys = array_keys($tags);
470 $tmpTags = array_combine($keys, $keys);
f28396a2 471 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
ca74886f
V
472 return $tags;
473 }
474
3b67b222 475 /**
336a28fa 476 * Rename or delete a tag across all bookmarks.
3b67b222
A
477 *
478 * @param string $from Tag to rename
6696729b 479 * @param string $to New tag. If none is provided, the from tag will be deleted
3b67b222 480 *
336a28fa 481 * @return array|bool List of altered bookmarks or false on error
3b67b222
A
482 */
483 public function renameTag($from, $to)
484 {
485 if (empty($from)) {
486 return false;
487 }
488 $delete = empty($to);
489 // True for case-sensitive tag search.
490 $linksToAlter = $this->filterSearch(['searchtags' => $from], true);
f211e417 491 foreach ($linksToAlter as $key => &$value) {
3b67b222
A
492 $tags = preg_split('/\s+/', trim($value['tags']));
493 if (($pos = array_search($from, $tags)) !== false) {
494 if ($delete) {
495 unset($tags[$pos]); // Remove tag.
496 } else {
497 $tags[$pos] = trim($to);
498 }
499 $value['tags'] = trim(implode(' ', array_unique($tags)));
500 $this[$value['id']] = $value;
501 }
502 }
503
504 return $linksToAlter;
505 }
506
ca74886f
V
507 /**
508 * Returns the list of days containing articles (oldest first)
509 * Output: An array containing days (in format YYYYMMDD).
510 */
511 public function days()
512 {
513 $linkDays = array();
29d10882
A
514 foreach ($this->links as $link) {
515 $linkDays[$link['created']->format('Ymd')] = 0;
ca74886f
V
516 }
517 $linkDays = array_keys($linkDays);
518 sort($linkDays);
510377d2 519
ca74886f
V
520 return $linkDays;
521 }
29d10882
A
522
523 /**
336a28fa 524 * Reorder bookmarks by creation date (newest first).
29d10882
A
525 *
526 * Also update the urls and ids mapping arrays.
527 *
528 * @param string $order ASC|DESC
529 */
530 public function reorder($order = 'DESC')
531 {
532 $order = $order === 'ASC' ? -1 : 1;
533 // Reorder array by dates.
f211e417 534 usort($this->links, function ($a, $b) use ($order) {
4154c25b
A
535 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
536 return $a['sticky'] ? -1 : 1;
537 }
9f962705
A
538 if ($a['created'] == $b['created']) {
539 return $a['id'] < $b['id'] ? 1 * $order : -1 * $order;
540 }
29d10882
A
541 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
542 });
543
9ec0a611
A
544 $this->urls = [];
545 $this->ids = [];
29d10882
A
546 foreach ($this->links as $key => $link) {
547 $this->urls[$link['url']] = $key;
548 $this->ids[$link['id']] = $key;
549 }
550 }
551
552 /**
553 * Return the next key for link creation.
554 * E.g. If the last ID is 597, the next will be 598.
555 *
556 * @return int next ID.
557 */
558 public function getNextId()
559 {
560 if (!empty($this->ids)) {
561 return max(array_keys($this->ids)) + 1;
562 }
563 return 0;
564 }
565
566 /**
336a28fa 567 * Returns a link offset in bookmarks array from its unique ID.
29d10882
A
568 *
569 * @param int $id Persistent ID of a link.
570 *
d592daea 571 * @return int Real offset in local array, or null if doesn't exist.
29d10882
A
572 */
573 protected function getLinkOffset($id)
574 {
575 if (isset($this->ids[$id])) {
576 return $this->ids[$id];
577 }
578 return null;
579 }
ca74886f 580}