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