]> git.immae.eu Git - github/shaarli/Shaarli.git/blob - application/bookmark/LinkDB.php
Fix a warning if links sticky status isn't set
[github/shaarli/Shaarli.git] / application / bookmark / LinkDB.php
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 (no redirector, relative, etc.).
33 * Can be absolute or relative.
34 * Relative URLs are permalinks (e.g.'?m-ukcw')
35 * - real_url Absolute processed URL.
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 // link redirector set in user settings.
92 private $redirector;
93
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
104 /**
105 * Creates a new LinkDB
106 *
107 * Checks if the datastore exists; else, attempts to create a dummy one.
108 *
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.
113 * @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
114 */
115 public function __construct(
116 $datastore,
117 $isLoggedIn,
118 $hidePublicLinks,
119 $redirector = '',
120 $redirectorEncode = true
121 ) {
122
123 $this->datastore = $datastore;
124 $this->loggedIn = $isLoggedIn;
125 $this->hidePublicLinks = $hidePublicLinks;
126 $this->redirector = $redirector;
127 $this->redirectorEncode = $redirectorEncode === true;
128 $this->check();
129 $this->read();
130 }
131
132 /**
133 * Countable - Counts elements of an object
134 */
135 public function count()
136 {
137 return count($this->links);
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"
146 if (!$this->loggedIn) {
147 die(t('You are not authorized to add a link.'));
148 }
149 if (!isset($value['id']) || empty($value['url'])) {
150 die(t('Internal Error: A link should always have an id and URL.'));
151 }
152 if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) {
153 die(t('You must specify an integer as a key.'));
154 }
155 if ($offset !== null && $offset !== $value['id']) {
156 die(t('Array offset and link ID must be equal.'));
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);
165 }
166 $this->links[$offset] = $value;
167 $this->urls[$value['url']] = $offset;
168 $this->ids[$value['id']] = $offset;
169 }
170
171 /**
172 * ArrayAccess - Whether or not an offset exists
173 */
174 public function offsetExists($offset)
175 {
176 return array_key_exists($this->getLinkOffset($offset), $this->links);
177 }
178
179 /**
180 * ArrayAccess - Unsets an offset
181 */
182 public function offsetUnset($offset)
183 {
184 if (!$this->loggedIn) {
185 // TODO: raise an exception
186 die('You are not authorized to delete a link.');
187 }
188 $realOffset = $this->getLinkOffset($offset);
189 $url = $this->links[$realOffset]['url'];
190 unset($this->urls[$url]);
191 unset($this->ids[$realOffset]);
192 unset($this->links[$realOffset]);
193 }
194
195 /**
196 * ArrayAccess - Returns the value at specified offset
197 */
198 public function offsetGet($offset)
199 {
200 $realOffset = $this->getLinkOffset($offset);
201 return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
202 }
203
204 /**
205 * Iterator - Returns the current element
206 */
207 public function current()
208 {
209 return $this[$this->keys[$this->position]];
210 }
211
212 /**
213 * Iterator - Returns the key of the current element
214 */
215 public function key()
216 {
217 return $this->keys[$this->position];
218 }
219
220 /**
221 * Iterator - Moves forward to next element
222 */
223 public function next()
224 {
225 ++$this->position;
226 }
227
228 /**
229 * Iterator - Rewinds the Iterator to the first element
230 *
231 * Entries are sorted by date (latest first)
232 */
233 public function rewind()
234 {
235 $this->keys = array_keys($this->ids);
236 $this->position = 0;
237 }
238
239 /**
240 * Iterator - Checks if current position is valid
241 */
242 public function valid()
243 {
244 return isset($this->keys[$this->position]);
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 */
252 private function check()
253 {
254 if (file_exists($this->datastore)) {
255 return;
256 }
257
258 // Create a dummy database for example
259 $this->links = array();
260 $link = array(
261 'id' => 1,
262 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
263 'url' => 'https://shaarli.readthedocs.io',
264 'description' => t(
265 'Welcome to Shaarli! This is your first public bookmark. '
266 . 'To edit or delete me, you must first login.
267
268 To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
269
270 You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
271 ),
272 'private' => 0,
273 'created' => new DateTime(),
274 'tags' => 'opensource software',
275 'sticky' => false,
276 );
277 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
278 $this->links[1] = $link;
279
280 $link = array(
281 'id' => 0,
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',
288 'sticky' => false,
289 );
290 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
291 $this->links[0] = $link;
292
293 // Write database to disk
294 $this->write();
295 }
296
297 /**
298 * Reads database from disk to memory
299 */
300 private function read()
301 {
302 // Public links are hidden and user not logged in => nothing to show
303 if ($this->hidePublicLinks && !$this->loggedIn) {
304 $this->links = array();
305 return;
306 }
307
308 $this->urls = [];
309 $this->ids = [];
310 $this->links = FileUtils::readFlatDB($this->datastore, []);
311
312 $toremove = array();
313 foreach ($this->links as $key => &$link) {
314 if (!$this->loggedIn && $link['private'] != 0) {
315 // Transition for not upgraded databases.
316 unset($this->links[$key]);
317 continue;
318 }
319
320 // Sanitize data fields.
321 sanitizeLink($link);
322
323 // Remove private tags if the user is not logged in.
324 if (!$this->loggedIn) {
325 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
326 }
327
328 // Do not use the redirector for internal links (Shaarli note URL starting with a '?').
329 if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
330 $link['real_url'] = $this->redirector;
331 if ($this->redirectorEncode) {
332 $link['real_url'] .= urlencode(unescape($link['url']));
333 } else {
334 $link['real_url'] .= $link['url'];
335 }
336 } else {
337 $link['real_url'] = $link['url'];
338 }
339
340 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
341
342 // To be able to load links before running the update, and prepare the update
343 if (!isset($link['created'])) {
344 $link['id'] = $link['linkdate'];
345 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
346 if (!empty($link['updated'])) {
347 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
348 }
349 $link['shorturl'] = smallHash($link['linkdate']);
350 }
351
352 $this->urls[$link['url']] = $key;
353 $this->ids[$link['id']] = $key;
354 }
355 }
356
357 /**
358 * Saves the database from memory to disk
359 *
360 * @throws IOException the datastore is not writable
361 */
362 private function write()
363 {
364 $this->reorder();
365 FileUtils::writeFlatDB($this->datastore, $this->links);
366 }
367
368 /**
369 * Saves the database from memory to disk
370 *
371 * @param string $pageCacheDir page cache directory
372 */
373 public function save($pageCacheDir)
374 {
375 if (!$this->loggedIn) {
376 // TODO: raise an Exception instead
377 die('You are not authorized to change the database.');
378 }
379
380 $this->write();
381
382 invalidateCaches($pageCacheDir);
383 }
384
385 /**
386 * Returns the link for a given URL, or False if it does not exist.
387 *
388 * @param string $url URL to search for
389 *
390 * @return mixed the existing link if it exists, else 'false'
391 */
392 public function getLinkFromUrl($url)
393 {
394 if (isset($this->urls[$url])) {
395 return $this->links[$this->urls[$url]];
396 }
397 return false;
398 }
399
400 /**
401 * Returns the shaare corresponding to a smallHash.
402 *
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);
412 $linkFilter = new LinkFilter($this->links);
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 */
423 public function filterDay($request)
424 {
425 $linkFilter = new LinkFilter($this->links);
426 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
427 }
428
429 /**
430 * Filter links according to search parameters.
431 *
432 * @param array $filterRequest Search request content. Supported keys:
433 * - searchtags: list of tags
434 * - searchterm: term search
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
438 *
439 * @return array filtered links, all links if no suitable filter was provided.
440 */
441 public function filterSearch(
442 $filterRequest = array(),
443 $casesensitive = false,
444 $visibility = 'all',
445 $untaggedonly = false
446 ) {
447
448 // Filter link database according to parameters.
449 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
450 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
451
452 // Search tags + fullsearch - blank string parameter will return all links.
453 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
454 $request = [$searchtags, $searchterm];
455
456 $linkFilter = new LinkFilter($this);
457 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
458 }
459
460 /**
461 * Returns the list tags appearing in the links with the given tags
462 *
463 * @param array $filteringTags tags selecting the links to consider
464 * @param string $visibility process only all/private/public links
465 *
466 * @return array tag => linksCount
467 */
468 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
469 {
470 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
471 $tags = [];
472 $caseMapping = [];
473 foreach ($links as $link) {
474 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
475 if (empty($tag)) {
476 continue;
477 }
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)]]++;
484 }
485 }
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 */
496 $keys = array_keys($tags);
497 $tmpTags = array_combine($keys, $keys);
498 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
499 return $tags;
500 }
501
502 /**
503 * Rename or delete a tag across all links.
504 *
505 * @param string $from Tag to rename
506 * @param string $to New tag. If none is provided, the from tag will be deleted
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);
518 foreach ($linksToAlter as $key => &$value) {
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
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();
541 foreach ($this->links as $link) {
542 $linkDays[$link['created']->format('Ymd')] = 0;
543 }
544 $linkDays = array_keys($linkDays);
545 sort($linkDays);
546
547 return $linkDays;
548 }
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.
561 usort($this->links, function ($a, $b) use ($order) {
562 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
563 return $a['sticky'] ? -1 : 1;
564 }
565 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
566 });
567
568 $this->urls = [];
569 $this->ids = [];
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 *
595 * @return int Real offset in local array, or null if doesn't exist.
596 */
597 protected function getLinkOffset($id)
598 {
599 if (isset($this->ids[$id])) {
600 return $this->ids[$id];
601 }
602 return null;
603 }
604 }