]> git.immae.eu Git - github/shaarli/Shaarli.git/blob - application/bookmark/LinkDB.php
Remove the redirector setting
[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.
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).
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 /**
92 * Creates a new LinkDB
93 *
94 * Checks if the datastore exists; else, attempts to create a dummy one.
95 *
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.
99 */
100 public function __construct(
101 $datastore,
102 $isLoggedIn,
103 $hidePublicLinks
104 ) {
105
106 $this->datastore = $datastore;
107 $this->loggedIn = $isLoggedIn;
108 $this->hidePublicLinks = $hidePublicLinks;
109 $this->check();
110 $this->read();
111 }
112
113 /**
114 * Countable - Counts elements of an object
115 */
116 public function count()
117 {
118 return count($this->links);
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"
127 if (!$this->loggedIn) {
128 die(t('You are not authorized to add a link.'));
129 }
130 if (!isset($value['id']) || empty($value['url'])) {
131 die(t('Internal Error: A link should always have an id and URL.'));
132 }
133 if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) {
134 die(t('You must specify an integer as a key.'));
135 }
136 if ($offset !== null && $offset !== $value['id']) {
137 die(t('Array offset and link ID must be equal.'));
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);
146 }
147 $this->links[$offset] = $value;
148 $this->urls[$value['url']] = $offset;
149 $this->ids[$value['id']] = $offset;
150 }
151
152 /**
153 * ArrayAccess - Whether or not an offset exists
154 */
155 public function offsetExists($offset)
156 {
157 return array_key_exists($this->getLinkOffset($offset), $this->links);
158 }
159
160 /**
161 * ArrayAccess - Unsets an offset
162 */
163 public function offsetUnset($offset)
164 {
165 if (!$this->loggedIn) {
166 // TODO: raise an exception
167 die('You are not authorized to delete a link.');
168 }
169 $realOffset = $this->getLinkOffset($offset);
170 $url = $this->links[$realOffset]['url'];
171 unset($this->urls[$url]);
172 unset($this->ids[$realOffset]);
173 unset($this->links[$realOffset]);
174 }
175
176 /**
177 * ArrayAccess - Returns the value at specified offset
178 */
179 public function offsetGet($offset)
180 {
181 $realOffset = $this->getLinkOffset($offset);
182 return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
183 }
184
185 /**
186 * Iterator - Returns the current element
187 */
188 public function current()
189 {
190 return $this[$this->keys[$this->position]];
191 }
192
193 /**
194 * Iterator - Returns the key of the current element
195 */
196 public function key()
197 {
198 return $this->keys[$this->position];
199 }
200
201 /**
202 * Iterator - Moves forward to next element
203 */
204 public function next()
205 {
206 ++$this->position;
207 }
208
209 /**
210 * Iterator - Rewinds the Iterator to the first element
211 *
212 * Entries are sorted by date (latest first)
213 */
214 public function rewind()
215 {
216 $this->keys = array_keys($this->ids);
217 $this->position = 0;
218 }
219
220 /**
221 * Iterator - Checks if current position is valid
222 */
223 public function valid()
224 {
225 return isset($this->keys[$this->position]);
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 */
233 private function check()
234 {
235 if (file_exists($this->datastore)) {
236 return;
237 }
238
239 // Create a dummy database for example
240 $this->links = array();
241 $link = array(
242 'id' => 1,
243 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'),
244 'url' => 'https://shaarli.readthedocs.io',
245 'description' => t(
246 'Welcome to Shaarli! This is your first public bookmark. '
247 . 'To edit or delete me, you must first login.
248
249 To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
250
251 You use the community supported version of the original Shaarli project, by Sebastien Sauvage.'
252 ),
253 'private' => 0,
254 'created' => new DateTime(),
255 'tags' => 'opensource software'
256 );
257 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
258 $this->links[1] = $link;
259
260 $link = array(
261 'id' => 0,
262 'title' => t('My secret stuff... - Pastebin.com'),
263 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
264 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'),
265 'private' => 1,
266 'created' => new DateTime('1 minute ago'),
267 'tags' => 'secretstuff',
268 );
269 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
270 $this->links[0] = $link;
271
272 // Write database to disk
273 $this->write();
274 }
275
276 /**
277 * Reads database from disk to memory
278 */
279 private function read()
280 {
281 // Public links are hidden and user not logged in => nothing to show
282 if ($this->hidePublicLinks && !$this->loggedIn) {
283 $this->links = array();
284 return;
285 }
286
287 $this->urls = [];
288 $this->ids = [];
289 $this->links = FileUtils::readFlatDB($this->datastore, []);
290
291 $toremove = array();
292 foreach ($this->links as $key => &$link) {
293 if (!$this->loggedIn && $link['private'] != 0) {
294 // Transition for not upgraded databases.
295 unset($this->links[$key]);
296 continue;
297 }
298
299 // Sanitize data fields.
300 sanitizeLink($link);
301
302 // Remove private tags if the user is not logged in.
303 if (!$this->loggedIn) {
304 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
305 }
306
307 $link['real_url'] = $link['url'];
308
309 // To be able to load links before running the update, and prepare the update
310 if (!isset($link['created'])) {
311 $link['id'] = $link['linkdate'];
312 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
313 if (!empty($link['updated'])) {
314 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
315 }
316 $link['shorturl'] = smallHash($link['linkdate']);
317 }
318
319 $this->urls[$link['url']] = $key;
320 $this->ids[$link['id']] = $key;
321 }
322 }
323
324 /**
325 * Saves the database from memory to disk
326 *
327 * @throws IOException the datastore is not writable
328 */
329 private function write()
330 {
331 $this->reorder();
332 FileUtils::writeFlatDB($this->datastore, $this->links);
333 }
334
335 /**
336 * Saves the database from memory to disk
337 *
338 * @param string $pageCacheDir page cache directory
339 */
340 public function save($pageCacheDir)
341 {
342 if (!$this->loggedIn) {
343 // TODO: raise an Exception instead
344 die('You are not authorized to change the database.');
345 }
346
347 $this->write();
348
349 invalidateCaches($pageCacheDir);
350 }
351
352 /**
353 * Returns the link for a given URL, or False if it does not exist.
354 *
355 * @param string $url URL to search for
356 *
357 * @return mixed the existing link if it exists, else 'false'
358 */
359 public function getLinkFromUrl($url)
360 {
361 if (isset($this->urls[$url])) {
362 return $this->links[$this->urls[$url]];
363 }
364 return false;
365 }
366
367 /**
368 * Returns the shaare corresponding to a smallHash.
369 *
370 * @param string $request QUERY_STRING server parameter.
371 *
372 * @return array $filtered array containing permalink data.
373 *
374 * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
375 */
376 public function filterHash($request)
377 {
378 $request = substr($request, 0, 6);
379 $linkFilter = new LinkFilter($this->links);
380 return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
381 }
382
383 /**
384 * Returns the list of articles for a given day.
385 *
386 * @param string $request day to filter. Format: YYYYMMDD.
387 *
388 * @return array list of shaare found.
389 */
390 public function filterDay($request)
391 {
392 $linkFilter = new LinkFilter($this->links);
393 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
394 }
395
396 /**
397 * Filter links according to search parameters.
398 *
399 * @param array $filterRequest Search request content. Supported keys:
400 * - searchtags: list of tags
401 * - searchterm: term search
402 * @param bool $casesensitive Optional: Perform case sensitive filter
403 * @param string $visibility return only all/private/public links
404 * @param bool $untaggedonly return only untagged links
405 *
406 * @return array filtered links, all links if no suitable filter was provided.
407 */
408 public function filterSearch(
409 $filterRequest = array(),
410 $casesensitive = false,
411 $visibility = 'all',
412 $untaggedonly = false
413 ) {
414
415 // Filter link database according to parameters.
416 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
417 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
418
419 // Search tags + fullsearch - blank string parameter will return all links.
420 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
421 $request = [$searchtags, $searchterm];
422
423 $linkFilter = new LinkFilter($this);
424 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
425 }
426
427 /**
428 * Returns the list tags appearing in the links with the given tags
429 *
430 * @param array $filteringTags tags selecting the links to consider
431 * @param string $visibility process only all/private/public links
432 *
433 * @return array tag => linksCount
434 */
435 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
436 {
437 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
438 $tags = [];
439 $caseMapping = [];
440 foreach ($links as $link) {
441 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
442 if (empty($tag)) {
443 continue;
444 }
445 // The first case found will be displayed.
446 if (!isset($caseMapping[strtolower($tag)])) {
447 $caseMapping[strtolower($tag)] = $tag;
448 $tags[$caseMapping[strtolower($tag)]] = 0;
449 }
450 $tags[$caseMapping[strtolower($tag)]]++;
451 }
452 }
453
454 /*
455 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
456 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
457 *
458 * So we now use array_multisort() to sort tags by DESC occurrences,
459 * then ASC alphabetically for equal values.
460 *
461 * @see https://github.com/shaarli/Shaarli/issues/1142
462 */
463 $keys = array_keys($tags);
464 $tmpTags = array_combine($keys, $keys);
465 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
466 return $tags;
467 }
468
469 /**
470 * Rename or delete a tag across all links.
471 *
472 * @param string $from Tag to rename
473 * @param string $to New tag. If none is provided, the from tag will be deleted
474 *
475 * @return array|bool List of altered links or false on error
476 */
477 public function renameTag($from, $to)
478 {
479 if (empty($from)) {
480 return false;
481 }
482 $delete = empty($to);
483 // True for case-sensitive tag search.
484 $linksToAlter = $this->filterSearch(['searchtags' => $from], true);
485 foreach ($linksToAlter as $key => &$value) {
486 $tags = preg_split('/\s+/', trim($value['tags']));
487 if (($pos = array_search($from, $tags)) !== false) {
488 if ($delete) {
489 unset($tags[$pos]); // Remove tag.
490 } else {
491 $tags[$pos] = trim($to);
492 }
493 $value['tags'] = trim(implode(' ', array_unique($tags)));
494 $this[$value['id']] = $value;
495 }
496 }
497
498 return $linksToAlter;
499 }
500
501 /**
502 * Returns the list of days containing articles (oldest first)
503 * Output: An array containing days (in format YYYYMMDD).
504 */
505 public function days()
506 {
507 $linkDays = array();
508 foreach ($this->links as $link) {
509 $linkDays[$link['created']->format('Ymd')] = 0;
510 }
511 $linkDays = array_keys($linkDays);
512 sort($linkDays);
513
514 return $linkDays;
515 }
516
517 /**
518 * Reorder links by creation date (newest first).
519 *
520 * Also update the urls and ids mapping arrays.
521 *
522 * @param string $order ASC|DESC
523 */
524 public function reorder($order = 'DESC')
525 {
526 $order = $order === 'ASC' ? -1 : 1;
527 // Reorder array by dates.
528 usort($this->links, function ($a, $b) use ($order) {
529 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
530 return $a['sticky'] ? -1 : 1;
531 }
532 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
533 });
534
535 $this->urls = [];
536 $this->ids = [];
537 foreach ($this->links as $key => $link) {
538 $this->urls[$link['url']] = $key;
539 $this->ids[$link['id']] = $key;
540 }
541 }
542
543 /**
544 * Return the next key for link creation.
545 * E.g. If the last ID is 597, the next will be 598.
546 *
547 * @return int next ID.
548 */
549 public function getNextId()
550 {
551 if (!empty($this->ids)) {
552 return max(array_keys($this->ids)) + 1;
553 }
554 return 0;
555 }
556
557 /**
558 * Returns a link offset in links array from its unique ID.
559 *
560 * @param int $id Persistent ID of a link.
561 *
562 * @return int Real offset in local array, or null if doesn't exist.
563 */
564 protected function getLinkOffset($id)
565 {
566 if (isset($this->ids[$id])) {
567 return $this->ids[$id];
568 }
569 return null;
570 }
571 }