aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/bookmark
diff options
context:
space:
mode:
Diffstat (limited to 'application/bookmark')
-rw-r--r--application/bookmark/Bookmark.php462
-rw-r--r--application/bookmark/BookmarkArray.php260
-rw-r--r--application/bookmark/BookmarkFileService.php407
-rw-r--r--application/bookmark/BookmarkFilter.php (renamed from application/bookmark/LinkFilter.php)160
-rw-r--r--application/bookmark/BookmarkIO.php108
-rw-r--r--application/bookmark/BookmarkInitializer.php110
-rw-r--r--application/bookmark/BookmarkServiceInterface.php186
-rw-r--r--application/bookmark/LinkDB.php575
-rw-r--r--application/bookmark/LinkUtils.php137
-rw-r--r--application/bookmark/exception/BookmarkNotFoundException.php (renamed from application/bookmark/exception/LinkNotFoundException.php)2
-rw-r--r--application/bookmark/exception/DatastoreNotInitializedException.php10
-rw-r--r--application/bookmark/exception/EmptyDataStoreException.php7
-rw-r--r--application/bookmark/exception/InvalidBookmarkException.php30
-rw-r--r--application/bookmark/exception/NotWritableDataStoreException.php19
14 files changed, 1698 insertions, 775 deletions
diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
new file mode 100644
index 00000000..1beb8be2
--- /dev/null
+++ b/application/bookmark/Bookmark.php
@@ -0,0 +1,462 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use DateTime;
6use DateTimeInterface;
7use Shaarli\Bookmark\Exception\InvalidBookmarkException;
8
9/**
10 * Class Bookmark
11 *
12 * This class represent a single Bookmark with all its attributes.
13 * Every bookmark should manipulated using this, before being formatted.
14 *
15 * @package Shaarli\Bookmark
16 */
17class Bookmark
18{
19 /** @var string Date format used in string (former ID format) */
20 const LINK_DATE_FORMAT = 'Ymd_His';
21
22 /** @var int Bookmark ID */
23 protected $id;
24
25 /** @var string Permalink identifier */
26 protected $shortUrl;
27
28 /** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
29 protected $url;
30
31 /** @var string Bookmark's title */
32 protected $title;
33
34 /** @var string Raw bookmark's description */
35 protected $description;
36
37 /** @var array List of bookmark's tags */
38 protected $tags;
39
40 /** @var string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found */
41 protected $thumbnail;
42
43 /** @var bool Set to true if the bookmark is set as sticky */
44 protected $sticky;
45
46 /** @var DateTimeInterface Creation datetime */
47 protected $created;
48
49 /** @var DateTimeInterface datetime */
50 protected $updated;
51
52 /** @var bool True if the bookmark can only be seen while logged in */
53 protected $private;
54
55 /**
56 * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
57 *
58 * @param array $data
59 *
60 * @return $this
61 */
62 public function fromArray($data)
63 {
64 $this->id = $data['id'];
65 $this->shortUrl = $data['shorturl'];
66 $this->url = $data['url'];
67 $this->title = $data['title'];
68 $this->description = $data['description'];
69 $this->thumbnail = isset($data['thumbnail']) ? $data['thumbnail'] : null;
70 $this->sticky = isset($data['sticky']) ? $data['sticky'] : false;
71 $this->created = $data['created'];
72 if (is_array($data['tags'])) {
73 $this->tags = $data['tags'];
74 } else {
75 $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY);
76 }
77 if (! empty($data['updated'])) {
78 $this->updated = $data['updated'];
79 }
80 $this->private = $data['private'] ? true : false;
81
82 return $this;
83 }
84
85 /**
86 * Make sure that the current instance of Bookmark is valid and can be saved into the data store.
87 * A valid link requires:
88 * - an integer ID
89 * - a short URL (for permalinks)
90 * - a creation date
91 *
92 * This function also initialize optional empty fields:
93 * - the URL with the permalink
94 * - the title with the URL
95 *
96 * @throws InvalidBookmarkException
97 */
98 public function validate()
99 {
100 if ($this->id === null
101 || ! is_int($this->id)
102 || empty($this->shortUrl)
103 || empty($this->created)
104 || ! $this->created instanceof DateTimeInterface
105 ) {
106 throw new InvalidBookmarkException($this);
107 }
108 if (empty($this->url)) {
109 $this->url = '/shaare/'. $this->shortUrl;
110 }
111 if (empty($this->title)) {
112 $this->title = $this->url;
113 }
114 }
115
116 /**
117 * Set the Id.
118 * If they're not already initialized, this function also set:
119 * - created: with the current datetime
120 * - shortUrl: with a generated small hash from the date and the given ID
121 *
122 * @param int $id
123 *
124 * @return Bookmark
125 */
126 public function setId($id)
127 {
128 $this->id = $id;
129 if (empty($this->created)) {
130 $this->created = new DateTime();
131 }
132 if (empty($this->shortUrl)) {
133 $this->shortUrl = link_small_hash($this->created, $this->id);
134 }
135
136 return $this;
137 }
138
139 /**
140 * Get the Id.
141 *
142 * @return int
143 */
144 public function getId()
145 {
146 return $this->id;
147 }
148
149 /**
150 * Get the ShortUrl.
151 *
152 * @return string
153 */
154 public function getShortUrl()
155 {
156 return $this->shortUrl;
157 }
158
159 /**
160 * Get the Url.
161 *
162 * @return string
163 */
164 public function getUrl()
165 {
166 return $this->url;
167 }
168
169 /**
170 * Get the Title.
171 *
172 * @return string
173 */
174 public function getTitle()
175 {
176 return $this->title;
177 }
178
179 /**
180 * Get the Description.
181 *
182 * @return string
183 */
184 public function getDescription()
185 {
186 return ! empty($this->description) ? $this->description : '';
187 }
188
189 /**
190 * Get the Created.
191 *
192 * @return DateTimeInterface
193 */
194 public function getCreated()
195 {
196 return $this->created;
197 }
198
199 /**
200 * Get the Updated.
201 *
202 * @return DateTimeInterface
203 */
204 public function getUpdated()
205 {
206 return $this->updated;
207 }
208
209 /**
210 * Set the ShortUrl.
211 *
212 * @param string $shortUrl
213 *
214 * @return Bookmark
215 */
216 public function setShortUrl($shortUrl)
217 {
218 $this->shortUrl = $shortUrl;
219
220 return $this;
221 }
222
223 /**
224 * Set the Url.
225 *
226 * @param string $url
227 * @param array $allowedProtocols
228 *
229 * @return Bookmark
230 */
231 public function setUrl($url, $allowedProtocols = [])
232 {
233 $url = trim($url);
234 if (! empty($url)) {
235 $url = whitelist_protocols($url, $allowedProtocols);
236 }
237 $this->url = $url;
238
239 return $this;
240 }
241
242 /**
243 * Set the Title.
244 *
245 * @param string $title
246 *
247 * @return Bookmark
248 */
249 public function setTitle($title)
250 {
251 $this->title = trim($title);
252
253 return $this;
254 }
255
256 /**
257 * Set the Description.
258 *
259 * @param string $description
260 *
261 * @return Bookmark
262 */
263 public function setDescription($description)
264 {
265 $this->description = $description;
266
267 return $this;
268 }
269
270 /**
271 * Set the Created.
272 * Note: you shouldn't set this manually except for special cases (like bookmark import)
273 *
274 * @param DateTimeInterface $created
275 *
276 * @return Bookmark
277 */
278 public function setCreated($created)
279 {
280 $this->created = $created;
281
282 return $this;
283 }
284
285 /**
286 * Set the Updated.
287 *
288 * @param DateTimeInterface $updated
289 *
290 * @return Bookmark
291 */
292 public function setUpdated($updated)
293 {
294 $this->updated = $updated;
295
296 return $this;
297 }
298
299 /**
300 * Get the Private.
301 *
302 * @return bool
303 */
304 public function isPrivate()
305 {
306 return $this->private ? true : false;
307 }
308
309 /**
310 * Set the Private.
311 *
312 * @param bool $private
313 *
314 * @return Bookmark
315 */
316 public function setPrivate($private)
317 {
318 $this->private = $private ? true : false;
319
320 return $this;
321 }
322
323 /**
324 * Get the Tags.
325 *
326 * @return array
327 */
328 public function getTags()
329 {
330 return is_array($this->tags) ? $this->tags : [];
331 }
332
333 /**
334 * Set the Tags.
335 *
336 * @param array $tags
337 *
338 * @return Bookmark
339 */
340 public function setTags($tags)
341 {
342 $this->setTagsString(implode(' ', $tags));
343
344 return $this;
345 }
346
347 /**
348 * Get the Thumbnail.
349 *
350 * @return string|bool|null Thumbnail's URL - initialized at null, false if no thumbnail could be found
351 */
352 public function getThumbnail()
353 {
354 return !$this->isNote() ? $this->thumbnail : false;
355 }
356
357 /**
358 * Set the Thumbnail.
359 *
360 * @param string|bool $thumbnail Thumbnail's URL - false if no thumbnail could be found
361 *
362 * @return Bookmark
363 */
364 public function setThumbnail($thumbnail)
365 {
366 $this->thumbnail = $thumbnail;
367
368 return $this;
369 }
370
371 /**
372 * Get the Sticky.
373 *
374 * @return bool
375 */
376 public function isSticky()
377 {
378 return $this->sticky ? true : false;
379 }
380
381 /**
382 * Set the Sticky.
383 *
384 * @param bool $sticky
385 *
386 * @return Bookmark
387 */
388 public function setSticky($sticky)
389 {
390 $this->sticky = $sticky ? true : false;
391
392 return $this;
393 }
394
395 /**
396 * @return string Bookmark's tags as a string, separated by a space
397 */
398 public function getTagsString()
399 {
400 return implode(' ', $this->getTags());
401 }
402
403 /**
404 * @return bool
405 */
406 public function isNote()
407 {
408 // We check empty value to get a valid result if the link has not been saved yet
409 return empty($this->url) || startsWith($this->url, '/shaare/') || $this->url[0] === '?';
410 }
411
412 /**
413 * Set tags from a string.
414 * Note:
415 * - tags must be separated whether by a space or a comma
416 * - multiple spaces will be removed
417 * - trailing dash in tags will be removed
418 *
419 * @param string $tags
420 *
421 * @return $this
422 */
423 public function setTagsString($tags)
424 {
425 // Remove first '-' char in tags.
426 $tags = preg_replace('/(^| )\-/', '$1', $tags);
427 // Explode all tags separted by spaces or commas
428 $tags = preg_split('/[\s,]+/', $tags);
429 // Remove eventual empty values
430 $tags = array_values(array_filter($tags));
431
432 $this->tags = $tags;
433
434 return $this;
435 }
436
437 /**
438 * Rename a tag in tags list.
439 *
440 * @param string $fromTag
441 * @param string $toTag
442 */
443 public function renameTag($fromTag, $toTag)
444 {
445 if (($pos = array_search($fromTag, $this->tags)) !== false) {
446 $this->tags[$pos] = trim($toTag);
447 }
448 }
449
450 /**
451 * Delete a tag from tags list.
452 *
453 * @param string $tag
454 */
455 public function deleteTag($tag)
456 {
457 if (($pos = array_search($tag, $this->tags)) !== false) {
458 unset($this->tags[$pos]);
459 $this->tags = array_values($this->tags);
460 }
461 }
462}
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
new file mode 100644
index 00000000..3bd5eb20
--- /dev/null
+++ b/application/bookmark/BookmarkArray.php
@@ -0,0 +1,260 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Shaarli\Bookmark\Exception\InvalidBookmarkException;
6
7/**
8 * Class BookmarkArray
9 *
10 * Implementing ArrayAccess, this allows us to use the bookmark list
11 * as an array and iterate over it.
12 *
13 * @package Shaarli\Bookmark
14 */
15class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
16{
17 /**
18 * @var Bookmark[]
19 */
20 protected $bookmarks;
21
22 /**
23 * @var array List of all bookmarks IDS mapped with their array offset.
24 * Map: id->offset.
25 */
26 protected $ids;
27
28 /**
29 * @var int Position in the $this->keys array (for the Iterator interface)
30 */
31 protected $position;
32
33 /**
34 * @var array List of offset keys (for the Iterator interface implementation)
35 */
36 protected $keys;
37
38 /**
39 * @var array List of all recorded URLs (key=url, value=bookmark offset)
40 * for fast reserve search (url-->bookmark offset)
41 */
42 protected $urls;
43
44 public function __construct()
45 {
46 $this->ids = [];
47 $this->bookmarks = [];
48 $this->keys = [];
49 $this->urls = [];
50 $this->position = 0;
51 }
52
53 /**
54 * Countable - Counts elements of an object
55 *
56 * @return int Number of bookmarks
57 */
58 public function count()
59 {
60 return count($this->bookmarks);
61 }
62
63 /**
64 * ArrayAccess - Assigns a value to the specified offset
65 *
66 * @param int $offset Bookmark ID
67 * @param Bookmark $value instance
68 *
69 * @throws InvalidBookmarkException
70 */
71 public function offsetSet($offset, $value)
72 {
73 if (! $value instanceof Bookmark
74 || $value->getId() === null || empty($value->getUrl())
75 || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId())
76 || $offset !== null && $offset !== $value->getId()
77 ) {
78 throw new InvalidBookmarkException($value);
79 }
80
81 // If the bookmark exists, we reuse the real offset, otherwise new entry
82 if ($offset !== null) {
83 $existing = $this->getBookmarkOffset($offset);
84 } else {
85 $existing = $this->getBookmarkOffset($value->getId());
86 }
87
88 if ($existing !== null) {
89 $offset = $existing;
90 } else {
91 $offset = count($this->bookmarks);
92 }
93
94 $this->bookmarks[$offset] = $value;
95 $this->urls[$value->getUrl()] = $offset;
96 $this->ids[$value->getId()] = $offset;
97 }
98
99 /**
100 * ArrayAccess - Whether or not an offset exists
101 *
102 * @param int $offset Bookmark ID
103 *
104 * @return bool true if it exists, false otherwise
105 */
106 public function offsetExists($offset)
107 {
108 return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks);
109 }
110
111 /**
112 * ArrayAccess - Unsets an offset
113 *
114 * @param int $offset Bookmark ID
115 */
116 public function offsetUnset($offset)
117 {
118 $realOffset = $this->getBookmarkOffset($offset);
119 $url = $this->bookmarks[$realOffset]->getUrl();
120 unset($this->urls[$url]);
121 unset($this->ids[$offset]);
122 unset($this->bookmarks[$realOffset]);
123 }
124
125 /**
126 * ArrayAccess - Returns the value at specified offset
127 *
128 * @param int $offset Bookmark ID
129 *
130 * @return Bookmark|null The Bookmark if found, null otherwise
131 */
132 public function offsetGet($offset)
133 {
134 $realOffset = $this->getBookmarkOffset($offset);
135 return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null;
136 }
137
138 /**
139 * Iterator - Returns the current element
140 *
141 * @return Bookmark corresponding to the current position
142 */
143 public function current()
144 {
145 return $this[$this->keys[$this->position]];
146 }
147
148 /**
149 * Iterator - Returns the key of the current element
150 *
151 * @return int Bookmark ID corresponding to the current position
152 */
153 public function key()
154 {
155 return $this->keys[$this->position];
156 }
157
158 /**
159 * Iterator - Moves forward to next element
160 */
161 public function next()
162 {
163 ++$this->position;
164 }
165
166 /**
167 * Iterator - Rewinds the Iterator to the first element
168 *
169 * Entries are sorted by date (latest first)
170 */
171 public function rewind()
172 {
173 $this->keys = array_keys($this->ids);
174 $this->position = 0;
175 }
176
177 /**
178 * Iterator - Checks if current position is valid
179 *
180 * @return bool true if the current Bookmark ID exists, false otherwise
181 */
182 public function valid()
183 {
184 return isset($this->keys[$this->position]);
185 }
186
187 /**
188 * Returns a bookmark offset in bookmarks array from its unique ID.
189 *
190 * @param int $id Persistent ID of a bookmark.
191 *
192 * @return int Real offset in local array, or null if doesn't exist.
193 */
194 protected function getBookmarkOffset($id)
195 {
196 if (isset($this->ids[$id])) {
197 return $this->ids[$id];
198 }
199 return null;
200 }
201
202 /**
203 * Return the next key for bookmark creation.
204 * E.g. If the last ID is 597, the next will be 598.
205 *
206 * @return int next ID.
207 */
208 public function getNextId()
209 {
210 if (!empty($this->ids)) {
211 return max(array_keys($this->ids)) + 1;
212 }
213 return 0;
214 }
215
216 /**
217 * @param $url
218 *
219 * @return Bookmark|null
220 */
221 public function getByUrl($url)
222 {
223 if (! empty($url)
224 && isset($this->urls[$url])
225 && isset($this->bookmarks[$this->urls[$url]])
226 ) {
227 return $this->bookmarks[$this->urls[$url]];
228 }
229 return null;
230 }
231
232 /**
233 * Reorder links by creation date (newest first).
234 *
235 * Also update the urls and ids mapping arrays.
236 *
237 * @param string $order ASC|DESC
238 * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first
239 */
240 public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void
241 {
242 $order = $order === 'ASC' ? -1 : 1;
243 // Reorder array by dates.
244 usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) {
245 /** @var $a Bookmark */
246 /** @var $b Bookmark */
247 if (false === $ignoreSticky && $a->isSticky() !== $b->isSticky()) {
248 return $a->isSticky() ? -1 : 1;
249 }
250 return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order;
251 });
252
253 $this->urls = [];
254 $this->ids = [];
255 foreach ($this->bookmarks as $key => $bookmark) {
256 $this->urls[$bookmark->getUrl()] = $key;
257 $this->ids[$bookmark->getId()] = $key;
258 }
259 }
260}
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
new file mode 100644
index 00000000..c9ec2609
--- /dev/null
+++ b/application/bookmark/BookmarkFileService.php
@@ -0,0 +1,407 @@
1<?php
2
3
4namespace Shaarli\Bookmark;
5
6
7use Exception;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
10use Shaarli\Bookmark\Exception\EmptyDataStoreException;
11use Shaarli\Config\ConfigManager;
12use Shaarli\Formatter\BookmarkMarkdownFormatter;
13use Shaarli\History;
14use Shaarli\Legacy\LegacyLinkDB;
15use Shaarli\Legacy\LegacyUpdater;
16use Shaarli\Render\PageCacheManager;
17use Shaarli\Updater\UpdaterUtils;
18
19/**
20 * Class BookmarksService
21 *
22 * This is the entry point to manipulate the bookmark DB.
23 * It manipulates loads links from a file data store containing all bookmarks.
24 *
25 * It also triggers the legacy format (bookmarks as arrays) migration.
26 */
27class BookmarkFileService implements BookmarkServiceInterface
28{
29 /** @var Bookmark[] instance */
30 protected $bookmarks;
31
32 /** @var BookmarkIO instance */
33 protected $bookmarksIO;
34
35 /** @var BookmarkFilter */
36 protected $bookmarkFilter;
37
38 /** @var ConfigManager instance */
39 protected $conf;
40
41 /** @var History instance */
42 protected $history;
43
44 /** @var PageCacheManager instance */
45 protected $pageCacheManager;
46
47 /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
48 protected $isLoggedIn;
49
50 /**
51 * @inheritDoc
52 */
53 public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
54 {
55 $this->conf = $conf;
56 $this->history = $history;
57 $this->pageCacheManager = new PageCacheManager($this->conf->get('resource.page_cache'), $isLoggedIn);
58 $this->bookmarksIO = new BookmarkIO($this->conf);
59 $this->isLoggedIn = $isLoggedIn;
60
61 if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) {
62 $this->bookmarks = [];
63 } else {
64 try {
65 $this->bookmarks = $this->bookmarksIO->read();
66 } catch (EmptyDataStoreException|DatastoreNotInitializedException $e) {
67 $this->bookmarks = new BookmarkArray();
68
69 if ($this->isLoggedIn) {
70 // Datastore file does not exists, we initialize it with default bookmarks.
71 if ($e instanceof DatastoreNotInitializedException) {
72 $this->initialize();
73 } else {
74 $this->save();
75 }
76 }
77 }
78
79 if (! $this->bookmarks instanceof BookmarkArray) {
80 $this->migrate();
81 exit(
82 'Your data store has been migrated, please reload the page.'. PHP_EOL .
83 'If this message keeps showing up, please delete data/updates.txt file.'
84 );
85 }
86 }
87
88 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks);
89 }
90
91 /**
92 * @inheritDoc
93 */
94 public function findByHash($hash)
95 {
96 $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash);
97 // PHP 7.3 introduced array_key_first() to avoid this hack
98 $first = reset($bookmark);
99 if (! $this->isLoggedIn && $first->isPrivate()) {
100 throw new Exception('Not authorized');
101 }
102
103 return $first;
104 }
105
106 /**
107 * @inheritDoc
108 */
109 public function findByUrl($url)
110 {
111 return $this->bookmarks->getByUrl($url);
112 }
113
114 /**
115 * @inheritDoc
116 */
117 public function search(
118 $request = [],
119 $visibility = null,
120 $caseSensitive = false,
121 $untaggedOnly = false,
122 bool $ignoreSticky = false
123 ) {
124 if ($visibility === null) {
125 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
126 }
127
128 // Filter bookmark database according to parameters.
129 $searchtags = isset($request['searchtags']) ? $request['searchtags'] : '';
130 $searchterm = isset($request['searchterm']) ? $request['searchterm'] : '';
131
132 if ($ignoreSticky) {
133 $this->bookmarks->reorder('DESC', true);
134 }
135
136 return $this->bookmarkFilter->filter(
137 BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT,
138 [$searchtags, $searchterm],
139 $caseSensitive,
140 $visibility,
141 $untaggedOnly
142 );
143 }
144
145 /**
146 * @inheritDoc
147 */
148 public function get($id, $visibility = null)
149 {
150 if (! isset($this->bookmarks[$id])) {
151 throw new BookmarkNotFoundException();
152 }
153
154 if ($visibility === null) {
155 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
156 }
157
158 $bookmark = $this->bookmarks[$id];
159 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
160 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
161 ) {
162 throw new Exception('Unauthorized');
163 }
164
165 return $bookmark;
166 }
167
168 /**
169 * @inheritDoc
170 */
171 public function set($bookmark, $save = true)
172 {
173 if (true !== $this->isLoggedIn) {
174 throw new Exception(t('You\'re not authorized to alter the datastore'));
175 }
176 if (! $bookmark instanceof Bookmark) {
177 throw new Exception(t('Provided data is invalid'));
178 }
179 if (! isset($this->bookmarks[$bookmark->getId()])) {
180 throw new BookmarkNotFoundException();
181 }
182 $bookmark->validate();
183
184 $bookmark->setUpdated(new \DateTime());
185 $this->bookmarks[$bookmark->getId()] = $bookmark;
186 if ($save === true) {
187 $this->save();
188 $this->history->updateLink($bookmark);
189 }
190 return $this->bookmarks[$bookmark->getId()];
191 }
192
193 /**
194 * @inheritDoc
195 */
196 public function add($bookmark, $save = true)
197 {
198 if (true !== $this->isLoggedIn) {
199 throw new Exception(t('You\'re not authorized to alter the datastore'));
200 }
201 if (! $bookmark instanceof Bookmark) {
202 throw new Exception(t('Provided data is invalid'));
203 }
204 if (! empty($bookmark->getId())) {
205 throw new Exception(t('This bookmarks already exists'));
206 }
207 $bookmark->setId($this->bookmarks->getNextId());
208 $bookmark->validate();
209
210 $this->bookmarks[$bookmark->getId()] = $bookmark;
211 if ($save === true) {
212 $this->save();
213 $this->history->addLink($bookmark);
214 }
215 return $this->bookmarks[$bookmark->getId()];
216 }
217
218 /**
219 * @inheritDoc
220 */
221 public function addOrSet($bookmark, $save = true)
222 {
223 if (true !== $this->isLoggedIn) {
224 throw new Exception(t('You\'re not authorized to alter the datastore'));
225 }
226 if (! $bookmark instanceof Bookmark) {
227 throw new Exception('Provided data is invalid');
228 }
229 if ($bookmark->getId() === null) {
230 return $this->add($bookmark, $save);
231 }
232 return $this->set($bookmark, $save);
233 }
234
235 /**
236 * @inheritDoc
237 */
238 public function remove($bookmark, $save = true)
239 {
240 if (true !== $this->isLoggedIn) {
241 throw new Exception(t('You\'re not authorized to alter the datastore'));
242 }
243 if (! $bookmark instanceof Bookmark) {
244 throw new Exception(t('Provided data is invalid'));
245 }
246 if (! isset($this->bookmarks[$bookmark->getId()])) {
247 throw new BookmarkNotFoundException();
248 }
249
250 unset($this->bookmarks[$bookmark->getId()]);
251 if ($save === true) {
252 $this->save();
253 $this->history->deleteLink($bookmark);
254 }
255 }
256
257 /**
258 * @inheritDoc
259 */
260 public function exists($id, $visibility = null)
261 {
262 if (! isset($this->bookmarks[$id])) {
263 return false;
264 }
265
266 if ($visibility === null) {
267 $visibility = $this->isLoggedIn ? 'all' : 'public';
268 }
269
270 $bookmark = $this->bookmarks[$id];
271 if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private')
272 || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public')
273 ) {
274 return false;
275 }
276
277 return true;
278 }
279
280 /**
281 * @inheritDoc
282 */
283 public function count($visibility = null)
284 {
285 return count($this->search([], $visibility));
286 }
287
288 /**
289 * @inheritDoc
290 */
291 public function save()
292 {
293 if (true !== $this->isLoggedIn) {
294 // TODO: raise an Exception instead
295 die('You are not authorized to change the database.');
296 }
297
298 $this->bookmarks->reorder();
299 $this->bookmarksIO->write($this->bookmarks);
300 $this->pageCacheManager->invalidateCaches();
301 }
302
303 /**
304 * @inheritDoc
305 */
306 public function bookmarksCountPerTag($filteringTags = [], $visibility = null)
307 {
308 $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility);
309 $tags = [];
310 $caseMapping = [];
311 foreach ($bookmarks as $bookmark) {
312 foreach ($bookmark->getTags() as $tag) {
313 if (empty($tag)
314 || (! $this->isLoggedIn && startsWith($tag, '.'))
315 || $tag === BookmarkMarkdownFormatter::NO_MD_TAG
316 || in_array($tag, $filteringTags, true)
317 ) {
318 continue;
319 }
320
321 // The first case found will be displayed.
322 if (!isset($caseMapping[strtolower($tag)])) {
323 $caseMapping[strtolower($tag)] = $tag;
324 $tags[$caseMapping[strtolower($tag)]] = 0;
325 }
326 $tags[$caseMapping[strtolower($tag)]]++;
327 }
328 }
329
330 /*
331 * Formerly used arsort(), which doesn't define the sort behaviour for equal values.
332 * Also, this function doesn't produce the same result between PHP 5.6 and 7.
333 *
334 * So we now use array_multisort() to sort tags by DESC occurrences,
335 * then ASC alphabetically for equal values.
336 *
337 * @see https://github.com/shaarli/Shaarli/issues/1142
338 */
339 $keys = array_keys($tags);
340 $tmpTags = array_combine($keys, $keys);
341 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
342 return $tags;
343 }
344
345 /**
346 * @inheritDoc
347 */
348 public function days()
349 {
350 $bookmarkDays = [];
351 foreach ($this->search() as $bookmark) {
352 $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0;
353 }
354 $bookmarkDays = array_keys($bookmarkDays);
355 sort($bookmarkDays);
356
357 return $bookmarkDays;
358 }
359
360 /**
361 * @inheritDoc
362 */
363 public function filterDay($request)
364 {
365 $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC;
366
367 return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request, false, $visibility);
368 }
369
370 /**
371 * @inheritDoc
372 */
373 public function initialize()
374 {
375 $initializer = new BookmarkInitializer($this);
376 $initializer->initialize();
377
378 if (true === $this->isLoggedIn) {
379 $this->save();
380 }
381 }
382
383 /**
384 * Handles migration to the new database format (BookmarksArray).
385 */
386 protected function migrate()
387 {
388 $bookmarkDb = new LegacyLinkDB(
389 $this->conf->get('resource.datastore'),
390 true,
391 false
392 );
393 $updater = new LegacyUpdater(
394 UpdaterUtils::read_updates_file($this->conf->get('resource.updates')),
395 $bookmarkDb,
396 $this->conf,
397 true
398 );
399 $newUpdates = $updater->update();
400 if (! empty($newUpdates)) {
401 UpdaterUtils::write_updates_file(
402 $this->conf->get('resource.updates'),
403 $updater->getDoneUpdates()
404 );
405 }
406 }
407}
diff --git a/application/bookmark/LinkFilter.php b/application/bookmark/BookmarkFilter.php
index 9b966307..6636bbfe 100644
--- a/application/bookmark/LinkFilter.php
+++ b/application/bookmark/BookmarkFilter.php
@@ -3,14 +3,14 @@
3namespace Shaarli\Bookmark; 3namespace Shaarli\Bookmark;
4 4
5use Exception; 5use Exception;
6use Shaarli\Bookmark\Exception\LinkNotFoundException; 6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7 7
8/** 8/**
9 * Class LinkFilter. 9 * Class LinkFilter.
10 * 10 *
11 * Perform search and filter operation on link data list. 11 * Perform search and filter operation on link data list.
12 */ 12 */
13class LinkFilter 13class BookmarkFilter
14{ 14{
15 /** 15 /**
16 * @var string permalinks. 16 * @var string permalinks.
@@ -33,33 +33,49 @@ class LinkFilter
33 public static $FILTER_DAY = 'FILTER_DAY'; 33 public static $FILTER_DAY = 'FILTER_DAY';
34 34
35 /** 35 /**
36 * @var string filter by day.
37 */
38 public static $DEFAULT = 'NO_FILTER';
39
40 /** @var string Visibility: all */
41 public static $ALL = 'all';
42
43 /** @var string Visibility: public */
44 public static $PUBLIC = 'public';
45
46 /** @var string Visibility: private */
47 public static $PRIVATE = 'private';
48
49 /**
36 * @var string Allowed characters for hashtags (regex syntax). 50 * @var string Allowed characters for hashtags (regex syntax).
37 */ 51 */
38 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}'; 52 public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
39 53
40 /** 54 /**
41 * @var LinkDB all available links. 55 * @var Bookmark[] all available bookmarks.
42 */ 56 */
43 private $links; 57 private $bookmarks;
44 58
45 /** 59 /**
46 * @param LinkDB $links initialization. 60 * @param Bookmark[] $bookmarks initialization.
47 */ 61 */
48 public function __construct($links) 62 public function __construct($bookmarks)
49 { 63 {
50 $this->links = $links; 64 $this->bookmarks = $bookmarks;
51 } 65 }
52 66
53 /** 67 /**
54 * Filter links according to parameters. 68 * Filter bookmarks according to parameters.
55 * 69 *
56 * @param string $type Type of filter (eg. tags, permalink, etc.). 70 * @param string $type Type of filter (eg. tags, permalink, etc.).
57 * @param mixed $request Filter content. 71 * @param mixed $request Filter content.
58 * @param bool $casesensitive Optional: Perform case sensitive filter if true. 72 * @param bool $casesensitive Optional: Perform case sensitive filter if true.
59 * @param string $visibility Optional: return only all/private/public links 73 * @param string $visibility Optional: return only all/private/public bookmarks
60 * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG 74 * @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG
61 * 75 *
62 * @return array filtered link list. 76 * @return Bookmark[] filtered bookmark list.
77 *
78 * @throws BookmarkNotFoundException
63 */ 79 */
64 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) 80 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
65 { 81 {
@@ -81,13 +97,13 @@ class LinkFilter
81 if ($untaggedonly) { 97 if ($untaggedonly) {
82 $filtered = $this->filterUntagged($visibility); 98 $filtered = $this->filterUntagged($visibility);
83 } else { 99 } else {
84 $filtered = $this->links; 100 $filtered = $this->bookmarks;
85 } 101 }
86 if (!empty($request[0])) { 102 if (!empty($request[0])) {
87 $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); 103 $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
88 } 104 }
89 if (!empty($request[1])) { 105 if (!empty($request[1])) {
90 $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); 106 $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility);
91 } 107 }
92 return $filtered; 108 return $filtered;
93 case self::$FILTER_TEXT: 109 case self::$FILTER_TEXT:
@@ -99,7 +115,7 @@ class LinkFilter
99 return $this->filterTags($request, $casesensitive, $visibility); 115 return $this->filterTags($request, $casesensitive, $visibility);
100 } 116 }
101 case self::$FILTER_DAY: 117 case self::$FILTER_DAY:
102 return $this->filterDay($request); 118 return $this->filterDay($request, $visibility);
103 default: 119 default:
104 return $this->noFilter($visibility); 120 return $this->noFilter($visibility);
105 } 121 }
@@ -108,21 +124,21 @@ class LinkFilter
108 /** 124 /**
109 * Unknown filter, but handle private only. 125 * Unknown filter, but handle private only.
110 * 126 *
111 * @param string $visibility Optional: return only all/private/public links 127 * @param string $visibility Optional: return only all/private/public bookmarks
112 * 128 *
113 * @return array filtered links. 129 * @return Bookmark[] filtered bookmarks.
114 */ 130 */
115 private function noFilter($visibility = 'all') 131 private function noFilter($visibility = 'all')
116 { 132 {
117 if ($visibility === 'all') { 133 if ($visibility === 'all') {
118 return $this->links; 134 return $this->bookmarks;
119 } 135 }
120 136
121 $out = array(); 137 $out = array();
122 foreach ($this->links as $key => $value) { 138 foreach ($this->bookmarks as $key => $value) {
123 if ($value['private'] && $visibility === 'private') { 139 if ($value->isPrivate() && $visibility === 'private') {
124 $out[$key] = $value; 140 $out[$key] = $value;
125 } elseif (!$value['private'] && $visibility === 'public') { 141 } elseif (!$value->isPrivate() && $visibility === 'public') {
126 $out[$key] = $value; 142 $out[$key] = $value;
127 } 143 }
128 } 144 }
@@ -137,28 +153,22 @@ class LinkFilter
137 * 153 *
138 * @return array $filtered array containing permalink data. 154 * @return array $filtered array containing permalink data.
139 * 155 *
140 * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link. 156 * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link.
141 */ 157 */
142 private function filterSmallHash($smallHash) 158 private function filterSmallHash($smallHash)
143 { 159 {
144 $filtered = array(); 160 foreach ($this->bookmarks as $key => $l) {
145 foreach ($this->links as $key => $l) { 161 if ($smallHash == $l->getShortUrl()) {
146 if ($smallHash == $l['shorturl']) {
147 // Yes, this is ugly and slow 162 // Yes, this is ugly and slow
148 $filtered[$key] = $l; 163 return [$key => $l];
149 return $filtered;
150 } 164 }
151 } 165 }
152 166
153 if (empty($filtered)) { 167 throw new BookmarkNotFoundException();
154 throw new LinkNotFoundException();
155 }
156
157 return $filtered;
158 } 168 }
159 169
160 /** 170 /**
161 * Returns the list of links corresponding to a full-text search 171 * Returns the list of bookmarks corresponding to a full-text search
162 * 172 *
163 * Searches: 173 * Searches:
164 * - in the URLs, title and description; 174 * - in the URLs, title and description;
@@ -174,7 +184,7 @@ class LinkFilter
174 * - see https://github.com/shaarli/Shaarli/issues/75 for examples 184 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
175 * 185 *
176 * @param string $searchterms search query. 186 * @param string $searchterms search query.
177 * @param string $visibility Optional: return only all/private/public links. 187 * @param string $visibility Optional: return only all/private/public bookmarks.
178 * 188 *
179 * @return array search results. 189 * @return array search results.
180 */ 190 */
@@ -206,25 +216,23 @@ class LinkFilter
206 } 216 }
207 } 217 }
208 218
209 $keys = array('title', 'description', 'url', 'tags');
210
211 // Iterate over every stored link. 219 // Iterate over every stored link.
212 foreach ($this->links as $id => $link) { 220 foreach ($this->bookmarks as $id => $link) {
213 // ignore non private links when 'privatonly' is on. 221 // ignore non private bookmarks when 'privatonly' is on.
214 if ($visibility !== 'all') { 222 if ($visibility !== 'all') {
215 if (!$link['private'] && $visibility === 'private') { 223 if (!$link->isPrivate() && $visibility === 'private') {
216 continue; 224 continue;
217 } elseif ($link['private'] && $visibility === 'public') { 225 } elseif ($link->isPrivate() && $visibility === 'public') {
218 continue; 226 continue;
219 } 227 }
220 } 228 }
221 229
222 // Concatenate link fields to search across fields. 230 // Concatenate link fields to search across fields.
223 // Adds a '\' separator for exact search terms. 231 // Adds a '\' separator for exact search terms.
224 $content = ''; 232 $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\';
225 foreach ($keys as $key) { 233 $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\';
226 $content .= mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8') . '\\'; 234 $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\';
227 } 235 $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\';
228 236
229 // Be optimistic 237 // Be optimistic
230 $found = true; 238 $found = true;
@@ -301,16 +309,16 @@ class LinkFilter
301 } 309 }
302 310
303 /** 311 /**
304 * Returns the list of links associated with a given list of tags 312 * Returns the list of bookmarks associated with a given list of tags
305 * 313 *
306 * You can specify one or more tags, separated by space or a comma, e.g. 314 * You can specify one or more tags, separated by space or a comma, e.g.
307 * print_r($mydb->filterTags('linux programming')); 315 * print_r($mydb->filterTags('linux programming'));
308 * 316 *
309 * @param string $tags list of tags separated by commas or blank spaces. 317 * @param string $tags list of tags separated by commas or blank spaces.
310 * @param bool $casesensitive ignore case if false. 318 * @param bool $casesensitive ignore case if false.
311 * @param string $visibility Optional: return only all/private/public links. 319 * @param string $visibility Optional: return only all/private/public bookmarks.
312 * 320 *
313 * @return array filtered links. 321 * @return array filtered bookmarks.
314 */ 322 */
315 public function filterTags($tags, $casesensitive = false, $visibility = 'all') 323 public function filterTags($tags, $casesensitive = false, $visibility = 'all')
316 { 324 {
@@ -326,6 +334,17 @@ class LinkFilter
326 return $this->noFilter($visibility); 334 return $this->noFilter($visibility);
327 } 335 }
328 336
337 // If we only have public visibility, we can't look for hidden tags
338 if ($visibility === self::$PUBLIC) {
339 $inputTags = array_values(array_filter($inputTags, function ($tag) {
340 return ! startsWith($tag, '.');
341 }));
342
343 if (empty($inputTags)) {
344 return [];
345 }
346 }
347
329 // build regex from all tags 348 // build regex from all tags
330 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; 349 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
331 if (!$casesensitive) { 350 if (!$casesensitive) {
@@ -334,27 +353,27 @@ class LinkFilter
334 } 353 }
335 354
336 // create resulting array 355 // create resulting array
337 $filtered = array(); 356 $filtered = [];
338 357
339 // iterate over each link 358 // iterate over each link
340 foreach ($this->links as $key => $link) { 359 foreach ($this->bookmarks as $key => $link) {
341 // check level of visibility 360 // check level of visibility
342 // ignore non private links when 'privateonly' is on. 361 // ignore non private bookmarks when 'privateonly' is on.
343 if ($visibility !== 'all') { 362 if ($visibility !== 'all') {
344 if (!$link['private'] && $visibility === 'private') { 363 if (!$link->isPrivate() && $visibility === 'private') {
345 continue; 364 continue;
346 } elseif ($link['private'] && $visibility === 'public') { 365 } elseif ($link->isPrivate() && $visibility === 'public') {
347 continue; 366 continue;
348 } 367 }
349 } 368 }
350 $search = $link['tags']; // build search string, start with tags of current link 369 $search = $link->getTagsString(); // build search string, start with tags of current link
351 if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== false) { 370 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) {
352 // description given and at least one possible tag found 371 // description given and at least one possible tag found
353 $descTags = array(); 372 $descTags = array();
354 // find all tags in the form of #tag in the description 373 // find all tags in the form of #tag in the description
355 preg_match_all( 374 preg_match_all(
356 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 375 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
357 $link['description'], 376 $link->getDescription(),
358 $descTags 377 $descTags
359 ); 378 );
360 if (count($descTags[1])) { 379 if (count($descTags[1])) {
@@ -373,25 +392,25 @@ class LinkFilter
373 } 392 }
374 393
375 /** 394 /**
376 * Return only links without any tag. 395 * Return only bookmarks without any tag.
377 * 396 *
378 * @param string $visibility return only all/private/public links. 397 * @param string $visibility return only all/private/public bookmarks.
379 * 398 *
380 * @return array filtered links. 399 * @return array filtered bookmarks.
381 */ 400 */
382 public function filterUntagged($visibility) 401 public function filterUntagged($visibility)
383 { 402 {
384 $filtered = []; 403 $filtered = [];
385 foreach ($this->links as $key => $link) { 404 foreach ($this->bookmarks as $key => $link) {
386 if ($visibility !== 'all') { 405 if ($visibility !== 'all') {
387 if (!$link['private'] && $visibility === 'private') { 406 if (!$link->isPrivate() && $visibility === 'private') {
388 continue; 407 continue;
389 } elseif ($link['private'] && $visibility === 'public') { 408 } elseif ($link->isPrivate() && $visibility === 'public') {
390 continue; 409 continue;
391 } 410 }
392 } 411 }
393 412
394 if (empty(trim($link['tags']))) { 413 if (empty(trim($link->getTagsString()))) {
395 $filtered[$key] = $link; 414 $filtered[$key] = $link;
396 } 415 }
397 } 416 }
@@ -406,21 +425,26 @@ class LinkFilter
406 * print_r($mydb->filterDay('20120125')); 425 * print_r($mydb->filterDay('20120125'));
407 * 426 *
408 * @param string $day day to filter. 427 * @param string $day day to filter.
409 * 428 * @param string $visibility return only all/private/public bookmarks.
429
410 * @return array all link matching given day. 430 * @return array all link matching given day.
411 * 431 *
412 * @throws Exception if date format is invalid. 432 * @throws Exception if date format is invalid.
413 */ 433 */
414 public function filterDay($day) 434 public function filterDay($day, $visibility)
415 { 435 {
416 if (!checkDateFormat('Ymd', $day)) { 436 if (!checkDateFormat('Ymd', $day)) {
417 throw new Exception('Invalid date format'); 437 throw new Exception('Invalid date format');
418 } 438 }
419 439
420 $filtered = array(); 440 $filtered = [];
421 foreach ($this->links as $key => $l) { 441 foreach ($this->bookmarks as $key => $bookmark) {
422 if ($l['created']->format('Ymd') == $day) { 442 if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) {
423 $filtered[$key] = $l; 443 continue;
444 }
445
446 if ($bookmark->getCreated()->format('Ymd') == $day) {
447 $filtered[$key] = $bookmark;
424 } 448 }
425 } 449 }
426 450
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
new file mode 100644
index 00000000..6bf7f365
--- /dev/null
+++ b/application/bookmark/BookmarkIO.php
@@ -0,0 +1,108 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use Shaarli\Bookmark\Exception\DatastoreNotInitializedException;
6use Shaarli\Bookmark\Exception\EmptyDataStoreException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9
10/**
11 * Class BookmarkIO
12 *
13 * This class performs read/write operation to the file data store.
14 * Used by BookmarkFileService.
15 *
16 * @package Shaarli\Bookmark
17 */
18class BookmarkIO
19{
20 /**
21 * @var string Datastore file path
22 */
23 protected $datastore;
24
25 /**
26 * @var ConfigManager instance
27 */
28 protected $conf;
29
30 /**
31 * string Datastore PHP prefix
32 */
33 protected static $phpPrefix = '<?php /* ';
34
35 /**
36 * string Datastore PHP suffix
37 */
38 protected static $phpSuffix = ' */ ?>';
39
40 /**
41 * LinksIO constructor.
42 *
43 * @param ConfigManager $conf instance
44 */
45 public function __construct($conf)
46 {
47 $this->conf = $conf;
48 $this->datastore = $conf->get('resource.datastore');
49 }
50
51 /**
52 * Reads database from disk to memory
53 *
54 * @return BookmarkArray instance
55 *
56 * @throws NotWritableDataStoreException Data couldn't be loaded
57 * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark
58 * @throws DatastoreNotInitializedException File does not exists
59 */
60 public function read()
61 {
62 if (! file_exists($this->datastore)) {
63 throw new DatastoreNotInitializedException();
64 }
65
66 if (!is_writable($this->datastore)) {
67 throw new NotWritableDataStoreException($this->datastore);
68 }
69
70 // Note that gzinflate is faster than gzuncompress.
71 // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
72 $links = unserialize(gzinflate(base64_decode(
73 substr(file_get_contents($this->datastore),
74 strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
75
76 if (empty($links)) {
77 if (filesize($this->datastore) > 100) {
78 throw new NotWritableDataStoreException($this->datastore);
79 }
80 throw new EmptyDataStoreException();
81 }
82
83 return $links;
84 }
85
86 /**
87 * Saves the database from memory to disk
88 *
89 * @param BookmarkArray $links instance.
90 *
91 * @throws NotWritableDataStoreException the datastore is not writable
92 */
93 public function write($links)
94 {
95 if (is_file($this->datastore) && !is_writeable($this->datastore)) {
96 // The datastore exists but is not writeable
97 throw new NotWritableDataStoreException($this->datastore);
98 } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) {
99 // The datastore does not exist and its parent directory is not writeable
100 throw new NotWritableDataStoreException(dirname($this->datastore));
101 }
102
103 file_put_contents(
104 $this->datastore,
105 self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix
106 );
107 }
108}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
new file mode 100644
index 00000000..815047e3
--- /dev/null
+++ b/application/bookmark/BookmarkInitializer.php
@@ -0,0 +1,110 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5/**
6 * Class BookmarkInitializer
7 *
8 * This class is used to initialized default bookmarks after a fresh install of Shaarli.
9 * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks).
10 *
11 * To prevent data corruption, it does not overwrite existing bookmarks,
12 * even though there should not be any.
13 *
14 * @package Shaarli\Bookmark
15 */
16class BookmarkInitializer
17{
18 /** @var BookmarkServiceInterface */
19 protected $bookmarkService;
20
21 /**
22 * BookmarkInitializer constructor.
23 *
24 * @param BookmarkServiceInterface $bookmarkService
25 */
26 public function __construct($bookmarkService)
27 {
28 $this->bookmarkService = $bookmarkService;
29 }
30
31 /**
32 * Initialize the data store with default bookmarks
33 */
34 public function initialize()
35 {
36 $bookmark = new Bookmark();
37 $bookmark->setTitle('quicksilver (loop) on Vimeo ' . t('(private bookmark with thumbnail demo)'));
38 $bookmark->setUrl('https://vimeo.com/153493904');
39 $bookmark->setDescription(t(
40'Shaarli will automatically pick up the thumbnail for links to a variety of websites.
41
42Explore your new Shaarli instance by trying out controls and menus.
43Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli.
44
45Now you can edit or delete the default shaares.
46'
47 ));
48 $bookmark->setTagsString('shaarli help thumbnail');
49 $bookmark->setPrivate(true);
50 $this->bookmarkService->add($bookmark, false);
51
52 $bookmark = new Bookmark();
53 $bookmark->setTitle(t('Note: Shaare descriptions'));
54 $bookmark->setDescription(t(
55'Adding a shaare without entering a URL creates a text-only "note" post such as this one.
56This note is private, so you are the only one able to see it while logged in.
57
58You can use this to keep notes, post articles, code snippets, and much more.
59
60The Markdown formatting setting allows you to format your notes and bookmark description:
61
62### Title headings
63
64#### Multiple headings levels
65 * bullet lists
66 * _italic_ text
67 * **bold** text
68 * ~~strike through~~ text
69 * `code` blocks
70 * images
71 * [links](https://en.wikipedia.org/wiki/Markdown)
72
73Markdown also supports tables:
74
75| Name | Type | Color | Qty |
76| ------- | --------- | ------ | ----- |
77| Orange | Fruit | Orange | 126 |
78| Apple | Fruit | Any | 62 |
79| Lemon | Fruit | Yellow | 30 |
80| Carrot | Vegetable | Red | 14 |
81'
82 ));
83 $bookmark->setTagsString('shaarli help');
84 $bookmark->setPrivate(true);
85 $this->bookmarkService->add($bookmark, false);
86
87 $bookmark = new Bookmark();
88 $bookmark->setTitle(
89 'Shaarli - ' . t('The personal, minimalist, super-fast, database free, bookmarking service')
90 );
91 $bookmark->setDescription(t(
92'Welcome to Shaarli!
93
94Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately.
95You can add a description to your bookmarks, such as this one, and tag them.
96
97Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.).
98
99You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`).
100Hashtags such as #shaarli #help are also supported.
101You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search.
102
103We hope that you will enjoy using Shaarli, maintained with ❤️ by the community!
104Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue.
105'
106 ));
107 $bookmark->setTagsString('shaarli help');
108 $this->bookmarkService->add($bookmark, false);
109 }
110}
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
new file mode 100644
index 00000000..b9b483eb
--- /dev/null
+++ b/application/bookmark/BookmarkServiceInterface.php
@@ -0,0 +1,186 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5
6use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
7use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
8use Shaarli\Config\ConfigManager;
9use Shaarli\History;
10
11/**
12 * Class BookmarksService
13 *
14 * This is the entry point to manipulate the bookmark DB.
15 */
16interface BookmarkServiceInterface
17{
18 /**
19 * BookmarksService constructor.
20 *
21 * @param ConfigManager $conf instance
22 * @param History $history instance
23 * @param bool $isLoggedIn true if the current user is logged in
24 */
25 public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
26
27 /**
28 * Find a bookmark by hash
29 *
30 * @param string $hash
31 *
32 * @return mixed
33 *
34 * @throws \Exception
35 */
36 public function findByHash($hash);
37
38 /**
39 * @param $url
40 *
41 * @return Bookmark|null
42 */
43 public function findByUrl($url);
44
45 /**
46 * Search bookmarks
47 *
48 * @param mixed $request
49 * @param string $visibility
50 * @param bool $caseSensitive
51 * @param bool $untaggedOnly
52 * @param bool $ignoreSticky
53 *
54 * @return Bookmark[]
55 */
56 public function search(
57 $request = [],
58 $visibility = null,
59 $caseSensitive = false,
60 $untaggedOnly = false,
61 bool $ignoreSticky = false
62 );
63
64 /**
65 * Get a single bookmark by its ID.
66 *
67 * @param int $id Bookmark ID
68 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
69 * exception
70 *
71 * @return Bookmark
72 *
73 * @throws BookmarkNotFoundException
74 * @throws \Exception
75 */
76 public function get($id, $visibility = null);
77
78 /**
79 * Updates an existing bookmark (depending on its ID).
80 *
81 * @param Bookmark $bookmark
82 * @param bool $save Writes to the datastore if set to true
83 *
84 * @return Bookmark Updated bookmark
85 *
86 * @throws BookmarkNotFoundException
87 * @throws \Exception
88 */
89 public function set($bookmark, $save = true);
90
91 /**
92 * Adds a new bookmark (the ID must be empty).
93 *
94 * @param Bookmark $bookmark
95 * @param bool $save Writes to the datastore if set to true
96 *
97 * @return Bookmark new bookmark
98 *
99 * @throws \Exception
100 */
101 public function add($bookmark, $save = true);
102
103 /**
104 * Adds or updates a bookmark depending on its ID:
105 * - a Bookmark without ID will be added
106 * - a Bookmark with an existing ID will be updated
107 *
108 * @param Bookmark $bookmark
109 * @param bool $save
110 *
111 * @return Bookmark
112 *
113 * @throws \Exception
114 */
115 public function addOrSet($bookmark, $save = true);
116
117 /**
118 * Deletes a bookmark.
119 *
120 * @param Bookmark $bookmark
121 * @param bool $save
122 *
123 * @throws \Exception
124 */
125 public function remove($bookmark, $save = true);
126
127 /**
128 * Get a single bookmark by its ID.
129 *
130 * @param int $id Bookmark ID
131 * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
132 * exception
133 *
134 * @return bool
135 */
136 public function exists($id, $visibility = null);
137
138 /**
139 * Return the number of available bookmarks for given visibility.
140 *
141 * @param string $visibility public|private|all
142 *
143 * @return int Number of bookmarks
144 */
145 public function count($visibility = null);
146
147 /**
148 * Write the datastore.
149 *
150 * @throws NotWritableDataStoreException
151 */
152 public function save();
153
154 /**
155 * Returns the list tags appearing in the bookmarks with the given tags
156 *
157 * @param array $filteringTags tags selecting the bookmarks to consider
158 * @param string $visibility process only all/private/public bookmarks
159 *
160 * @return array tag => bookmarksCount
161 */
162 public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all');
163
164 /**
165 * Returns the list of days containing articles (oldest first)
166 *
167 * @return array containing days (in format YYYYMMDD).
168 */
169 public function days();
170
171 /**
172 * Returns the list of articles for a given day.
173 *
174 * @param string $request day to filter. Format: YYYYMMDD.
175 *
176 * @return Bookmark[] list of shaare found.
177 *
178 * @throws BookmarkNotFoundException
179 */
180 public function filterDay($request);
181
182 /**
183 * Creates the default database after a fresh install.
184 */
185 public function initialize();
186}
diff --git a/application/bookmark/LinkDB.php b/application/bookmark/LinkDB.php
deleted file mode 100644
index 76ba95f0..00000000
--- a/application/bookmark/LinkDB.php
+++ /dev/null
@@ -1,575 +0,0 @@
1<?php
2
3namespace Shaarli\Bookmark;
4
5use ArrayAccess;
6use Countable;
7use DateTime;
8use Iterator;
9use Shaarli\Bookmark\Exception\LinkNotFoundException;
10use Shaarli\Exceptions\IOException;
11use 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 */
56class 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
249To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page.
250
251You 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 'sticky' => false,
257 );
258 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
259 $this->links[1] = $link;
260
261 $link = array(
262 'id' => 0,
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',
269 'sticky' => false,
270 );
271 $link['shorturl'] = link_small_hash($link['created'], $link['id']);
272 $this->links[0] = $link;
273
274 // Write database to disk
275 $this->write();
276 }
277
278 /**
279 * Reads database from disk to memory
280 */
281 private function read()
282 {
283 // Public links are hidden and user not logged in => nothing to show
284 if ($this->hidePublicLinks && !$this->loggedIn) {
285 $this->links = array();
286 return;
287 }
288
289 $this->urls = [];
290 $this->ids = [];
291 $this->links = FileUtils::readFlatDB($this->datastore, []);
292
293 $toremove = array();
294 foreach ($this->links as $key => &$link) {
295 if (!$this->loggedIn && $link['private'] != 0) {
296 // Transition for not upgraded databases.
297 unset($this->links[$key]);
298 continue;
299 }
300
301 // Sanitize data fields.
302 sanitizeLink($link);
303
304 // Remove private tags if the user is not logged in.
305 if (!$this->loggedIn) {
306 $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
307 }
308
309 $link['real_url'] = $link['url'];
310
311 $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
312
313 // To be able to load links before running the update, and prepare the update
314 if (!isset($link['created'])) {
315 $link['id'] = $link['linkdate'];
316 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
317 if (!empty($link['updated'])) {
318 $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
319 }
320 $link['shorturl'] = smallHash($link['linkdate']);
321 }
322
323 $this->urls[$link['url']] = $key;
324 $this->ids[$link['id']] = $key;
325 }
326 }
327
328 /**
329 * Saves the database from memory to disk
330 *
331 * @throws IOException the datastore is not writable
332 */
333 private function write()
334 {
335 $this->reorder();
336 FileUtils::writeFlatDB($this->datastore, $this->links);
337 }
338
339 /**
340 * Saves the database from memory to disk
341 *
342 * @param string $pageCacheDir page cache directory
343 */
344 public function save($pageCacheDir)
345 {
346 if (!$this->loggedIn) {
347 // TODO: raise an Exception instead
348 die('You are not authorized to change the database.');
349 }
350
351 $this->write();
352
353 invalidateCaches($pageCacheDir);
354 }
355
356 /**
357 * Returns the link for a given URL, or False if it does not exist.
358 *
359 * @param string $url URL to search for
360 *
361 * @return mixed the existing link if it exists, else 'false'
362 */
363 public function getLinkFromUrl($url)
364 {
365 if (isset($this->urls[$url])) {
366 return $this->links[$this->urls[$url]];
367 }
368 return false;
369 }
370
371 /**
372 * Returns the shaare corresponding to a smallHash.
373 *
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);
383 $linkFilter = new LinkFilter($this->links);
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 */
394 public function filterDay($request)
395 {
396 $linkFilter = new LinkFilter($this->links);
397 return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
398 }
399
400 /**
401 * Filter links according to search parameters.
402 *
403 * @param array $filterRequest Search request content. Supported keys:
404 * - searchtags: list of tags
405 * - searchterm: term search
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
409 *
410 * @return array filtered links, all links if no suitable filter was provided.
411 */
412 public function filterSearch(
413 $filterRequest = array(),
414 $casesensitive = false,
415 $visibility = 'all',
416 $untaggedonly = false
417 ) {
418
419 // Filter link database according to parameters.
420 $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
421 $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
422
423 // Search tags + fullsearch - blank string parameter will return all links.
424 $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
425 $request = [$searchtags, $searchterm];
426
427 $linkFilter = new LinkFilter($this);
428 return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
429 }
430
431 /**
432 * Returns the list tags appearing in the links with the given tags
433 *
434 * @param array $filteringTags tags selecting the links to consider
435 * @param string $visibility process only all/private/public links
436 *
437 * @return array tag => linksCount
438 */
439 public function linksCountPerTag($filteringTags = [], $visibility = 'all')
440 {
441 $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
442 $tags = [];
443 $caseMapping = [];
444 foreach ($links as $link) {
445 foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
446 if (empty($tag)) {
447 continue;
448 }
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)]]++;
455 }
456 }
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 */
467 $keys = array_keys($tags);
468 $tmpTags = array_combine($keys, $keys);
469 array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags);
470 return $tags;
471 }
472
473 /**
474 * Rename or delete a tag across all links.
475 *
476 * @param string $from Tag to rename
477 * @param string $to New tag. If none is provided, the from tag will be deleted
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);
489 foreach ($linksToAlter as $key => &$value) {
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
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();
512 foreach ($this->links as $link) {
513 $linkDays[$link['created']->format('Ymd')] = 0;
514 }
515 $linkDays = array_keys($linkDays);
516 sort($linkDays);
517
518 return $linkDays;
519 }
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.
532 usort($this->links, function ($a, $b) use ($order) {
533 if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) {
534 return $a['sticky'] ? -1 : 1;
535 }
536 return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
537 });
538
539 $this->urls = [];
540 $this->ids = [];
541 foreach ($this->links as $key => $link) {
542 $this->urls[$link['url']] = $key;
543 $this->ids[$link['id']] = $key;
544 }
545 }
546
547 /**
548 * Return the next key for link creation.
549 * E.g. If the last ID is 597, the next will be 598.
550 *
551 * @return int next ID.
552 */
553 public function getNextId()
554 {
555 if (!empty($this->ids)) {
556 return max(array_keys($this->ids)) + 1;
557 }
558 return 0;
559 }
560
561 /**
562 * Returns a link offset in links array from its unique ID.
563 *
564 * @param int $id Persistent ID of a link.
565 *
566 * @return int Real offset in local array, or null if doesn't exist.
567 */
568 protected function getLinkOffset($id)
569 {
570 if (isset($this->ids[$id])) {
571 return $this->ids[$id];
572 }
573 return null;
574 }
575}
diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php
index 77eb2d95..e7af4d55 100644
--- a/application/bookmark/LinkUtils.php
+++ b/application/bookmark/LinkUtils.php
@@ -1,112 +1,6 @@
1<?php 1<?php
2 2
3use Shaarli\Bookmark\LinkDB; 3use Shaarli\Bookmark\Bookmark;
4
5/**
6 * Get cURL callback function for CURLOPT_WRITEFUNCTION
7 *
8 * @param string $charset to extract from the downloaded page (reference)
9 * @param string $title to extract from the downloaded page (reference)
10 * @param string $description to extract from the downloaded page (reference)
11 * @param string $keywords to extract from the downloaded page (reference)
12 * @param bool $retrieveDescription Automatically tries to retrieve description and keywords from HTML content
13 * @param string $curlGetInfo Optionally overrides curl_getinfo function
14 *
15 * @return Closure
16 */
17function get_curl_download_callback(
18 &$charset,
19 &$title,
20 &$description,
21 &$keywords,
22 $retrieveDescription,
23 $curlGetInfo = 'curl_getinfo'
24) {
25 $isRedirected = false;
26 $currentChunk = 0;
27 $foundChunk = null;
28
29 /**
30 * cURL callback function for CURLOPT_WRITEFUNCTION (called during the download).
31 *
32 * While downloading the remote page, we check that the HTTP code is 200 and content type is 'html/text'
33 * Then we extract the title and the charset and stop the download when it's done.
34 *
35 * @param resource $ch cURL resource
36 * @param string $data chunk of data being downloaded
37 *
38 * @return int|bool length of $data or false if we need to stop the download
39 */
40 return function (&$ch, $data) use (
41 $retrieveDescription,
42 $curlGetInfo,
43 &$charset,
44 &$title,
45 &$description,
46 &$keywords,
47 &$isRedirected,
48 &$currentChunk,
49 &$foundChunk
50 ) {
51 $currentChunk++;
52 $responseCode = $curlGetInfo($ch, CURLINFO_RESPONSE_CODE);
53 if (!empty($responseCode) && in_array($responseCode, [301, 302])) {
54 $isRedirected = true;
55 return strlen($data);
56 }
57 if (!empty($responseCode) && $responseCode !== 200) {
58 return false;
59 }
60 // After a redirection, the content type will keep the previous request value
61 // until it finds the next content-type header.
62 if (! $isRedirected || strpos(strtolower($data), 'content-type') !== false) {
63 $contentType = $curlGetInfo($ch, CURLINFO_CONTENT_TYPE);
64 }
65 if (!empty($contentType) && strpos($contentType, 'text/html') === false) {
66 return false;
67 }
68 if (!empty($contentType) && empty($charset)) {
69 $charset = header_extract_charset($contentType);
70 }
71 if (empty($charset)) {
72 $charset = html_extract_charset($data);
73 }
74 if (empty($title)) {
75 $title = html_extract_title($data);
76 $foundChunk = ! empty($title) ? $currentChunk : $foundChunk;
77 }
78 if ($retrieveDescription && empty($description)) {
79 $description = html_extract_tag('description', $data);
80 $foundChunk = ! empty($description) ? $currentChunk : $foundChunk;
81 }
82 if ($retrieveDescription && empty($keywords)) {
83 $keywords = html_extract_tag('keywords', $data);
84 if (! empty($keywords)) {
85 $foundChunk = $currentChunk;
86 // Keywords use the format tag1, tag2 multiple words, tag
87 // So we format them to match Shaarli's separator and glue multiple words with '-'
88 $keywords = implode(' ', array_map(function($keyword) {
89 return implode('-', preg_split('/\s+/', trim($keyword)));
90 }, explode(',', $keywords)));
91 }
92 }
93
94 // We got everything we want, stop the download.
95 // If we already found either the title, description or keywords,
96 // it's highly unlikely that we'll found the other metas further than
97 // in the same chunk of data or the next one. So we also stop the download after that.
98 if ((!empty($responseCode) && !empty($contentType) && !empty($charset)) && $foundChunk !== null
99 && (! $retrieveDescription
100 || $foundChunk < $currentChunk
101 || (!empty($title) && !empty($description) && !empty($keywords))
102 )
103 ) {
104 return false;
105 }
106
107 return strlen($data);
108 };
109}
110 4
111/** 5/**
112 * Extract title from an HTML document. 6 * Extract title from an HTML document.
@@ -132,7 +26,7 @@ function html_extract_title($html)
132 */ 26 */
133function header_extract_charset($header) 27function header_extract_charset($header)
134{ 28{
135 preg_match('/charset="?([^; ]+)/i', $header, $match); 29 preg_match('/charset=["\']?([^; "\']+)/i', $header, $match);
136 if (! empty($match[1])) { 30 if (! empty($match[1])) {
137 return strtolower(trim($match[1])); 31 return strtolower(trim($match[1]));
138 } 32 }
@@ -188,30 +82,11 @@ function html_extract_tag($tag, $html)
188} 82}
189 83
190/** 84/**
191 * Count private links in given linklist. 85 * In a string, converts URLs to clickable bookmarks.
192 *
193 * @param array|Countable $links Linklist.
194 *
195 * @return int Number of private links.
196 */
197function count_private($links)
198{
199 $cpt = 0;
200 foreach ($links as $link) {
201 if ($link['private']) {
202 $cpt += 1;
203 }
204 }
205
206 return $cpt;
207}
208
209/**
210 * In a string, converts URLs to clickable links.
211 * 86 *
212 * @param string $text input string. 87 * @param string $text input string.
213 * 88 *
214 * @return string returns $text with all links converted to HTML links. 89 * @return string returns $text with all bookmarks converted to HTML bookmarks.
215 * 90 *
216 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 91 * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
217 */ 92 */
@@ -239,7 +114,7 @@ function hashtag_autolink($description, $indexUrl = '')
239 * \p{Mn} - any non marking space (accents, umlauts, etc) 114 * \p{Mn} - any non marking space (accents, umlauts, etc)
240 */ 115 */
241 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; 116 $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui';
242 $replacement = '$1<a href="'. $indexUrl .'?addtag=$2" title="Hashtag $2">#$2</a>'; 117 $replacement = '$1<a href="'. $indexUrl .'./add-tag/$2" title="Hashtag $2">#$2</a>';
243 return preg_replace($regex, $replacement, $description); 118 return preg_replace($regex, $replacement, $description);
244} 119}
245 120
@@ -279,7 +154,7 @@ function format_description($description, $indexUrl = '')
279 */ 154 */
280function link_small_hash($date, $id) 155function link_small_hash($date, $id)
281{ 156{
282 return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id); 157 return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
283} 158}
284 159
285/** 160/**
diff --git a/application/bookmark/exception/LinkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php
index f9414428..827a3d35 100644
--- a/application/bookmark/exception/LinkNotFoundException.php
+++ b/application/bookmark/exception/BookmarkNotFoundException.php
@@ -3,7 +3,7 @@ namespace Shaarli\Bookmark\Exception;
3 3
4use Exception; 4use Exception;
5 5
6class LinkNotFoundException extends Exception 6class BookmarkNotFoundException extends Exception
7{ 7{
8 /** 8 /**
9 * LinkNotFoundException constructor. 9 * LinkNotFoundException constructor.
diff --git a/application/bookmark/exception/DatastoreNotInitializedException.php b/application/bookmark/exception/DatastoreNotInitializedException.php
new file mode 100644
index 00000000..f495049d
--- /dev/null
+++ b/application/bookmark/exception/DatastoreNotInitializedException.php
@@ -0,0 +1,10 @@
1<?php
2
3declare(strict_types=1);
4
5namespace Shaarli\Bookmark\Exception;
6
7class DatastoreNotInitializedException extends \Exception
8{
9
10}
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
new file mode 100644
index 00000000..cd48c1e6
--- /dev/null
+++ b/application/bookmark/exception/EmptyDataStoreException.php
@@ -0,0 +1,7 @@
1<?php
2
3
4namespace Shaarli\Bookmark\Exception;
5
6
7class EmptyDataStoreException extends \Exception {}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
new file mode 100644
index 00000000..10c84a6d
--- /dev/null
+++ b/application/bookmark/exception/InvalidBookmarkException.php
@@ -0,0 +1,30 @@
1<?php
2
3namespace Shaarli\Bookmark\Exception;
4
5use Shaarli\Bookmark\Bookmark;
6
7class InvalidBookmarkException extends \Exception
8{
9 public function __construct($bookmark)
10 {
11 if ($bookmark instanceof Bookmark) {
12 if ($bookmark->getCreated() instanceof \DateTime) {
13 $created = $bookmark->getCreated()->format(\DateTime::ATOM);
14 } elseif (empty($bookmark->getCreated())) {
15 $created = '';
16 } else {
17 $created = 'Not a DateTime object';
18 }
19 $this->message = 'This bookmark is not valid'. PHP_EOL;
20 $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL;
21 $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL;
22 $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL;
23 $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL;
24 $this->message .= ' - Created: '. $created . PHP_EOL;
25 } else {
26 $this->message = 'The provided data is not a bookmark'. PHP_EOL;
27 $this->message .= var_export($bookmark, true);
28 }
29 }
30}
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
new file mode 100644
index 00000000..95f34b50
--- /dev/null
+++ b/application/bookmark/exception/NotWritableDataStoreException.php
@@ -0,0 +1,19 @@
1<?php
2
3
4namespace Shaarli\Bookmark\Exception;
5
6
7class NotWritableDataStoreException extends \Exception
8{
9 /**
10 * NotReadableDataStore constructor.
11 *
12 * @param string $dataStore file path
13 */
14 public function __construct($dataStore)
15 {
16 $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '.
17 'Your data might be corrupted, or your file isn\'t readable.';
18 }
19}