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