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