From 336a28fa4a09b968ce4705900bf57693e672f0bf Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 25 May 2019 15:46:47 +0200 Subject: [PATCH] Introduce Bookmark object and Service layer to retrieve them See https://github.com/shaarli/Shaarli/issues/1307 for details --- application/bookmark/Bookmark.php | 461 +++++++++++++ application/bookmark/BookmarkArray.php | 259 ++++++++ application/bookmark/BookmarkFileService.php | 373 +++++++++++ application/bookmark/BookmarkFilter.php | 468 +++++++++++++ application/bookmark/BookmarkIO.php | 108 +++ application/bookmark/BookmarkInitializer.php | 59 ++ .../bookmark/BookmarkServiceInterface.php | 180 +++++ application/bookmark/LinkUtils.php | 27 +- ...tion.php => BookmarkNotFoundException.php} | 2 +- .../exception/EmptyDataStoreException.php | 7 + .../exception/InvalidBookmarkException.php | 30 + .../NotWritableDataStoreException.php | 19 + .../formatter/BookmarkDefaultFormatter.php | 81 +++ application/formatter/BookmarkFormatter.php | 256 ++++++++ .../formatter/BookmarkMarkdownFormatter.php | 198 ++++++ .../formatter/BookmarkRawFormatter.php | 13 + application/formatter/FormatterFactory.php | 46 ++ .../LinkDB.php => legacy/LegacyLinkDB.php} | 68 +- .../LegacyLinkFilter.php} | 20 +- application/legacy/LegacyUpdater.php | 617 ++++++++++++++++++ composer.json | 2 + 21 files changed, 3228 insertions(+), 66 deletions(-) create mode 100644 application/bookmark/Bookmark.php create mode 100644 application/bookmark/BookmarkArray.php create mode 100644 application/bookmark/BookmarkFileService.php create mode 100644 application/bookmark/BookmarkFilter.php create mode 100644 application/bookmark/BookmarkIO.php create mode 100644 application/bookmark/BookmarkInitializer.php create mode 100644 application/bookmark/BookmarkServiceInterface.php rename application/bookmark/exception/{LinkNotFoundException.php => BookmarkNotFoundException.php} (84%) create mode 100644 application/bookmark/exception/EmptyDataStoreException.php create mode 100644 application/bookmark/exception/InvalidBookmarkException.php create mode 100644 application/bookmark/exception/NotWritableDataStoreException.php create mode 100644 application/formatter/BookmarkDefaultFormatter.php create mode 100644 application/formatter/BookmarkFormatter.php create mode 100644 application/formatter/BookmarkMarkdownFormatter.php create mode 100644 application/formatter/BookmarkRawFormatter.php create mode 100644 application/formatter/FormatterFactory.php rename application/{bookmark/LinkDB.php => legacy/LegacyLinkDB.php} (89%) rename application/{bookmark/LinkFilter.php => legacy/LegacyLinkFilter.php} (96%) create mode 100644 application/legacy/LegacyUpdater.php diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php new file mode 100644 index 00000000..b08e5d67 --- /dev/null +++ b/application/bookmark/Bookmark.php @@ -0,0 +1,461 @@ +id = $data['id']; + $this->shortUrl = $data['shorturl']; + $this->url = $data['url']; + $this->title = $data['title']; + $this->description = $data['description']; + $this->thumbnail = ! empty($data['thumbnail']) ? $data['thumbnail'] : null; + $this->sticky = ! empty($data['sticky']) ? $data['sticky'] : false; + $this->created = $data['created']; + if (is_array($data['tags'])) { + $this->tags = $data['tags']; + } else { + $this->tags = preg_split('/\s+/', $data['tags'], -1, PREG_SPLIT_NO_EMPTY); + } + if (! empty($data['updated'])) { + $this->updated = $data['updated']; + } + $this->private = $data['private'] ? true : false; + + return $this; + } + + /** + * Make sure that the current instance of Bookmark is valid and can be saved into the data store. + * A valid link requires: + * - an integer ID + * - a short URL (for permalinks) + * - a creation date + * + * This function also initialize optional empty fields: + * - the URL with the permalink + * - the title with the URL + * + * @throws InvalidBookmarkException + */ + public function validate() + { + if ($this->id === null + || ! is_int($this->id) + || empty($this->shortUrl) + || empty($this->created) + || ! $this->created instanceof DateTime + ) { + throw new InvalidBookmarkException($this); + } + if (empty($this->url)) { + $this->url = '?'. $this->shortUrl; + } + if (empty($this->title)) { + $this->title = $this->url; + } + } + + /** + * Set the Id. + * If they're not already initialized, this function also set: + * - created: with the current datetime + * - shortUrl: with a generated small hash from the date and the given ID + * + * @param int $id + * + * @return Bookmark + */ + public function setId($id) + { + $this->id = $id; + if (empty($this->created)) { + $this->created = new DateTime(); + } + if (empty($this->shortUrl)) { + $this->shortUrl = link_small_hash($this->created, $this->id); + } + + return $this; + } + + /** + * Get the Id. + * + * @return int + */ + public function getId() + { + return $this->id; + } + + /** + * Get the ShortUrl. + * + * @return string + */ + public function getShortUrl() + { + return $this->shortUrl; + } + + /** + * Get the Url. + * + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * Get the Title. + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Get the Description. + * + * @return string + */ + public function getDescription() + { + return ! empty($this->description) ? $this->description : ''; + } + + /** + * Get the Created. + * + * @return DateTime + */ + public function getCreated() + { + return $this->created; + } + + /** + * Get the Updated. + * + * @return DateTime + */ + public function getUpdated() + { + return $this->updated; + } + + /** + * Set the ShortUrl. + * + * @param string $shortUrl + * + * @return Bookmark + */ + public function setShortUrl($shortUrl) + { + $this->shortUrl = $shortUrl; + + return $this; + } + + /** + * Set the Url. + * + * @param string $url + * @param array $allowedProtocols + * + * @return Bookmark + */ + public function setUrl($url, $allowedProtocols = []) + { + $url = trim($url); + if (! empty($url)) { + $url = whitelist_protocols($url, $allowedProtocols); + } + $this->url = $url; + + return $this; + } + + /** + * Set the Title. + * + * @param string $title + * + * @return Bookmark + */ + public function setTitle($title) + { + $this->title = trim($title); + + return $this; + } + + /** + * Set the Description. + * + * @param string $description + * + * @return Bookmark + */ + public function setDescription($description) + { + $this->description = $description; + + return $this; + } + + /** + * Set the Created. + * Note: you shouldn't set this manually except for special cases (like bookmark import) + * + * @param DateTime $created + * + * @return Bookmark + */ + public function setCreated($created) + { + $this->created = $created; + + return $this; + } + + /** + * Set the Updated. + * + * @param DateTime $updated + * + * @return Bookmark + */ + public function setUpdated($updated) + { + $this->updated = $updated; + + return $this; + } + + /** + * Get the Private. + * + * @return bool + */ + public function isPrivate() + { + return $this->private ? true : false; + } + + /** + * Set the Private. + * + * @param bool $private + * + * @return Bookmark + */ + public function setPrivate($private) + { + $this->private = $private ? true : false; + + return $this; + } + + /** + * Get the Tags. + * + * @return array + */ + public function getTags() + { + return is_array($this->tags) ? $this->tags : []; + } + + /** + * Set the Tags. + * + * @param array $tags + * + * @return Bookmark + */ + public function setTags($tags) + { + $this->setTagsString(implode(' ', $tags)); + + return $this; + } + + /** + * Get the Thumbnail. + * + * @return string|bool + */ + public function getThumbnail() + { + return !$this->isNote() ? $this->thumbnail : false; + } + + /** + * Set the Thumbnail. + * + * @param string|bool $thumbnail + * + * @return Bookmark + */ + public function setThumbnail($thumbnail) + { + $this->thumbnail = $thumbnail; + + return $this; + } + + /** + * Get the Sticky. + * + * @return bool + */ + public function isSticky() + { + return $this->sticky ? true : false; + } + + /** + * Set the Sticky. + * + * @param bool $sticky + * + * @return Bookmark + */ + public function setSticky($sticky) + { + $this->sticky = $sticky ? true : false; + + return $this; + } + + /** + * @return string Bookmark's tags as a string, separated by a space + */ + public function getTagsString() + { + return implode(' ', $this->getTags()); + } + + /** + * @return bool + */ + public function isNote() + { + // We check empty value to get a valid result if the link has not been saved yet + return empty($this->url) || $this->url[0] === '?'; + } + + /** + * Set tags from a string. + * Note: + * - tags must be separated whether by a space or a comma + * - multiple spaces will be removed + * - trailing dash in tags will be removed + * + * @param string $tags + * + * @return $this + */ + public function setTagsString($tags) + { + // Remove first '-' char in tags. + $tags = preg_replace('/(^| )\-/', '$1', $tags); + // Explode all tags separted by spaces or commas + $tags = preg_split('/[\s,]+/', $tags); + // Remove eventual empty values + $tags = array_values(array_filter($tags)); + + $this->tags = $tags; + + return $this; + } + + /** + * Rename a tag in tags list. + * + * @param string $fromTag + * @param string $toTag + */ + public function renameTag($fromTag, $toTag) + { + if (($pos = array_search($fromTag, $this->tags)) !== false) { + $this->tags[$pos] = trim($toTag); + } + } + + /** + * Delete a tag from tags list. + * + * @param string $tag + */ + public function deleteTag($tag) + { + if (($pos = array_search($tag, $this->tags)) !== false) { + unset($this->tags[$pos]); + $this->tags = array_values($this->tags); + } + } +} diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php new file mode 100644 index 00000000..b427c91a --- /dev/null +++ b/application/bookmark/BookmarkArray.php @@ -0,0 +1,259 @@ +offset. + */ + protected $ids; + + /** + * @var int Position in the $this->keys array (for the Iterator interface) + */ + protected $position; + + /** + * @var array List of offset keys (for the Iterator interface implementation) + */ + protected $keys; + + /** + * @var array List of all recorded URLs (key=url, value=bookmark offset) + * for fast reserve search (url-->bookmark offset) + */ + protected $urls; + + public function __construct() + { + $this->ids = []; + $this->bookmarks = []; + $this->keys = []; + $this->urls = []; + $this->position = 0; + } + + /** + * Countable - Counts elements of an object + * + * @return int Number of bookmarks + */ + public function count() + { + return count($this->bookmarks); + } + + /** + * ArrayAccess - Assigns a value to the specified offset + * + * @param int $offset Bookmark ID + * @param Bookmark $value instance + * + * @throws InvalidBookmarkException + */ + public function offsetSet($offset, $value) + { + if (! $value instanceof Bookmark + || $value->getId() === null || empty($value->getUrl()) + || ($offset !== null && ! is_int($offset)) || ! is_int($value->getId()) + || $offset !== null && $offset !== $value->getId() + ) { + throw new InvalidBookmarkException($value); + } + + // If the bookmark exists, we reuse the real offset, otherwise new entry + if ($offset !== null) { + $existing = $this->getBookmarkOffset($offset); + } else { + $existing = $this->getBookmarkOffset($value->getId()); + } + + if ($existing !== null) { + $offset = $existing; + } else { + $offset = count($this->bookmarks); + } + + $this->bookmarks[$offset] = $value; + $this->urls[$value->getUrl()] = $offset; + $this->ids[$value->getId()] = $offset; + } + + /** + * ArrayAccess - Whether or not an offset exists + * + * @param int $offset Bookmark ID + * + * @return bool true if it exists, false otherwise + */ + public function offsetExists($offset) + { + return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks); + } + + /** + * ArrayAccess - Unsets an offset + * + * @param int $offset Bookmark ID + */ + public function offsetUnset($offset) + { + $realOffset = $this->getBookmarkOffset($offset); + $url = $this->bookmarks[$realOffset]->getUrl(); + unset($this->urls[$url]); + unset($this->ids[$realOffset]); + unset($this->bookmarks[$realOffset]); + } + + /** + * ArrayAccess - Returns the value at specified offset + * + * @param int $offset Bookmark ID + * + * @return Bookmark|null The Bookmark if found, null otherwise + */ + public function offsetGet($offset) + { + $realOffset = $this->getBookmarkOffset($offset); + return isset($this->bookmarks[$realOffset]) ? $this->bookmarks[$realOffset] : null; + } + + /** + * Iterator - Returns the current element + * + * @return Bookmark corresponding to the current position + */ + public function current() + { + return $this[$this->keys[$this->position]]; + } + + /** + * Iterator - Returns the key of the current element + * + * @return int Bookmark ID corresponding to the current position + */ + public function key() + { + return $this->keys[$this->position]; + } + + /** + * Iterator - Moves forward to next element + */ + public function next() + { + ++$this->position; + } + + /** + * Iterator - Rewinds the Iterator to the first element + * + * Entries are sorted by date (latest first) + */ + public function rewind() + { + $this->keys = array_keys($this->ids); + $this->position = 0; + } + + /** + * Iterator - Checks if current position is valid + * + * @return bool true if the current Bookmark ID exists, false otherwise + */ + public function valid() + { + return isset($this->keys[$this->position]); + } + + /** + * Returns a bookmark offset in bookmarks array from its unique ID. + * + * @param int $id Persistent ID of a bookmark. + * + * @return int Real offset in local array, or null if doesn't exist. + */ + protected function getBookmarkOffset($id) + { + if (isset($this->ids[$id])) { + return $this->ids[$id]; + } + return null; + } + + /** + * Return the next key for bookmark creation. + * E.g. If the last ID is 597, the next will be 598. + * + * @return int next ID. + */ + public function getNextId() + { + if (!empty($this->ids)) { + return max(array_keys($this->ids)) + 1; + } + return 0; + } + + /** + * @param $url + * + * @return Bookmark|null + */ + public function getByUrl($url) + { + if (! empty($url) + && isset($this->urls[$url]) + && isset($this->bookmarks[$this->urls[$url]]) + ) { + return $this->bookmarks[$this->urls[$url]]; + } + return null; + } + + /** + * Reorder links by creation date (newest first). + * + * Also update the urls and ids mapping arrays. + * + * @param string $order ASC|DESC + */ + public function reorder($order = 'DESC') + { + $order = $order === 'ASC' ? -1 : 1; + // Reorder array by dates. + usort($this->bookmarks, function ($a, $b) use ($order) { + /** @var $a Bookmark */ + /** @var $b Bookmark */ + if ($a->isSticky() !== $b->isSticky()) { + return $a->isSticky() ? -1 : 1; + } + return $a->getCreated() < $b->getCreated() ? 1 * $order : -1 * $order; + }); + + $this->urls = []; + $this->ids = []; + foreach ($this->bookmarks as $key => $bookmark) { + $this->urls[$bookmark->getUrl()] = $key; + $this->ids[$bookmark->getId()] = $key; + } + } +} diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php new file mode 100644 index 00000000..a56cc92b --- /dev/null +++ b/application/bookmark/BookmarkFileService.php @@ -0,0 +1,373 @@ +conf = $conf; + $this->history = $history; + $this->bookmarksIO = new BookmarkIO($this->conf); + $this->isLoggedIn = $isLoggedIn; + + if (!$this->isLoggedIn && $this->conf->get('privacy.hide_public_links', false)) { + $this->bookmarks = []; + } else { + try { + $this->bookmarks = $this->bookmarksIO->read(); + } catch (EmptyDataStoreException $e) { + $this->bookmarks = new BookmarkArray(); + if ($isLoggedIn) { + $this->save(); + } + } + + if (! $this->bookmarks instanceof BookmarkArray) { + $this->migrate(); + exit( + 'Your data store has been migrated, please reload the page.'. PHP_EOL . + 'If this message keeps showing up, please delete data/updates.txt file.' + ); + } + } + + $this->bookmarkFilter = new BookmarkFilter($this->bookmarks); + } + + /** + * @inheritDoc + */ + public function findByHash($hash) + { + $bookmark = $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_HASH, $hash); + // PHP 7.3 introduced array_key_first() to avoid this hack + $first = reset($bookmark); + if (! $this->isLoggedIn && $first->isPrivate()) { + throw new Exception('Not authorized'); + } + + return $bookmark; + } + + /** + * @inheritDoc + */ + public function findByUrl($url) + { + return $this->bookmarks->getByUrl($url); + } + + /** + * @inheritDoc + */ + public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false) + { + if ($visibility === null) { + $visibility = $this->isLoggedIn ? BookmarkFilter::$ALL : BookmarkFilter::$PUBLIC; + } + + // Filter bookmark database according to parameters. + $searchtags = isset($request['searchtags']) ? $request['searchtags'] : ''; + $searchterm = isset($request['searchterm']) ? $request['searchterm'] : ''; + + return $this->bookmarkFilter->filter( + BookmarkFilter::$FILTER_TAG | BookmarkFilter::$FILTER_TEXT, + [$searchtags, $searchterm], + $caseSensitive, + $visibility, + $untaggedOnly + ); + } + + /** + * @inheritDoc + */ + public function get($id, $visibility = null) + { + if (! isset($this->bookmarks[$id])) { + throw new BookmarkNotFoundException(); + } + + if ($visibility === null) { + $visibility = $this->isLoggedIn ? 'all' : 'public'; + } + + $bookmark = $this->bookmarks[$id]; + if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') + || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') + ) { + throw new Exception('Unauthorized'); + } + + return $bookmark; + } + + /** + * @inheritDoc + */ + public function set($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception(t('Provided data is invalid')); + } + if (! isset($this->bookmarks[$bookmark->getId()])) { + throw new BookmarkNotFoundException(); + } + $bookmark->validate(); + + $bookmark->setUpdated(new \DateTime()); + $this->bookmarks[$bookmark->getId()] = $bookmark; + if ($save === true) { + $this->save(); + $this->history->updateLink($bookmark); + } + return $this->bookmarks[$bookmark->getId()]; + } + + /** + * @inheritDoc + */ + public function add($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception(t('Provided data is invalid')); + } + if (! empty($bookmark->getId())) { + throw new Exception(t('This bookmarks already exists')); + } + $bookmark->setId($this->bookmarks->getNextId()); + $bookmark->validate(); + + $this->bookmarks[$bookmark->getId()] = $bookmark; + if ($save === true) { + $this->save(); + $this->history->addLink($bookmark); + } + return $this->bookmarks[$bookmark->getId()]; + } + + /** + * @inheritDoc + */ + public function addOrSet($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception('Provided data is invalid'); + } + if ($bookmark->getId() === null) { + return $this->add($bookmark, $save); + } + return $this->set($bookmark, $save); + } + + /** + * @inheritDoc + */ + public function remove($bookmark, $save = true) + { + if ($this->isLoggedIn !== true) { + throw new Exception(t('You\'re not authorized to alter the datastore')); + } + if (! $bookmark instanceof Bookmark) { + throw new Exception(t('Provided data is invalid')); + } + if (! isset($this->bookmarks[$bookmark->getId()])) { + throw new BookmarkNotFoundException(); + } + + unset($this->bookmarks[$bookmark->getId()]); + if ($save === true) { + $this->save(); + $this->history->deleteLink($bookmark); + } + } + + /** + * @inheritDoc + */ + public function exists($id, $visibility = null) + { + if (! isset($this->bookmarks[$id])) { + return false; + } + + if ($visibility === null) { + $visibility = $this->isLoggedIn ? 'all' : 'public'; + } + + $bookmark = $this->bookmarks[$id]; + if (($bookmark->isPrivate() && $visibility != 'all' && $visibility != 'private') + || (! $bookmark->isPrivate() && $visibility != 'all' && $visibility != 'public') + ) { + return false; + } + + return true; + } + + /** + * @inheritDoc + */ + public function count($visibility = null) + { + return count($this->search([], $visibility)); + } + + /** + * @inheritDoc + */ + public function save() + { + if (!$this->isLoggedIn) { + // TODO: raise an Exception instead + die('You are not authorized to change the database.'); + } + $this->bookmarks->reorder(); + $this->bookmarksIO->write($this->bookmarks); + invalidateCaches($this->conf->get('resource.page_cache')); + } + + /** + * @inheritDoc + */ + public function bookmarksCountPerTag($filteringTags = [], $visibility = null) + { + $bookmarks = $this->search(['searchtags' => $filteringTags], $visibility); + $tags = []; + $caseMapping = []; + foreach ($bookmarks as $bookmark) { + foreach ($bookmark->getTags() as $tag) { + if (empty($tag) || (! $this->isLoggedIn && startsWith($tag, '.'))) { + continue; + } + // The first case found will be displayed. + if (!isset($caseMapping[strtolower($tag)])) { + $caseMapping[strtolower($tag)] = $tag; + $tags[$caseMapping[strtolower($tag)]] = 0; + } + $tags[$caseMapping[strtolower($tag)]]++; + } + } + + /* + * Formerly used arsort(), which doesn't define the sort behaviour for equal values. + * Also, this function doesn't produce the same result between PHP 5.6 and 7. + * + * So we now use array_multisort() to sort tags by DESC occurrences, + * then ASC alphabetically for equal values. + * + * @see https://github.com/shaarli/Shaarli/issues/1142 + */ + $keys = array_keys($tags); + $tmpTags = array_combine($keys, $keys); + array_multisort($tags, SORT_DESC, $tmpTags, SORT_ASC, $tags); + return $tags; + } + + /** + * @inheritDoc + */ + public function days() + { + $bookmarkDays = []; + foreach ($this->search() as $bookmark) { + $bookmarkDays[$bookmark->getCreated()->format('Ymd')] = 0; + } + $bookmarkDays = array_keys($bookmarkDays); + sort($bookmarkDays); + + return $bookmarkDays; + } + + /** + * @inheritDoc + */ + public function filterDay($request) + { + return $this->bookmarkFilter->filter(BookmarkFilter::$FILTER_DAY, $request); + } + + /** + * @inheritDoc + */ + public function initialize() + { + $initializer = new BookmarkInitializer($this); + $initializer->initialize(); + } + + /** + * Handles migration to the new database format (BookmarksArray). + */ + protected function migrate() + { + $bookmarkDb = new LegacyLinkDB( + $this->conf->get('resource.datastore'), + true, + false + ); + $updater = new LegacyUpdater( + UpdaterUtils::read_updates_file($this->conf->get('resource.updates')), + $bookmarkDb, + $this->conf, + true + ); + $newUpdates = $updater->update(); + if (! empty($newUpdates)) { + UpdaterUtils::write_updates_file( + $this->conf->get('resource.updates'), + $updater->getDoneUpdates() + ); + } + } +} diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php new file mode 100644 index 00000000..fd556679 --- /dev/null +++ b/application/bookmark/BookmarkFilter.php @@ -0,0 +1,468 @@ +bookmarks = $bookmarks; + } + + /** + * Filter bookmarks according to parameters. + * + * @param string $type Type of filter (eg. tags, permalink, etc.). + * @param mixed $request Filter content. + * @param bool $casesensitive Optional: Perform case sensitive filter if true. + * @param string $visibility Optional: return only all/private/public bookmarks + * @param bool $untaggedonly Optional: return only untagged bookmarks. Applies only if $type includes FILTER_TAG + * + * @return Bookmark[] filtered bookmark list. + * + * @throws BookmarkNotFoundException + */ + public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false) + { + if (!in_array($visibility, ['all', 'public', 'private'])) { + $visibility = 'all'; + } + + switch ($type) { + case self::$FILTER_HASH: + return $this->filterSmallHash($request); + case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext" + $noRequest = empty($request) || (empty($request[0]) && empty($request[1])); + if ($noRequest) { + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } + return $this->noFilter($visibility); + } + if ($untaggedonly) { + $filtered = $this->filterUntagged($visibility); + } else { + $filtered = $this->bookmarks; + } + if (!empty($request[0])) { + $filtered = (new BookmarkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); + } + if (!empty($request[1])) { + $filtered = (new BookmarkFilter($filtered))->filterFulltext($request[1], $visibility); + } + return $filtered; + case self::$FILTER_TEXT: + return $this->filterFulltext($request, $visibility); + case self::$FILTER_TAG: + if ($untaggedonly) { + return $this->filterUntagged($visibility); + } else { + return $this->filterTags($request, $casesensitive, $visibility); + } + case self::$FILTER_DAY: + return $this->filterDay($request); + default: + return $this->noFilter($visibility); + } + } + + /** + * Unknown filter, but handle private only. + * + * @param string $visibility Optional: return only all/private/public bookmarks + * + * @return Bookmark[] filtered bookmarks. + */ + private function noFilter($visibility = 'all') + { + if ($visibility === 'all') { + return $this->bookmarks; + } + + $out = array(); + foreach ($this->bookmarks as $key => $value) { + if ($value->isPrivate() && $visibility === 'private') { + $out[$key] = $value; + } elseif (!$value->isPrivate() && $visibility === 'public') { + $out[$key] = $value; + } + } + + return $out; + } + + /** + * Returns the shaare corresponding to a smallHash. + * + * @param string $smallHash permalink hash. + * + * @return array $filtered array containing permalink data. + * + * @throws \Shaarli\Bookmark\Exception\BookmarkNotFoundException if the smallhash doesn't match any link. + */ + private function filterSmallHash($smallHash) + { + foreach ($this->bookmarks as $key => $l) { + if ($smallHash == $l->getShortUrl()) { + // Yes, this is ugly and slow + return [$key => $l]; + } + } + + throw new BookmarkNotFoundException(); + } + + /** + * Returns the list of bookmarks corresponding to a full-text search + * + * Searches: + * - in the URLs, title and description; + * - are case-insensitive; + * - terms surrounded by quotes " are exact terms search. + * - terms starting with a dash - are excluded (except exact terms). + * + * Example: + * print_r($mydb->filterFulltext('hollandais')); + * + * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') + * - allows to perform searches on Unicode text + * - see https://github.com/shaarli/Shaarli/issues/75 for examples + * + * @param string $searchterms search query. + * @param string $visibility Optional: return only all/private/public bookmarks. + * + * @return array search results. + */ + private function filterFulltext($searchterms, $visibility = 'all') + { + if (empty($searchterms)) { + return $this->noFilter($visibility); + } + + $filtered = array(); + $search = mb_convert_case(html_entity_decode($searchterms), MB_CASE_LOWER, 'UTF-8'); + $exactRegex = '/"([^"]+)"/'; + // Retrieve exact search terms. + preg_match_all($exactRegex, $search, $exactSearch); + $exactSearch = array_values(array_filter($exactSearch[1])); + + // Remove exact search terms to get AND terms search. + $explodedSearchAnd = explode(' ', trim(preg_replace($exactRegex, '', $search))); + $explodedSearchAnd = array_values(array_filter($explodedSearchAnd)); + + // Filter excluding terms and update andSearch. + $excludeSearch = array(); + $andSearch = array(); + foreach ($explodedSearchAnd as $needle) { + if ($needle[0] == '-' && strlen($needle) > 1) { + $excludeSearch[] = substr($needle, 1); + } else { + $andSearch[] = $needle; + } + } + + // Iterate over every stored link. + foreach ($this->bookmarks as $id => $link) { + // ignore non private bookmarks when 'privatonly' is on. + if ($visibility !== 'all') { + if (!$link->isPrivate() && $visibility === 'private') { + continue; + } elseif ($link->isPrivate() && $visibility === 'public') { + continue; + } + } + + // Concatenate link fields to search across fields. + // Adds a '\' separator for exact search terms. + $content = mb_convert_case($link->getTitle(), MB_CASE_LOWER, 'UTF-8') .'\\'; + $content .= mb_convert_case($link->getDescription(), MB_CASE_LOWER, 'UTF-8') .'\\'; + $content .= mb_convert_case($link->getUrl(), MB_CASE_LOWER, 'UTF-8') .'\\'; + $content .= mb_convert_case($link->getTagsString(), MB_CASE_LOWER, 'UTF-8') .'\\'; + + // Be optimistic + $found = true; + + // First, we look for exact term search + for ($i = 0; $i < count($exactSearch) && $found; $i++) { + $found = strpos($content, $exactSearch[$i]) !== false; + } + + // Iterate over keywords, if keyword is not found, + // no need to check for the others. We want all or nothing. + for ($i = 0; $i < count($andSearch) && $found; $i++) { + $found = strpos($content, $andSearch[$i]) !== false; + } + + // Exclude terms. + for ($i = 0; $i < count($excludeSearch) && $found; $i++) { + $found = strpos($content, $excludeSearch[$i]) === false; + } + + if ($found) { + $filtered[$id] = $link; + } + } + + return $filtered; + } + + /** + * generate a regex fragment out of a tag + * + * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard + * + * @return string generated regex fragment + */ + private static function tag2regex($tag) + { + $len = strlen($tag); + if (!$len || $tag === "-" || $tag === "*") { + // nothing to search, return empty regex + return ''; + } + if ($tag[0] === "-") { + // query is negated + $i = 1; // use offset to start after '-' character + $regex = '(?!'; // create negative lookahead + } else { + $i = 0; // start at first character + $regex = '(?='; // use positive lookahead + } + $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning + // iterate over string, separating it into placeholder and content + for (; $i < $len; $i++) { + if ($tag[$i] === '*') { + // placeholder found + $regex .= '[^ ]*?'; + } else { + // regular characters + $offset = strpos($tag, '*', $i); + if ($offset === false) { + // no placeholder found, set offset to end of string + $offset = $len; + } + // subtract one, as we want to get before the placeholder or end of string + $offset -= 1; + // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. + $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); + // move $i on + $i = $offset; + } + } + $regex .= '(?:$| ))'; // after the tag may only be a space or the end + return $regex; + } + + /** + * Returns the list of bookmarks associated with a given list of tags + * + * You can specify one or more tags, separated by space or a comma, e.g. + * print_r($mydb->filterTags('linux programming')); + * + * @param string $tags list of tags separated by commas or blank spaces. + * @param bool $casesensitive ignore case if false. + * @param string $visibility Optional: return only all/private/public bookmarks. + * + * @return array filtered bookmarks. + */ + public function filterTags($tags, $casesensitive = false, $visibility = 'all') + { + // get single tags (we may get passed an array, even though the docs say different) + $inputTags = $tags; + if (!is_array($tags)) { + // we got an input string, split tags + $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY); + } + + if (!count($inputTags)) { + // no input tags + return $this->noFilter($visibility); + } + + // If we only have public visibility, we can't look for hidden tags + if ($visibility === self::$PUBLIC) { + $inputTags = array_values(array_filter($inputTags, function ($tag) { + return ! startsWith($tag, '.'); + })); + + if (empty($inputTags)) { + return []; + } + } + + // build regex from all tags + $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; + if (!$casesensitive) { + // make regex case insensitive + $re .= 'i'; + } + + // create resulting array + $filtered = []; + + // iterate over each link + foreach ($this->bookmarks as $key => $link) { + // check level of visibility + // ignore non private bookmarks when 'privateonly' is on. + if ($visibility !== 'all') { + if (!$link->isPrivate() && $visibility === 'private') { + continue; + } elseif ($link->isPrivate() && $visibility === 'public') { + continue; + } + } + $search = $link->getTagsString(); // build search string, start with tags of current link + if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { + // description given and at least one possible tag found + $descTags = array(); + // find all tags in the form of #tag in the description + preg_match_all( + '/(?getDescription(), + $descTags + ); + if (count($descTags[1])) { + // there were some tags in the description, add them to the search string + $search .= ' ' . implode(' ', $descTags[1]); + } + }; + // match regular expression with search string + if (!preg_match($re, $search)) { + // this entry does _not_ match our regex + continue; + } + $filtered[$key] = $link; + } + return $filtered; + } + + /** + * Return only bookmarks without any tag. + * + * @param string $visibility return only all/private/public bookmarks. + * + * @return array filtered bookmarks. + */ + public function filterUntagged($visibility) + { + $filtered = []; + foreach ($this->bookmarks as $key => $link) { + if ($visibility !== 'all') { + if (!$link->isPrivate() && $visibility === 'private') { + continue; + } elseif ($link->isPrivate() && $visibility === 'public') { + continue; + } + } + + if (empty(trim($link->getTagsString()))) { + $filtered[$key] = $link; + } + } + + return $filtered; + } + + /** + * Returns the list of articles for a given day, chronologically sorted + * + * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. + * print_r($mydb->filterDay('20120125')); + * + * @param string $day day to filter. + * + * @return array all link matching given day. + * + * @throws Exception if date format is invalid. + */ + public function filterDay($day) + { + if (!checkDateFormat('Ymd', $day)) { + throw new Exception('Invalid date format'); + } + + $filtered = array(); + foreach ($this->bookmarks as $key => $l) { + if ($l->getCreated()->format('Ymd') == $day) { + $filtered[$key] = $l; + } + } + + // sort by date ASC + return array_reverse($filtered, true); + } + + /** + * Convert a list of tags (str) to an array. Also + * - handle case sensitivity. + * - accepts spaces commas as separator. + * + * @param string $tags string containing a list of tags. + * @param bool $casesensitive will convert everything to lowercase if false. + * + * @return array filtered tags string. + */ + public static function tagsStrToArray($tags, $casesensitive) + { + // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) + $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); + $tagsOut = str_replace(',', ' ', $tagsOut); + + return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); + } +} diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php new file mode 100644 index 00000000..ae9ffcb4 --- /dev/null +++ b/application/bookmark/BookmarkIO.php @@ -0,0 +1,108 @@ +'; + + /** + * LinksIO constructor. + * + * @param ConfigManager $conf instance + */ + public function __construct($conf) + { + $this->conf = $conf; + $this->datastore = $conf->get('resource.datastore'); + } + + /** + * Reads database from disk to memory + * + * @return BookmarkArray instance + * + * @throws NotWritableDataStoreException Data couldn't be loaded + * @throws EmptyDataStoreException Datastore doesn't exist + */ + public function read() + { + if (! file_exists($this->datastore)) { + throw new EmptyDataStoreException(); + } + + if (!is_writable($this->datastore)) { + throw new NotWritableDataStoreException($this->datastore); + } + + // Note that gzinflate is faster than gzuncompress. + // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 + $links = unserialize(gzinflate(base64_decode( + substr(file_get_contents($this->datastore), + strlen(self::$phpPrefix), -strlen(self::$phpSuffix))))); + + if (empty($links)) { + if (filesize($this->datastore) > 100) { + throw new NotWritableDataStoreException($this->datastore); + } + throw new EmptyDataStoreException(); + } + + return $links; + } + + /** + * Saves the database from memory to disk + * + * @param BookmarkArray $links instance. + * + * @throws NotWritableDataStoreException the datastore is not writable + */ + public function write($links) + { + if (is_file($this->datastore) && !is_writeable($this->datastore)) { + // The datastore exists but is not writeable + throw new NotWritableDataStoreException($this->datastore); + } else if (!is_file($this->datastore) && !is_writeable(dirname($this->datastore))) { + // The datastore does not exist and its parent directory is not writeable + throw new NotWritableDataStoreException(dirname($this->datastore)); + } + + file_put_contents( + $this->datastore, + self::$phpPrefix.base64_encode(gzdeflate(serialize($links))).self::$phpSuffix + ); + + invalidateCaches($this->conf->get('resource.page_cache')); + } +} diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php new file mode 100644 index 00000000..9eee9a35 --- /dev/null +++ b/application/bookmark/BookmarkInitializer.php @@ -0,0 +1,59 @@ +bookmarkService = $bookmarkService; + } + + /** + * Initialize the data store with default bookmarks + */ + public function initialize() + { + $bookmark = new Bookmark(); + $bookmark->setTitle(t('My secret stuff... - Pastebin.com')); + $bookmark->setUrl('http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', []); + $bookmark->setDescription(t('Shhhh! I\'m a private link only YOU can see. You can delete me too.')); + $bookmark->setTagsString('secretstuff'); + $bookmark->setPrivate(true); + $this->bookmarkService->add($bookmark); + + $bookmark = new Bookmark(); + $bookmark->setTitle(t('The personal, minimalist, super-fast, database free, bookmarking service')); + $bookmark->setUrl('https://shaarli.readthedocs.io', []); + $bookmark->setDescription(t( + 'Welcome to Shaarli! This is your first public bookmark. ' + . 'To edit or delete me, you must first login. + +To learn how to use Shaarli, consult the link "Documentation" at the bottom of this page. + +You use the community supported version of the original Shaarli project, by Sebastien Sauvage.' + )); + $bookmark->setTagsString('opensource software'); + $this->bookmarkService->add($bookmark); + } +} diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php new file mode 100644 index 00000000..7b7a4f09 --- /dev/null +++ b/application/bookmark/BookmarkServiceInterface.php @@ -0,0 +1,180 @@ + bookmarksCount + */ + public function bookmarksCountPerTag($filteringTags = [], $visibility = 'all'); + + /** + * Returns the list of days containing articles (oldest first) + * + * @return array containing days (in format YYYYMMDD). + */ + public function days(); + + /** + * Returns the list of articles for a given day. + * + * @param string $request day to filter. Format: YYYYMMDD. + * + * @return Bookmark[] list of shaare found. + * + * @throws BookmarkNotFoundException + */ + public function filterDay($request); + + /** + * Creates the default database after a fresh install. + */ + public function initialize(); +} diff --git a/application/bookmark/LinkUtils.php b/application/bookmark/LinkUtils.php index 77eb2d95..88379430 100644 --- a/application/bookmark/LinkUtils.php +++ b/application/bookmark/LinkUtils.php @@ -1,6 +1,6 @@ format(LinkDB::LINK_DATE_FORMAT) . $id); + return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id); } /** diff --git a/application/bookmark/exception/LinkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php similarity index 84% rename from application/bookmark/exception/LinkNotFoundException.php rename to 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; use Exception; -class LinkNotFoundException extends Exception +class BookmarkNotFoundException extends Exception { /** * LinkNotFoundException constructor. 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 @@ +getCreated() instanceof \DateTime) { + $created = $bookmark->getCreated()->format(\DateTime::ATOM); + } elseif (empty($bookmark->getCreated())) { + $created = ''; + } else { + $created = 'Not a DateTime object'; + } + $this->message = 'This bookmark is not valid'. PHP_EOL; + $this->message .= ' - ID: '. $bookmark->getId() . PHP_EOL; + $this->message .= ' - Title: '. $bookmark->getTitle() . PHP_EOL; + $this->message .= ' - Url: '. $bookmark->getUrl() . PHP_EOL; + $this->message .= ' - ShortUrl: '. $bookmark->getShortUrl() . PHP_EOL; + $this->message .= ' - Created: '. $created . PHP_EOL; + } else { + $this->message = 'The provided data is not a bookmark'. PHP_EOL; + $this->message .= var_export($bookmark, true); + } + } +} 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 @@ +message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '. + 'Your data might be corrupted, or your file isn\'t readable.'; + } +} diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php new file mode 100644 index 00000000..7550c556 --- /dev/null +++ b/application/formatter/BookmarkDefaultFormatter.php @@ -0,0 +1,81 @@ +getTitle()); + } + + /** + * @inheritdoc + */ + public function formatDescription($bookmark) + { + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + return format_description(escape($bookmark->getDescription()), $indexUrl); + } + + /** + * @inheritdoc + */ + protected function formatTagList($bookmark) + { + return escape($bookmark->getTags()); + } + + /** + * @inheritdoc + */ + public function formatTagString($bookmark) + { + return implode(' ', $this->formatTagList($bookmark)); + } + + /** + * @inheritdoc + */ + public function formatUrl($bookmark) + { + if (! empty($this->contextData['index_url']) && ( + startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') + )) { + return $this->contextData['index_url'] . escape($bookmark->getUrl()); + } + return escape($bookmark->getUrl()); + } + + /** + * @inheritdoc + */ + protected function formatRealUrl($bookmark) + { + if (! empty($this->contextData['index_url']) && ( + startsWith($bookmark->getUrl(), '?') || startsWith($bookmark->getUrl(), '/') + )) { + return $this->contextData['index_url'] . escape($bookmark->getUrl()); + } + return escape($bookmark->getUrl()); + } + + /** + * @inheritdoc + */ + protected function formatThumbnail($bookmark) + { + return escape($bookmark->getThumbnail()); + } +} diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php new file mode 100644 index 00000000..c82c3452 --- /dev/null +++ b/application/formatter/BookmarkFormatter.php @@ -0,0 +1,256 @@ +conf = $conf; + } + + /** + * Convert a Bookmark into an array usable by templates and plugins. + * + * All Bookmark attributes are formatted through a format method + * that can be overridden in a formatter extending this class. + * + * @param Bookmark $bookmark instance + * + * @return array formatted representation of a Bookmark + */ + public function format($bookmark) + { + $out['id'] = $this->formatId($bookmark); + $out['shorturl'] = $this->formatShortUrl($bookmark); + $out['url'] = $this->formatUrl($bookmark); + $out['real_url'] = $this->formatRealUrl($bookmark); + $out['title'] = $this->formatTitle($bookmark); + $out['description'] = $this->formatDescription($bookmark); + $out['thumbnail'] = $this->formatThumbnail($bookmark); + $out['taglist'] = $this->formatTagList($bookmark); + $out['tags'] = $this->formatTagString($bookmark); + $out['sticky'] = $bookmark->isSticky(); + $out['private'] = $bookmark->isPrivate(); + $out['class'] = $this->formatClass($bookmark); + $out['created'] = $this->formatCreated($bookmark); + $out['updated'] = $this->formatUpdated($bookmark); + $out['timestamp'] = $this->formatCreatedTimestamp($bookmark); + $out['updated_timestamp'] = $this->formatUpdatedTimestamp($bookmark); + return $out; + } + + /** + * Add additional data available to formatters. + * This is used for example to add `index_url` in description's links. + * + * @param string $key Context data key + * @param string $value Context data value + */ + public function addContextData($key, $value) + { + $this->contextData[$key] = $value; + } + + /** + * Format ID + * + * @param Bookmark $bookmark instance + * + * @return int formatted ID + */ + protected function formatId($bookmark) + { + return $bookmark->getId(); + } + + /** + * Format ShortUrl + * + * @param Bookmark $bookmark instance + * + * @return string formatted ShortUrl + */ + protected function formatShortUrl($bookmark) + { + return $bookmark->getShortUrl(); + } + + /** + * Format Url + * + * @param Bookmark $bookmark instance + * + * @return string formatted Url + */ + protected function formatUrl($bookmark) + { + return $bookmark->getUrl(); + } + + /** + * Format RealUrl + * Legacy: identical to Url + * + * @param Bookmark $bookmark instance + * + * @return string formatted RealUrl + */ + protected function formatRealUrl($bookmark) + { + return $bookmark->getUrl(); + } + + /** + * Format Title + * + * @param Bookmark $bookmark instance + * + * @return string formatted Title + */ + protected function formatTitle($bookmark) + { + return $bookmark->getTitle(); + } + + /** + * Format Description + * + * @param Bookmark $bookmark instance + * + * @return string formatted Description + */ + protected function formatDescription($bookmark) + { + return $bookmark->getDescription(); + } + + /** + * Format Thumbnail + * + * @param Bookmark $bookmark instance + * + * @return string formatted Thumbnail + */ + protected function formatThumbnail($bookmark) + { + return $bookmark->getThumbnail(); + } + + /** + * Format Tags + * + * @param Bookmark $bookmark instance + * + * @return array formatted Tags + */ + protected function formatTagList($bookmark) + { + return $bookmark->getTags(); + } + + /** + * Format TagString + * + * @param Bookmark $bookmark instance + * + * @return string formatted TagString + */ + protected function formatTagString($bookmark) + { + return implode(' ', $bookmark->getTags()); + } + + /** + * Format Class + * Used to add specific CSS class for a link + * + * @param Bookmark $bookmark instance + * + * @return string formatted Class + */ + protected function formatClass($bookmark) + { + return $bookmark->isPrivate() ? 'private' : ''; + } + + /** + * Format Created + * + * @param Bookmark $bookmark instance + * + * @return DateTime instance + */ + protected function formatCreated(Bookmark $bookmark) + { + return $bookmark->getCreated(); + } + + /** + * Format Updated + * + * @param Bookmark $bookmark instance + * + * @return DateTime instance + */ + protected function formatUpdated(Bookmark $bookmark) + { + return $bookmark->getUpdated(); + } + + /** + * Format CreatedTimestamp + * + * @param Bookmark $bookmark instance + * + * @return int formatted CreatedTimestamp + */ + protected function formatCreatedTimestamp(Bookmark $bookmark) + { + if (! empty($bookmark->getCreated())) { + return $bookmark->getCreated()->getTimestamp(); + } + return 0; + } + + /** + * Format UpdatedTimestamp + * + * @param Bookmark $bookmark instance + * + * @return int formatted UpdatedTimestamp + */ + protected function formatUpdatedTimestamp(Bookmark $bookmark) + { + if (! empty($bookmark->getUpdated())) { + return $bookmark->getUpdated()->getTimestamp(); + } + return 0; + } +} diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php new file mode 100644 index 00000000..f60c61f4 --- /dev/null +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -0,0 +1,198 @@ +parsedown = new \Parsedown(); + $this->escape = $conf->get('security.markdown_escape', true); + $this->allowedProtocols = $conf->get('security.allowed_protocols', []); + } + + /** + * @inheritdoc + */ + public function formatDescription($bookmark) + { + if (in_array(self::NO_MD_TAG, $bookmark->getTags())) { + return parent::formatDescription($bookmark); + } + + $processedDescription = $bookmark->getDescription(); + $processedDescription = $this->filterProtocols($processedDescription); + $processedDescription = $this->formatHashTags($processedDescription); + $processedDescription = $this->parsedown + ->setMarkupEscaped($this->escape) + ->setBreaksEnabled(true) + ->text($processedDescription); + $processedDescription = $this->sanitizeHtml($processedDescription); + + if (!empty($processedDescription)) { + $processedDescription = '
'. $processedDescription . '
'; + } + + return $processedDescription; + } + + /** + * Remove the NO markdown tag if it is present + * + * @inheritdoc + */ + protected function formatTagList($bookmark) + { + $out = parent::formatTagList($bookmark); + if (($pos = array_search(self::NO_MD_TAG, $out)) !== false) { + unset($out[$pos]); + return array_values($out); + } + return $out; + } + + /** + * Replace not whitelisted protocols with http:// in given description. + * Also adds `index_url` to relative links if it's specified + * + * @param string $description input description text. + * + * @return string $description without malicious link. + */ + protected function filterProtocols($description) + { + $allowedProtocols = $this->allowedProtocols; + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + + return preg_replace_callback( + '#]\((.*?)\)#is', + function ($match) use ($allowedProtocols, $indexUrl) { + $link = startsWith($match[1], '?') || startsWith($match[1], '/') ? $indexUrl : ''; + $link .= whitelist_protocols($match[1], $allowedProtocols); + return ']('. $link.')'; + }, + $description + ); + } + + /** + * Replace hashtag in Markdown links format + * E.g. `#hashtag` becomes `[#hashtag](?addtag=hashtag)` + * It includes the index URL if specified. + * + * @param string $description + * + * @return string + */ + protected function formatHashTags($description) + { + $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; + + /* + * To support unicode: http://stackoverflow.com/a/35498078/1484919 + * \p{Pc} - to match underscore + * \p{N} - numeric character in any script + * \p{L} - letter from any language + * \p{Mn} - any non marking space (accents, umlauts, etc) + */ + $regex = '/(^|\s)#([\p{Pc}\p{N}\p{L}\p{Mn}]+)/mui'; + $replacement = '$1[#$2]('. $indexUrl .'?addtag=$2)'; + + $descriptionLines = explode(PHP_EOL, $description); + $descriptionOut = ''; + $codeBlockOn = false; + $lineCount = 0; + + foreach ($descriptionLines as $descriptionLine) { + // Detect line of code: starting with 4 spaces, + // except lists which can start with +/*/- or `2.` after spaces. + $codeLineOn = preg_match('/^ +(?=[^\+\*\-])(?=(?!\d\.).)/', $descriptionLine) > 0; + // Detect and toggle block of code + if (!$codeBlockOn) { + $codeBlockOn = preg_match('/^```/', $descriptionLine) > 0; + } elseif (preg_match('/^```/', $descriptionLine) > 0) { + $codeBlockOn = false; + } + + if (!$codeBlockOn && !$codeLineOn) { + $descriptionLine = preg_replace($regex, $replacement, $descriptionLine); + } + + $descriptionOut .= $descriptionLine; + if ($lineCount++ < count($descriptionLines) - 1) { + $descriptionOut .= PHP_EOL; + } + } + + return $descriptionOut; + } + + /** + * Remove dangerous HTML tags (tags, iframe, etc.). + * Doesn't affect content (already escaped by Parsedown). + * + * @param string $description input description text. + * + * @return string given string escaped. + */ + protected function sanitizeHtml($description) + { + $escapeTags = array( + 'script', + 'style', + 'link', + 'iframe', + 'frameset', + 'frame', + ); + foreach ($escapeTags as $tag) { + $description = preg_replace_callback( + '#<\s*'. $tag .'[^>]*>(.*]*>)?#is', + function ($match) { + return escape($match[0]); + }, + $description + ); + } + $description = preg_replace( + '#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is', + '$1', + $description + ); + return $description; + } +} diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php new file mode 100644 index 00000000..bc372273 --- /dev/null +++ b/application/formatter/BookmarkRawFormatter.php @@ -0,0 +1,13 @@ +conf = $conf; + } + + /** + * Instanciate a BookmarkFormatter depending on the configuration or provided formatter type. + * + * @param string|null $type force a specific type regardless of the configuration + * + * @return BookmarkFormatter instance. + */ + public function getFormatter($type = null) + { + $type = $type ? $type : $this->conf->get('formatter', 'default'); + $className = '\\Shaarli\\Formatter\\Bookmark'. ucfirst($type) .'Formatter'; + if (!class_exists($className)) { + $className = '\\Shaarli\\Formatter\\BookmarkDefaultFormatter'; + } + + return new $className($this->conf); + } +} diff --git a/application/bookmark/LinkDB.php b/application/legacy/LegacyLinkDB.php similarity index 89% rename from application/bookmark/LinkDB.php rename to application/legacy/LegacyLinkDB.php index f01c7ee6..7ccf5e54 100644 --- a/application/bookmark/LinkDB.php +++ b/application/legacy/LegacyLinkDB.php @@ -1,17 +1,17 @@ offset. */ protected $ids; @@ -82,10 +84,10 @@ class LinkDB implements Iterator, Countable, ArrayAccess // Position in the $this->keys array (for the Iterator interface) private $position; - // Is the user logged in? (used to filter private links) + // Is the user logged in? (used to filter private bookmarks) private $loggedIn; - // Hide public links + // Hide public bookmarks private $hidePublicLinks; /** @@ -95,7 +97,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess * * @param string $datastore datastore file path. * @param boolean $isLoggedIn is the user logged in? - * @param boolean $hidePublicLinks if true all links are private. + * @param boolean $hidePublicLinks if true all bookmarks are private. */ public function __construct( $datastore, @@ -280,7 +282,7 @@ You use the community supported version of the original Shaarli project, by Seba */ private function read() { - // Public links are hidden and user not logged in => nothing to show + // Public bookmarks are hidden and user not logged in => nothing to show if ($this->hidePublicLinks && !$this->loggedIn) { $this->links = array(); return; @@ -310,7 +312,7 @@ You use the community supported version of the original Shaarli project, by Seba $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; - // To be able to load links before running the update, and prepare the update + // To be able to load bookmarks before running the update, and prepare the update if (!isset($link['created'])) { $link['id'] = $link['linkdate']; $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); @@ -375,13 +377,13 @@ You use the community supported version of the original Shaarli project, by Seba * * @return array $filtered array containing permalink data. * - * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link. + * @throws BookmarkNotFoundException if the smallhash is malformed or doesn't match any link. */ public function filterHash($request) { $request = substr($request, 0, 6); - $linkFilter = new LinkFilter($this->links); - return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); + $linkFilter = new LegacyLinkFilter($this->links); + return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request); } /** @@ -393,21 +395,21 @@ You use the community supported version of the original Shaarli project, by Seba */ public function filterDay($request) { - $linkFilter = new LinkFilter($this->links); - return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); + $linkFilter = new LegacyLinkFilter($this->links); + return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request); } /** - * Filter links according to search parameters. + * Filter bookmarks according to search parameters. * * @param array $filterRequest Search request content. Supported keys: * - searchtags: list of tags * - searchterm: term search * @param bool $casesensitive Optional: Perform case sensitive filter - * @param string $visibility return only all/private/public links - * @param bool $untaggedonly return only untagged links + * @param string $visibility return only all/private/public bookmarks + * @param bool $untaggedonly return only untagged bookmarks * - * @return array filtered links, all links if no suitable filter was provided. + * @return array filtered bookmarks, all bookmarks if no suitable filter was provided. */ public function filterSearch( $filterRequest = array(), @@ -420,19 +422,19 @@ You use the community supported version of the original Shaarli project, by Seba $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; - // Search tags + fullsearch - blank string parameter will return all links. - $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" + // Search tags + fullsearch - blank string parameter will return all bookmarks. + $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext" $request = [$searchtags, $searchterm]; - $linkFilter = new LinkFilter($this); + $linkFilter = new LegacyLinkFilter($this); return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); } /** - * Returns the list tags appearing in the links with the given tags + * Returns the list tags appearing in the bookmarks with the given tags * - * @param array $filteringTags tags selecting the links to consider - * @param string $visibility process only all/private/public links + * @param array $filteringTags tags selecting the bookmarks to consider + * @param string $visibility process only all/private/public bookmarks * * @return array tag => linksCount */ @@ -471,12 +473,12 @@ You use the community supported version of the original Shaarli project, by Seba } /** - * Rename or delete a tag across all links. + * Rename or delete a tag across all bookmarks. * * @param string $from Tag to rename * @param string $to New tag. If none is provided, the from tag will be deleted * - * @return array|bool List of altered links or false on error + * @return array|bool List of altered bookmarks or false on error */ public function renameTag($from, $to) { @@ -519,7 +521,7 @@ You use the community supported version of the original Shaarli project, by Seba } /** - * Reorder links by creation date (newest first). + * Reorder bookmarks by creation date (newest first). * * Also update the urls and ids mapping arrays. * @@ -562,7 +564,7 @@ You use the community supported version of the original Shaarli project, by Seba } /** - * Returns a link offset in links array from its unique ID. + * Returns a link offset in bookmarks array from its unique ID. * * @param int $id Persistent ID of a link. * diff --git a/application/bookmark/LinkFilter.php b/application/legacy/LegacyLinkFilter.php similarity index 96% rename from application/bookmark/LinkFilter.php rename to application/legacy/LegacyLinkFilter.php index 9b966307..7cf93d60 100644 --- a/application/bookmark/LinkFilter.php +++ b/application/legacy/LegacyLinkFilter.php @@ -1,16 +1,18 @@ links; } if (!empty($request[0])) { - $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); + $filtered = (new LegacyLinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); } if (!empty($request[1])) { - $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); + $filtered = (new LegacyLinkFilter($filtered))->filterFulltext($request[1], $visibility); } return $filtered; case self::$FILTER_TEXT: @@ -137,7 +139,7 @@ class LinkFilter * * @return array $filtered array containing permalink data. * - * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link. + * @throws BookmarkNotFoundException if the smallhash doesn't match any link. */ private function filterSmallHash($smallHash) { @@ -151,7 +153,7 @@ class LinkFilter } if (empty($filtered)) { - throw new LinkNotFoundException(); + throw new BookmarkNotFoundException(); } return $filtered; diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php new file mode 100644 index 00000000..3a5de79f --- /dev/null +++ b/application/legacy/LegacyUpdater.php @@ -0,0 +1,617 @@ +doneUpdates = $doneUpdates; + $this->linkDB = $linkDB; + $this->conf = $conf; + $this->isLoggedIn = $isLoggedIn; + $this->session = &$session; + + // Retrieve all update methods. + $class = new ReflectionClass($this); + $this->methods = $class->getMethods(); + } + + /** + * Run all new updates. + * Update methods have to start with 'updateMethod' and return true (on success). + * + * @return array An array containing ran updates. + * + * @throws UpdaterException If something went wrong. + */ + public function update() + { + $updatesRan = array(); + + // If the user isn't logged in, exit without updating. + if ($this->isLoggedIn !== true) { + return $updatesRan; + } + + if ($this->methods === null) { + throw new UpdaterException(t('Couldn\'t retrieve updater class methods.')); + } + + foreach ($this->methods as $method) { + // Not an update method or already done, pass. + if (!startsWith($method->getName(), 'updateMethod') + || in_array($method->getName(), $this->doneUpdates) + ) { + continue; + } + + try { + $method->setAccessible(true); + $res = $method->invoke($this); + // Update method must return true to be considered processed. + if ($res === true) { + $updatesRan[] = $method->getName(); + } + } catch (Exception $e) { + throw new UpdaterException($method, $e); + } + } + + $this->doneUpdates = array_merge($this->doneUpdates, $updatesRan); + + return $updatesRan; + } + + /** + * @return array Updates methods already processed. + */ + public function getDoneUpdates() + { + return $this->doneUpdates; + } + + /** + * Move deprecated options.php to config.php. + * + * Milestone 0.9 (old versioning) - shaarli/Shaarli#41: + * options.php is not supported anymore. + */ + public function updateMethodMergeDeprecatedConfigFile() + { + if (is_file($this->conf->get('resource.data_dir') . '/options.php')) { + include $this->conf->get('resource.data_dir') . '/options.php'; + + // Load GLOBALS into config + $allowedKeys = array_merge(ConfigPhp::$ROOT_KEYS); + $allowedKeys[] = 'config'; + foreach ($GLOBALS as $key => $value) { + if (in_array($key, $allowedKeys)) { + $this->conf->set($key, $value); + } + } + $this->conf->write($this->isLoggedIn); + unlink($this->conf->get('resource.data_dir') . '/options.php'); + } + + return true; + } + + /** + * Move old configuration in PHP to the new config system in JSON format. + * + * Will rename 'config.php' into 'config.save.php' and create 'config.json.php'. + * It will also convert legacy setting keys to the new ones. + */ + public function updateMethodConfigToJson() + { + // JSON config already exists, nothing to do. + if ($this->conf->getConfigIO() instanceof ConfigJson) { + return true; + } + + $configPhp = new ConfigPhp(); + $configJson = new ConfigJson(); + $oldConfig = $configPhp->read($this->conf->getConfigFile() . '.php'); + rename($this->conf->getConfigFileExt(), $this->conf->getConfigFile() . '.save.php'); + $this->conf->setConfigIO($configJson); + $this->conf->reload(); + + $legacyMap = array_flip(ConfigPhp::$LEGACY_KEYS_MAPPING); + foreach (ConfigPhp::$ROOT_KEYS as $key) { + $this->conf->set($legacyMap[$key], $oldConfig[$key]); + } + + // Set sub config keys (config and plugins) + $subConfig = array('config', 'plugins'); + foreach ($subConfig as $sub) { + foreach ($oldConfig[$sub] as $key => $value) { + if (isset($legacyMap[$sub . '.' . $key])) { + $configKey = $legacyMap[$sub . '.' . $key]; + } else { + $configKey = $sub . '.' . $key; + } + $this->conf->set($configKey, $value); + } + } + + try { + $this->conf->write($this->isLoggedIn); + return true; + } catch (IOException $e) { + error_log($e->getMessage()); + return false; + } + } + + /** + * Escape settings which have been manually escaped in every request in previous versions: + * - general.title + * - general.header_link + * - redirector.url + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodEscapeUnescapedConfig() + { + try { + $this->conf->set('general.title', escape($this->conf->get('general.title'))); + $this->conf->set('general.header_link', escape($this->conf->get('general.header_link'))); + $this->conf->write($this->isLoggedIn); + } catch (Exception $e) { + error_log($e->getMessage()); + return false; + } + return true; + } + + /** + * Update the database to use the new ID system, which replaces linkdate primary keys. + * Also, creation and update dates are now DateTime objects (done by LinkDB). + * + * Since this update is very sensitve (changing the whole database), the datastore will be + * automatically backed up into the file datastore..php. + * + * LinkDB also adds the field 'shorturl' with the precedent format (linkdate smallhash), + * which will be saved by this method. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodDatastoreIds() + { + $first = 'update'; + foreach ($this->linkDB as $key => $link) { + $first = $key; + break; + } + + // up to date database + if (is_int($first)) { + return true; + } + + $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '.php'; + copy($this->conf->get('resource.datastore'), $save); + + $links = array(); + foreach ($this->linkDB as $offset => $value) { + $links[] = $value; + unset($this->linkDB[$offset]); + } + $links = array_reverse($links); + $cpt = 0; + foreach ($links as $l) { + unset($l['linkdate']); + $l['id'] = $cpt; + $this->linkDB[$cpt++] = $l; + } + + $this->linkDB->save($this->conf->get('resource.page_cache')); + $this->linkDB->reorder(); + + return true; + } + + /** + * Rename tags starting with a '-' to work with tag exclusion search. + */ + public function updateMethodRenameDashTags() + { + $linklist = $this->linkDB->filterSearch(); + foreach ($linklist as $key => $link) { + $link['tags'] = preg_replace('/(^| )\-/', '$1', $link['tags']); + $link['tags'] = implode(' ', array_unique(BookmarkFilter::tagsStrToArray($link['tags'], true))); + $this->linkDB[$key] = $link; + } + $this->linkDB->save($this->conf->get('resource.page_cache')); + return true; + } + + /** + * Initialize API settings: + * - api.enabled: true + * - api.secret: generated secret + */ + public function updateMethodApiSettings() + { + if ($this->conf->exists('api.secret')) { + return true; + } + + $this->conf->set('api.enabled', true); + $this->conf->set( + 'api.secret', + generate_api_secret( + $this->conf->get('credentials.login'), + $this->conf->get('credentials.salt') + ) + ); + $this->conf->write($this->isLoggedIn); + return true; + } + + /** + * New setting: theme name. If the default theme is used, nothing to do. + * + * If the user uses a custom theme, raintpl_tpl dir is updated to the parent directory, + * and the current theme is set as default in the theme setting. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodDefaultTheme() + { + // raintpl_tpl isn't the root template directory anymore. + // We run the update only if this folder still contains the template files. + $tplDir = $this->conf->get('resource.raintpl_tpl'); + $tplFile = $tplDir . '/linklist.html'; + if (!file_exists($tplFile)) { + return true; + } + + $parent = dirname($tplDir); + $this->conf->set('resource.raintpl_tpl', $parent); + $this->conf->set('resource.theme', trim(str_replace($parent, '', $tplDir), '/')); + $this->conf->write($this->isLoggedIn); + + // Dependency injection gore + RainTPL::$tpl_dir = $tplDir; + + return true; + } + + /** + * Move the file to inc/user.css to data/user.css. + * + * Note: Due to hardcoded paths, it's not unit testable. But one line of code should be fine. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodMoveUserCss() + { + if (!is_file('inc/user.css')) { + return true; + } + + return rename('inc/user.css', 'data/user.css'); + } + + /** + * * `markdown_escape` is a new setting, set to true as default. + * + * If the markdown plugin was already enabled, escaping is disabled to avoid + * breaking existing entries. + */ + public function updateMethodEscapeMarkdown() + { + if ($this->conf->exists('security.markdown_escape')) { + return true; + } + + if (in_array('markdown', $this->conf->get('general.enabled_plugins'))) { + $this->conf->set('security.markdown_escape', false); + } else { + $this->conf->set('security.markdown_escape', true); + } + $this->conf->write($this->isLoggedIn); + + return true; + } + + /** + * Add 'http://' to Piwik URL the setting is set. + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodPiwikUrl() + { + if (!$this->conf->exists('plugins.PIWIK_URL') || startsWith($this->conf->get('plugins.PIWIK_URL'), 'http')) { + return true; + } + + $this->conf->set('plugins.PIWIK_URL', 'http://' . $this->conf->get('plugins.PIWIK_URL')); + $this->conf->write($this->isLoggedIn); + + return true; + } + + /** + * Use ATOM feed as default. + */ + public function updateMethodAtomDefault() + { + if (!$this->conf->exists('feed.show_atom') || $this->conf->get('feed.show_atom') === true) { + return true; + } + + $this->conf->set('feed.show_atom', true); + $this->conf->write($this->isLoggedIn); + + return true; + } + + /** + * Update updates.check_updates_branch setting. + * + * If the current major version digit matches the latest branch + * major version digit, we set the branch to `latest`, + * otherwise we'll check updates on the `stable` branch. + * + * No update required for the dev version. + * + * Note: due to hardcoded URL and lack of dependency injection, this is not unit testable. + * + * FIXME! This needs to be removed when we switch to first digit major version + * instead of the second one since the versionning process will change. + */ + public function updateMethodCheckUpdateRemoteBranch() + { + if (SHAARLI_VERSION === 'dev' || $this->conf->get('updates.check_updates_branch') === 'latest') { + return true; + } + + // Get latest branch major version digit + $latestVersion = ApplicationUtils::getLatestGitVersionCode( + 'https://raw.githubusercontent.com/shaarli/Shaarli/latest/shaarli_version.php', + 5 + ); + if (preg_match('/(\d+)\.\d+$/', $latestVersion, $matches) === false) { + return false; + } + $latestMajor = $matches[1]; + + // Get current major version digit + preg_match('/(\d+)\.\d+$/', SHAARLI_VERSION, $matches); + $currentMajor = $matches[1]; + + if ($currentMajor === $latestMajor) { + $branch = 'latest'; + } else { + $branch = 'stable'; + } + $this->conf->set('updates.check_updates_branch', $branch); + $this->conf->write($this->isLoggedIn); + return true; + } + + /** + * Reset history store file due to date format change. + */ + public function updateMethodResetHistoryFile() + { + if (is_file($this->conf->get('resource.history'))) { + unlink($this->conf->get('resource.history')); + } + return true; + } + + /** + * Save the datastore -> the link order is now applied when bookmarks are saved. + */ + public function updateMethodReorderDatastore() + { + $this->linkDB->save($this->conf->get('resource.page_cache')); + return true; + } + + /** + * Change privateonly session key to visibility. + */ + public function updateMethodVisibilitySession() + { + if (isset($_SESSION['privateonly'])) { + unset($_SESSION['privateonly']); + $_SESSION['visibility'] = 'private'; + } + return true; + } + + /** + * Add download size and timeout to the configuration file + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodDownloadSizeAndTimeoutConf() + { + if ($this->conf->exists('general.download_max_size') + && $this->conf->exists('general.download_timeout') + ) { + return true; + } + + if (!$this->conf->exists('general.download_max_size')) { + $this->conf->set('general.download_max_size', 1024 * 1024 * 4); + } + + if (!$this->conf->exists('general.download_timeout')) { + $this->conf->set('general.download_timeout', 30); + } + + $this->conf->write($this->isLoggedIn); + return true; + } + + /** + * * Move thumbnails management to WebThumbnailer, coming with new settings. + */ + public function updateMethodWebThumbnailer() + { + if ($this->conf->exists('thumbnails.mode')) { + return true; + } + + $thumbnailsEnabled = extension_loaded('gd') && $this->conf->get('thumbnail.enable_thumbnails', true); + $this->conf->set('thumbnails.mode', $thumbnailsEnabled ? Thumbnailer::MODE_ALL : Thumbnailer::MODE_NONE); + $this->conf->set('thumbnails.width', 125); + $this->conf->set('thumbnails.height', 90); + $this->conf->remove('thumbnail'); + $this->conf->write(true); + + if ($thumbnailsEnabled) { + $this->session['warnings'][] = t( + 'You have enabled or changed thumbnails mode. Please synchronize them.' + ); + } + + return true; + } + + /** + * Set sticky = false on all bookmarks + * + * @return bool true if the update is successful, false otherwise. + */ + public function updateMethodSetSticky() + { + foreach ($this->linkDB as $key => $link) { + if (isset($link['sticky'])) { + return true; + } + $link['sticky'] = false; + $this->linkDB[$key] = $link; + } + + $this->linkDB->save($this->conf->get('resource.page_cache')); + + return true; + } + + /** + * Remove redirector settings. + */ + public function updateMethodRemoveRedirector() + { + $this->conf->remove('redirector'); + $this->conf->write(true); + return true; + } + + /** + * Migrate the legacy arrays to Bookmark objects. + * Also make a backup of the datastore. + */ + public function updateMethodMigrateDatabase() + { + $save = $this->conf->get('resource.data_dir') . '/datastore.' . date('YmdHis') . '_1.php'; + if (! copy($this->conf->get('resource.datastore'), $save)) { + die('Could not backup the datastore.'); + } + + $linksArray = new BookmarkArray(); + foreach ($this->linkDB as $key => $link) { + $linksArray[$key] = (new Bookmark())->fromArray($link); + } + $linksIo = new BookmarkIO($this->conf); + $linksIo->write($linksArray); + + return true; + } + + /** + * Write the `formatter` setting in config file. + * Use markdown if the markdown plugin is enabled, the default one otherwise. + * Also remove markdown plugin setting as it is now integrated to the core. + */ + public function updateMethodFormatterSetting() + { + if (!$this->conf->exists('formatter') || $this->conf->get('formatter') === 'default') { + $enabledPlugins = $this->conf->get('general.enabled_plugins'); + if (($pos = array_search('markdown', $enabledPlugins)) !== false) { + $formatter = 'markdown'; + unset($enabledPlugins[$pos]); + $this->conf->set('general.enabled_plugins', array_values($enabledPlugins)); + } else { + $formatter = 'default'; + } + $this->conf->set('formatter', $formatter); + $this->conf->write(true); + } + + return true; + } +} diff --git a/composer.json b/composer.json index a028e99a..ada06a74 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,9 @@ "Shaarli\\Config\\Exception\\": "application/config/exception", "Shaarli\\Exceptions\\": "application/exceptions", "Shaarli\\Feed\\": "application/feed", + "Shaarli\\Formatter\\": "application/formatter", "Shaarli\\Http\\": "application/http", + "Shaarli\\Legacy\\": "application/legacy", "Shaarli\\Netscape\\": "application/netscape", "Shaarli\\Plugin\\": "application/plugin", "Shaarli\\Plugin\\Exception\\": "application/plugin/exception", -- 2.41.0