]> git.immae.eu Git - github/shaarli/Shaarli.git/blame - application/bookmark/LinkDB.php
Merge pull request #1336 from ArthurHoaro/hotfix/atom-author
[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
520d2957
A
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).
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
ca74886f
V
91 /**
92 * Creates a new LinkDB
93 *
94 * Checks if the datastore exists; else, attempts to create a dummy one.
95 *
6696729b
V
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.
ca74886f 99 */
735ed4a9 100 public function __construct(
043eae70
A
101 $datastore,
102 $isLoggedIn,
520d2957 103 $hidePublicLinks
f211e417 104 ) {
9f962705 105
628b97cb
V
106 $this->datastore = $datastore;
107 $this->loggedIn = $isLoggedIn;
108 $this->hidePublicLinks = $hidePublicLinks;
f21abf32
V
109 $this->check();
110 $this->read();
ca74886f
V
111 }
112
113 /**
114 * Countable - Counts elements of an object
115 */
116 public function count()
117 {
628b97cb 118 return count($this->links);
ca74886f
V
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"
628b97cb 127 if (!$this->loggedIn) {
12266213 128 die(t('You are not authorized to add a link.'));
ca74886f 129 }
29d10882 130 if (!isset($value['id']) || empty($value['url'])) {
12266213 131 die(t('Internal Error: A link should always have an id and URL.'));
ca74886f 132 }
f24896b2 133 if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) {
12266213 134 die(t('You must specify an integer as a key.'));
29d10882 135 }
bc5f1597 136 if ($offset !== null && $offset !== $value['id']) {
12266213 137 die(t('Array offset and link ID must be equal.'));
29d10882
A
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);
ca74886f 146 }
628b97cb 147 $this->links[$offset] = $value;
29d10882
A
148 $this->urls[$value['url']] = $offset;
149 $this->ids[$value['id']] = $offset;
ca74886f
V
150 }
151
152 /**
153 * ArrayAccess - Whether or not an offset exists
154 */
155 public function offsetExists($offset)
156 {
29d10882 157 return array_key_exists($this->getLinkOffset($offset), $this->links);
ca74886f
V
158 }
159
160 /**
161 * ArrayAccess - Unsets an offset
162 */
163 public function offsetUnset($offset)
164 {
628b97cb 165 if (!$this->loggedIn) {
ca74886f
V
166 // TODO: raise an exception
167 die('You are not authorized to delete a link.');
168 }
29d10882
A
169 $realOffset = $this->getLinkOffset($offset);
170 $url = $this->links[$realOffset]['url'];
628b97cb 171 unset($this->urls[$url]);
29d10882
A
172 unset($this->ids[$realOffset]);
173 unset($this->links[$realOffset]);
ca74886f
V
174 }
175
176 /**
177 * ArrayAccess - Returns the value at specified offset
178 */
179 public function offsetGet($offset)
180 {
29d10882
A
181 $realOffset = $this->getLinkOffset($offset);
182 return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
ca74886f
V
183 }
184
185 /**
186 * Iterator - Returns the current element
187 */
735ed4a9 188 public function current()
ca74886f 189 {
29d10882 190 return $this[$this->keys[$this->position]];
ca74886f
V
191 }
192
193 /**
194 * Iterator - Returns the key of the current element
195 */
735ed4a9 196 public function key()
ca74886f 197 {
628b97cb 198 return $this->keys[$this->position];
ca74886f
V
199 }
200
201 /**
202 * Iterator - Moves forward to next element
203 */
735ed4a9 204 public function next()
ca74886f 205 {
628b97cb 206 ++$this->position;
ca74886f
V
207 }
208
209 /**
210 * Iterator - Rewinds the Iterator to the first element
211 *
212 * Entries are sorted by date (latest first)
213 */
735ed4a9 214 public function rewind()
ca74886f 215 {
29d10882 216 $this->keys = array_keys($this->ids);
628b97cb 217 $this->position = 0;
ca74886f
V
218 }
219
220 /**
221 * Iterator - Checks if current position is valid
222 */
735ed4a9 223 public function valid()
ca74886f 224 {
628b97cb 225 return isset($this->keys[$this->position]);
ca74886f
V
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 */
f21abf32 233 private function check()
ca74886f 234 {
628b97cb 235 if (file_exists($this->datastore)) {
ca74886f
V
236 return;
237 }
238
239 // Create a dummy database for example
628b97cb 240 $this->links = array();
ca74886f 241 $link = array(
29d10882 242 'id' => 1,
f24896b2
V
243 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
244 'url' => 'https://shaarli.readthedocs.io',
245 'description' => t(
9d9f6d75 246 'Welcome to Shaarli! This is your first public bookmark. '
f24896b2 247 . 'To edit or delete me, you must first login.
598376d4 248
12266213 249To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
598376d4 250
9d9f6d75
V
251You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
252 ),
f24896b2
V
253 'private' => 0,
254 'created' => new DateTime(),
b790f900
A
255 'tags' => 'opensource software',
256 'sticky' => false,
ca74886f 257 );
d592daea 258 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 259 $this->links[1] = $link;
ca74886f
V
260
261 $link = array(
29d10882 262 'id' => 0,
f24896b2
V
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',
b790f900 269 'sticky' => false,
ca74886f 270 );
d592daea 271 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
29d10882 272 $this->links[0] = $link;
ca74886f
V
273
274 // Write database to disk
f21abf32 275 $this->write();
ca74886f
V
276 }
277
278 /**
279 * Reads database from disk to memory
280 */
f21abf32 281 private function read()
ca74886f 282 {
578a84bd 283 // Public links are hidden and user not logged in => nothing to show
628b97cb
V
284 if ($this->hidePublicLinks && !$this->loggedIn) {
285 $this->links = array();
578a84bd 286 return;
287 }
288
9ec0a611
A
289 $this->urls = [];
290 $this->ids = [];
b2306b0c 291 $this->links = FileUtils::readFlatDB($this->datastore, []);
ca74886f 292
29d10882
A
293 $toremove = array();
294 foreach ($this->links as $key => &$link) {
f24896b2 295 if (!$this->loggedIn && $link['private'] != 0) {
29d10882 296 // Transition for not upgraded databases.
9ec0a611 297 unset($this->links[$key]);
29d10882 298 continue;
ca74886f 299 }
195acf9f 300
510377d2 301 // Sanitize data fields.
90e5bd65 302 sanitizeLink($link);
195acf9f
A
303
304 // Remove private tags if the user is not logged in.
f24896b2 305 if (!$this->loggedIn) {
9866b408 306 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
195acf9f
A
307 }
308
520d2957 309 $link['real_url'] = $link['url'];
29d10882 310
b790f900
A
311 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
312
29d10882 313 // To be able to load links before running the update, and prepare the update
f24896b2 314 if (!isset($link['created'])) {
29d10882 315 $link['id'] = $link['linkdate'];
d592daea 316 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
f24896b2 317 if (!empty($link['updated'])) {
d592daea 318 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
29d10882 319 }
d592daea 320 $link['shorturl'] = smallHash($link['linkdate']);
29d10882 321 }
29d10882 322
9ec0a611
A
323 $this->urls[$link['url']] = $key;
324 $this->ids[$link['id']] = $key;
5f85fcd8 325 }
ca74886f
V
326 }
327
2e28269b
V
328 /**
329 * Saves the database from memory to disk
330 *
331 * @throws IOException the datastore is not writable
332 */
f21abf32 333 private function write()
2e28269b 334 {
9ec0a611 335 $this->reorder();
b2306b0c 336 FileUtils::writeFlatDB($this->datastore, $this->links);
2e28269b
V
337 }
338
ca74886f
V
339 /**
340 * Saves the database from memory to disk
01e48f26
V
341 *
342 * @param string $pageCacheDir page cache directory
ca74886f 343 */
f21abf32 344 public function save($pageCacheDir)
ca74886f 345 {
628b97cb 346 if (!$this->loggedIn) {
ca74886f
V
347 // TODO: raise an Exception instead
348 die('You are not authorized to change the database.');
349 }
2e28269b 350
f21abf32 351 $this->write();
2e28269b 352
01e48f26 353 invalidateCaches($pageCacheDir);
ca74886f
V
354 }
355
356 /**
357 * Returns the link for a given URL, or False if it does not exist.
ef591e7e
GV
358 *
359 * @param string $url URL to search for
360 *
361 * @return mixed the existing link if it exists, else 'false'
ca74886f
V
362 */
363 public function getLinkFromUrl($url)
364 {
628b97cb
V
365 if (isset($this->urls[$url])) {
366 return $this->links[$this->urls[$url]];
ca74886f
V
367 }
368 return false;
369 }
370
371 /**
528a6f8a 372 * Returns the shaare corresponding to a smallHash.
ca74886f 373 *
528a6f8a
A
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);
628b97cb 383 $linkFilter = new LinkFilter($this->links);
528a6f8a
A
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 */
f211e417
V
394 public function filterDay($request)
395 {
628b97cb 396 $linkFilter = new LinkFilter($this->links);
528a6f8a
A
397 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
398 }
399
400 /**
401 * Filter links according to search parameters.
402 *
6696729b 403 * @param array $filterRequest Search request content. Supported keys:
528a6f8a
A
404 * - searchtags: list of tags
405 * - searchterm: term search
6696729b
V
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
ca74886f 409 *
528a6f8a 410 * @return array filtered links, all links if no suitable filter was provided.
ca74886f 411 */
9d9f6d75
V
412 public function filterSearch(
413 $filterRequest = array(),
414 $casesensitive = false,
415 $visibility = 'all',
416 $untaggedonly = false
417 ) {
9f962705 418
528a6f8a 419 // Filter link database according to parameters.
7d86f40b
A
420 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
421 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
528a6f8a 422
7d86f40b 423 // Search tags + fullsearch - blank string parameter will return all links.
f210d94f 424 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
7d86f40b 425 $request = [$searchtags, $searchterm];
528a6f8a 426
29d10882 427 $linkFilter = new LinkFilter($this);
f210d94f 428 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
ca74886f
V
429 }
430
431 /**
6ccd0b21 432 * Returns the list tags appearing in the links with the given tags
f8c5660d 433 *
6696729b
V
434 * @param array $filteringTags tags selecting the links to consider
435 * @param string $visibility process only all/private/public links
f8c5660d
A
436 *
437 * @return array tag => linksCount
ca74886f 438 */
6ccd0b21 439 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
ca74886f 440 {
f8c5660d
A
441 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
442 $tags = [];
443 $caseMapping = [];
6ccd0b21 444 foreach ($links as $link) {
4b35853d 445 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
b1eb5d1d
A
446 if (empty($tag)) {
447 continue;
ca74886f 448 }
b1eb5d1d
A
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)]]++;
ca74886f
V
455 }
456 }
f8c5660d
A
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 */
f28396a2
A
467 $keys = array_keys($tags);
468 $tmpTags = array_combine($keys, $keys);
f28396a2 469 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
ca74886f
V
470 return $tags;
471 }
472
3b67b222
A
473 /**
474 * Rename or delete a tag across all links.
475 *
476 * @param string $from Tag to rename
6696729b 477 * @param string $to New tag. If none is provided, the from tag will be deleted
3b67b222
A
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);
f211e417 489 foreach ($linksToAlter as $key => &$value) {
3b67b222
A
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
ca74886f
V
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();
29d10882
A
512 foreach ($this->links as $link) {
513 $linkDays[$link['created']->format('Ymd')] = 0;
ca74886f
V
514 }
515 $linkDays = array_keys($linkDays);
516 sort($linkDays);
510377d2 517
ca74886f
V
518 return $linkDays;
519 }
29d10882
A
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.
f211e417 532 usort($this->links, function ($a, $b) use ($order) {
4154c25b
A
533 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
534 return $a['sticky'] ? -1 : 1;
535 }
9f962705
A
536 if ($a['created'] == $b['created']) {
537 return $a['id'] < $b['id'] ? 1 * $order : -1 * $order;
538 }
29d10882
A
539 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
540 });
541
9ec0a611
A
542 $this->urls = [];
543 $this->ids = [];
29d10882
A
544 foreach ($this->links as $key => $link) {
545 $this->urls[$link['url']] = $key;
546 $this->ids[$link['id']] = $key;
547 }
548 }
549
550 /**
551 * Return the next key for link creation.
552 * E.g. If the last ID is 597, the next will be 598.
553 *
554 * @return int next ID.
555 */
556 public function getNextId()
557 {
558 if (!empty($this->ids)) {
559 return max(array_keys($this->ids)) + 1;
560 }
561 return 0;
562 }
563
564 /**
565 * Returns a link offset in links array from its unique ID.
566 *
567 * @param int $id Persistent ID of a link.
568 *
d592daea 569 * @return int Real offset in local array, or null if doesn't exist.
29d10882
A
570 */
571 protected function getLinkOffset($id)
572 {
573 if (isset($this->ids[$id])) {
574 return $this->ids[$id];
575 }
576 return null;
577 }
ca74886f 578}