]> git.immae.eu Git - github/shaarli/Shaarli.git/blame - application/bookmark/LinkDB.php
Optimize and cleanup imports
[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(),
274 'tags' => 'opensource software'
ca74886f 275 );
d592daea 276 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 277 $this->links[1] = $link;
ca74886f
V
278
279 $link = array(
29d10882 280 'id' => 0,
f24896b2
V
281 'title' => t('My secret stuff... - Pastebin.com'),
282 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
283 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
284 'private' => 1,
285 'created' => new DateTime('1 minute ago'),
286 'tags' => 'secretstuff',
ca74886f 287 );
d592daea 288 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 289 $this->links[0] = $link;
ca74886f
V
290
291 // Write database to disk
f21abf32 292 $this->write();
ca74886f
V
293 }
294
295 /**
296 * Reads database from disk to memory
297 */
f21abf32 298 private function read()
ca74886f 299 {
578a84bd 300 // Public links are hidden and user not logged in => nothing to show
628b97cb
V
301 if ($this->hidePublicLinks && !$this->loggedIn) {
302 $this->links = array();
578a84bd 303 return;
304 }
305
9ec0a611
A
306 $this->urls = [];
307 $this->ids = [];
b2306b0c 308 $this->links = FileUtils::readFlatDB($this->datastore, []);
ca74886f 309
29d10882
A
310 $toremove = array();
311 foreach ($this->links as $key => &$link) {
f24896b2 312 if (!$this->loggedIn && $link['private'] != 0) {
29d10882 313 // Transition for not upgraded databases.
9ec0a611 314 unset($this->links[$key]);
29d10882 315 continue;
ca74886f 316 }
195acf9f 317
510377d2 318 // Sanitize data fields.
90e5bd65 319 sanitizeLink($link);
195acf9f
A
320
321 // Remove private tags if the user is not logged in.
f24896b2 322 if (!$this->loggedIn) {
9866b408 323 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
195acf9f
A
324 }
325
90e5bd65 326 // Do not use the redirector for internal links (Shaarli note URL starting with a '?').
628b97cb
V
327 if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
328 $link['real_url'] = $this->redirector;
043eae70
A
329 if ($this->redirectorEncode) {
330 $link['real_url'] .= urlencode(unescape($link['url']));
331 } else {
332 $link['real_url'] .= $link['url'];
333 }
f211e417 334 } else {
90e5bd65
A
335 $link['real_url'] = $link['url'];
336 }
29d10882
A
337
338 // To be able to load links before running the update, and prepare the update
f24896b2 339 if (!isset($link['created'])) {
29d10882 340 $link['id'] = $link['linkdate'];
d592daea 341 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
f24896b2 342 if (!empty($link['updated'])) {
d592daea 343 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
29d10882 344 }
d592daea 345 $link['shorturl'] = smallHash($link['linkdate']);
29d10882 346 }
29d10882 347
9ec0a611
A
348 $this->urls[$link['url']] = $key;
349 $this->ids[$link['id']] = $key;
5f85fcd8 350 }
ca74886f
V
351 }
352
2e28269b
V
353 /**
354 * Saves the database from memory to disk
355 *
356 * @throws IOException the datastore is not writable
357 */
f21abf32 358 private function write()
2e28269b 359 {
9ec0a611 360 $this->reorder();
b2306b0c 361 FileUtils::writeFlatDB($this->datastore, $this->links);
2e28269b
V
362 }
363
ca74886f
V
364 /**
365 * Saves the database from memory to disk
01e48f26
V
366 *
367 * @param string $pageCacheDir page cache directory
ca74886f 368 */
f21abf32 369 public function save($pageCacheDir)
ca74886f 370 {
628b97cb 371 if (!$this->loggedIn) {
ca74886f
V
372 // TODO: raise an Exception instead
373 die('You are not authorized to change the database.');
374 }
2e28269b 375
f21abf32 376 $this->write();
2e28269b 377
01e48f26 378 invalidateCaches($pageCacheDir);
ca74886f
V
379 }
380
381 /**
382 * Returns the link for a given URL, or False if it does not exist.
ef591e7e
GV
383 *
384 * @param string $url URL to search for
385 *
386 * @return mixed the existing link if it exists, else 'false'
ca74886f
V
387 */
388 public function getLinkFromUrl($url)
389 {
628b97cb
V
390 if (isset($this->urls[$url])) {
391 return $this->links[$this->urls[$url]];
ca74886f
V
392 }
393 return false;
394 }
395
396 /**
528a6f8a 397 * Returns the shaare corresponding to a smallHash.
ca74886f 398 *
528a6f8a
A
399 * @param string $request QUERY_STRING server parameter.
400 *
401 * @return array $filtered array containing permalink data.
402 *
403 * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
404 */
405 public function filterHash($request)
406 {
407 $request = substr($request, 0, 6);
628b97cb 408 $linkFilter = new LinkFilter($this->links);
528a6f8a
A
409 return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
410 }
411
412 /**
413 * Returns the list of articles for a given day.
414 *
415 * @param string $request day to filter. Format: YYYYMMDD.
416 *
417 * @return array list of shaare found.
418 */
f211e417
V
419 public function filterDay($request)
420 {
628b97cb 421 $linkFilter = new LinkFilter($this->links);
528a6f8a
A
422 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
423 }
424
425 /**
426 * Filter links according to search parameters.
427 *
6696729b 428 * @param array $filterRequest Search request content. Supported keys:
528a6f8a
A
429 * - searchtags: list of tags
430 * - searchterm: term search
6696729b
V
431 * @param bool $casesensitive Optional: Perform case sensitive filter
432 * @param string $visibility return only all/private/public links
433 * @param bool $untaggedonly return only untagged links
ca74886f 434 *
528a6f8a 435 * @return array filtered links, all links if no suitable filter was provided.
ca74886f 436 */
9d9f6d75
V
437 public function filterSearch(
438 $filterRequest = array(),
439 $casesensitive = false,
440 $visibility = 'all',
441 $untaggedonly = false
442 ) {
f24896b2 443
528a6f8a 444 // Filter link database according to parameters.
7d86f40b
A
445 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
446 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
528a6f8a 447
7d86f40b 448 // Search tags + fullsearch - blank string parameter will return all links.
f210d94f 449 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
7d86f40b 450 $request = [$searchtags, $searchterm];
528a6f8a 451
29d10882 452 $linkFilter = new LinkFilter($this);
f210d94f 453 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
ca74886f
V
454 }
455
456 /**
6ccd0b21 457 * Returns the list tags appearing in the links with the given tags
f8c5660d 458 *
6696729b
V
459 * @param array $filteringTags tags selecting the links to consider
460 * @param string $visibility process only all/private/public links
f8c5660d
A
461 *
462 * @return array tag => linksCount
ca74886f 463 */
6ccd0b21 464 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
ca74886f 465 {
f8c5660d
A
466 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
467 $tags = [];
468 $caseMapping = [];
6ccd0b21 469 foreach ($links as $link) {
4b35853d 470 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
b1eb5d1d
A
471 if (empty($tag)) {
472 continue;
ca74886f 473 }
b1eb5d1d
A
474 // The first case found will be displayed.
475 if (!isset($caseMapping[strtolower($tag)])) {
476 $caseMapping[strtolower($tag)] = $tag;
477 $tags[$caseMapping[strtolower($tag)]] = 0;
478 }
479 $tags[$caseMapping[strtolower($tag)]]++;
ca74886f
V
480 }
481 }
f8c5660d
A
482
483 /*
484 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
485 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
486 *
487 * So we now use array_multisort() to sort tags by DESC occurrences,
488 * then ASC alphabetically for equal values.
489 *
490 * @see https://github.com/shaarli/Shaarli/issues/1142
491 */
f28396a2
A
492 $keys = array_keys($tags);
493 $tmpTags = array_combine($keys, $keys);
f28396a2 494 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
ca74886f
V
495 return $tags;
496 }
497
3b67b222
A
498 /**
499 * Rename or delete a tag across all links.
500 *
501 * @param string $from Tag to rename
6696729b 502 * @param string $to New tag. If none is provided, the from tag will be deleted
3b67b222
A
503 *
504 * @return array|bool List of altered links or false on error
505 */
506 public function renameTag($from, $to)
507 {
508 if (empty($from)) {
509 return false;
510 }
511 $delete = empty($to);
512 // True for case-sensitive tag search.
513 $linksToAlter = $this->filterSearch(['searchtags' => $from], true);
f211e417 514 foreach ($linksToAlter as $key => &$value) {
3b67b222
A
515 $tags = preg_split('/\s+/', trim($value['tags']));
516 if (($pos = array_search($from, $tags)) !== false) {
517 if ($delete) {
518 unset($tags[$pos]); // Remove tag.
519 } else {
520 $tags[$pos] = trim($to);
521 }
522 $value['tags'] = trim(implode(' ', array_unique($tags)));
523 $this[$value['id']] = $value;
524 }
525 }
526
527 return $linksToAlter;
528 }
529
ca74886f
V
530 /**
531 * Returns the list of days containing articles (oldest first)
532 * Output: An array containing days (in format YYYYMMDD).
533 */
534 public function days()
535 {
536 $linkDays = array();
29d10882
A
537 foreach ($this->links as $link) {
538 $linkDays[$link['created']->format('Ymd')] = 0;
ca74886f
V
539 }
540 $linkDays = array_keys($linkDays);
541 sort($linkDays);
510377d2 542
ca74886f
V
543 return $linkDays;
544 }
29d10882
A
545
546 /**
547 * Reorder links by creation date (newest first).
548 *
549 * Also update the urls and ids mapping arrays.
550 *
551 * @param string $order ASC|DESC
552 */
553 public function reorder($order = 'DESC')
554 {
555 $order = $order === 'ASC' ? -1 : 1;
556 // Reorder array by dates.
f211e417 557 usort($this->links, function ($a, $b) use ($order) {
4154c25b
A
558 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
559 return $a['sticky'] ? -1 : 1;
560 }
29d10882
A
561 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
562 });
563
9ec0a611
A
564 $this->urls = [];
565 $this->ids = [];
29d10882
A
566 foreach ($this->links as $key => $link) {
567 $this->urls[$link['url']] = $key;
568 $this->ids[$link['id']] = $key;
569 }
570 }
571
572 /**
573 * Return the next key for link creation.
574 * E.g. If the last ID is 597, the next will be 598.
575 *
576 * @return int next ID.
577 */
578 public function getNextId()
579 {
580 if (!empty($this->ids)) {
581 return max(array_keys($this->ids)) + 1;
582 }
583 return 0;
584 }
585
586 /**
587 * Returns a link offset in links array from its unique ID.
588 *
589 * @param int $id Persistent ID of a link.
590 *
d592daea 591 * @return int Real offset in local array, or null if doesn't exist.
29d10882
A
592 */
593 protected function getLinkOffset($id)
594 {
595 if (isset($this->ids[$id])) {
596 return $this->ids[$id];
597 }
598 return null;
599 }
ca74886f 600}