From 336a28fa4a09b968ce4705900bf57693e672f0bf Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 25 May 2019 15:46:47 +0200 Subject: 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 +++ application/bookmark/BookmarkServiceInterface.php | 180 +++++++ application/bookmark/LinkDB.php | 578 --------------------- application/bookmark/LinkFilter.php | 449 ---------------- application/bookmark/LinkUtils.php | 27 +- .../exception/BookmarkNotFoundException.php | 15 + .../bookmark/exception/EmptyDataStoreException.php | 7 + .../exception/InvalidBookmarkException.php | 30 ++ .../bookmark/exception/LinkNotFoundException.php | 15 - .../exception/NotWritableDataStoreException.php | 19 + 15 files changed, 1983 insertions(+), 1065 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 delete mode 100644 application/bookmark/LinkDB.php delete mode 100644 application/bookmark/LinkFilter.php create mode 100644 application/bookmark/exception/BookmarkNotFoundException.php create mode 100644 application/bookmark/exception/EmptyDataStoreException.php create mode 100644 application/bookmark/exception/InvalidBookmarkException.php delete mode 100644 application/bookmark/exception/LinkNotFoundException.php create mode 100644 application/bookmark/exception/NotWritableDataStoreException.php (limited to 'application/bookmark') 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/LinkDB.php b/application/bookmark/LinkDB.php deleted file mode 100644 index f01c7ee6..00000000 --- a/application/bookmark/LinkDB.php +++ /dev/null @@ -1,578 +0,0 @@ -link offset) - private $urls; - - /** - * @var array List of all links IDS mapped with their array offset. - * Map: id->offset. - */ - protected $ids; - - // List of offset keys (for the Iterator interface implementation) - private $keys; - - // Position in the $this->keys array (for the Iterator interface) - private $position; - - // Is the user logged in? (used to filter private links) - private $loggedIn; - - // Hide public links - private $hidePublicLinks; - - /** - * Creates a new LinkDB - * - * Checks if the datastore exists; else, attempts to create a dummy one. - * - * @param string $datastore datastore file path. - * @param boolean $isLoggedIn is the user logged in? - * @param boolean $hidePublicLinks if true all links are private. - */ - public function __construct( - $datastore, - $isLoggedIn, - $hidePublicLinks - ) { - - $this->datastore = $datastore; - $this->loggedIn = $isLoggedIn; - $this->hidePublicLinks = $hidePublicLinks; - $this->check(); - $this->read(); - } - - /** - * Countable - Counts elements of an object - */ - public function count() - { - return count($this->links); - } - - /** - * ArrayAccess - Assigns a value to the specified offset - */ - public function offsetSet($offset, $value) - { - // TODO: use exceptions instead of "die" - if (!$this->loggedIn) { - die(t('You are not authorized to add a link.')); - } - if (!isset($value['id']) || empty($value['url'])) { - die(t('Internal Error: A link should always have an id and URL.')); - } - if (($offset !== null && !is_int($offset)) || !is_int($value['id'])) { - die(t('You must specify an integer as a key.')); - } - if ($offset !== null && $offset !== $value['id']) { - die(t('Array offset and link ID must be equal.')); - } - - // If the link exists, we reuse the real offset, otherwise new entry - $existing = $this->getLinkOffset($offset); - if ($existing !== null) { - $offset = $existing; - } else { - $offset = count($this->links); - } - $this->links[$offset] = $value; - $this->urls[$value['url']] = $offset; - $this->ids[$value['id']] = $offset; - } - - /** - * ArrayAccess - Whether or not an offset exists - */ - public function offsetExists($offset) - { - return array_key_exists($this->getLinkOffset($offset), $this->links); - } - - /** - * ArrayAccess - Unsets an offset - */ - public function offsetUnset($offset) - { - if (!$this->loggedIn) { - // TODO: raise an exception - die('You are not authorized to delete a link.'); - } - $realOffset = $this->getLinkOffset($offset); - $url = $this->links[$realOffset]['url']; - unset($this->urls[$url]); - unset($this->ids[$realOffset]); - unset($this->links[$realOffset]); - } - - /** - * ArrayAccess - Returns the value at specified offset - */ - public function offsetGet($offset) - { - $realOffset = $this->getLinkOffset($offset); - return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null; - } - - /** - * Iterator - Returns the current element - */ - public function current() - { - return $this[$this->keys[$this->position]]; - } - - /** - * Iterator - Returns the key of the current element - */ - 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 - */ - public function valid() - { - return isset($this->keys[$this->position]); - } - - /** - * Checks if the DB directory and file exist - * - * If no DB file is found, creates a dummy DB. - */ - private function check() - { - if (file_exists($this->datastore)) { - return; - } - - // Create a dummy database for example - $this->links = array(); - $link = array( - 'id' => 1, - 'title' => t('The personal, minimalist, super-fast, database free, bookmarking service'), - 'url' => 'https://shaarli.readthedocs.io', - 'description' => 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.' - ), - 'private' => 0, - 'created' => new DateTime(), - 'tags' => 'opensource software', - 'sticky' => false, - ); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - $this->links[1] = $link; - - $link = array( - 'id' => 0, - 'title' => t('My secret stuff... - Pastebin.com'), - 'url' => 'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', - 'description' => t('Shhhh! I\'m a private link only YOU can see. You can delete me too.'), - 'private' => 1, - 'created' => new DateTime('1 minute ago'), - 'tags' => 'secretstuff', - 'sticky' => false, - ); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - $this->links[0] = $link; - - // Write database to disk - $this->write(); - } - - /** - * Reads database from disk to memory - */ - private function read() - { - // Public links are hidden and user not logged in => nothing to show - if ($this->hidePublicLinks && !$this->loggedIn) { - $this->links = array(); - return; - } - - $this->urls = []; - $this->ids = []; - $this->links = FileUtils::readFlatDB($this->datastore, []); - - $toremove = array(); - foreach ($this->links as $key => &$link) { - if (!$this->loggedIn && $link['private'] != 0) { - // Transition for not upgraded databases. - unset($this->links[$key]); - continue; - } - - // Sanitize data fields. - sanitizeLink($link); - - // Remove private tags if the user is not logged in. - if (!$this->loggedIn) { - $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']); - } - - $link['real_url'] = $link['url']; - - $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; - - // To be able to load links 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']); - if (!empty($link['updated'])) { - $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']); - } - $link['shorturl'] = smallHash($link['linkdate']); - } - - $this->urls[$link['url']] = $key; - $this->ids[$link['id']] = $key; - } - } - - /** - * Saves the database from memory to disk - * - * @throws IOException the datastore is not writable - */ - private function write() - { - $this->reorder(); - FileUtils::writeFlatDB($this->datastore, $this->links); - } - - /** - * Saves the database from memory to disk - * - * @param string $pageCacheDir page cache directory - */ - public function save($pageCacheDir) - { - if (!$this->loggedIn) { - // TODO: raise an Exception instead - die('You are not authorized to change the database.'); - } - - $this->write(); - - invalidateCaches($pageCacheDir); - } - - /** - * Returns the link for a given URL, or False if it does not exist. - * - * @param string $url URL to search for - * - * @return mixed the existing link if it exists, else 'false' - */ - public function getLinkFromUrl($url) - { - if (isset($this->urls[$url])) { - return $this->links[$this->urls[$url]]; - } - return false; - } - - /** - * Returns the shaare corresponding to a smallHash. - * - * @param string $request QUERY_STRING server parameter. - * - * @return array $filtered array containing permalink data. - * - * @throws LinkNotFoundException 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); - } - - /** - * Returns the list of articles for a given day. - * - * @param string $request day to filter. Format: YYYYMMDD. - * - * @return array list of shaare found. - */ - public function filterDay($request) - { - $linkFilter = new LinkFilter($this->links); - return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); - } - - /** - * Filter links 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 - * - * @return array filtered links, all links if no suitable filter was provided. - */ - public function filterSearch( - $filterRequest = array(), - $casesensitive = false, - $visibility = 'all', - $untaggedonly = false - ) { - - // Filter link database according to parameters. - $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" - $request = [$searchtags, $searchterm]; - - $linkFilter = new LinkFilter($this); - return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); - } - - /** - * Returns the list tags appearing in the links with the given tags - * - * @param array $filteringTags tags selecting the links to consider - * @param string $visibility process only all/private/public links - * - * @return array tag => linksCount - */ - public function linksCountPerTag($filteringTags = [], $visibility = 'all') - { - $links = $this->filterSearch(['searchtags' => $filteringTags], false, $visibility); - $tags = []; - $caseMapping = []; - foreach ($links as $link) { - foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) { - if (empty($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; - } - - /** - * Rename or delete a tag across all links. - * - * @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 - */ - public function renameTag($from, $to) - { - if (empty($from)) { - return false; - } - $delete = empty($to); - // True for case-sensitive tag search. - $linksToAlter = $this->filterSearch(['searchtags' => $from], true); - foreach ($linksToAlter as $key => &$value) { - $tags = preg_split('/\s+/', trim($value['tags'])); - if (($pos = array_search($from, $tags)) !== false) { - if ($delete) { - unset($tags[$pos]); // Remove tag. - } else { - $tags[$pos] = trim($to); - } - $value['tags'] = trim(implode(' ', array_unique($tags))); - $this[$value['id']] = $value; - } - } - - return $linksToAlter; - } - - /** - * Returns the list of days containing articles (oldest first) - * Output: An array containing days (in format YYYYMMDD). - */ - public function days() - { - $linkDays = array(); - foreach ($this->links as $link) { - $linkDays[$link['created']->format('Ymd')] = 0; - } - $linkDays = array_keys($linkDays); - sort($linkDays); - - return $linkDays; - } - - /** - * 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->links, function ($a, $b) use ($order) { - if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { - return $a['sticky'] ? -1 : 1; - } - if ($a['created'] == $b['created']) { - return $a['id'] < $b['id'] ? 1 * $order : -1 * $order; - } - return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; - }); - - $this->urls = []; - $this->ids = []; - foreach ($this->links as $key => $link) { - $this->urls[$link['url']] = $key; - $this->ids[$link['id']] = $key; - } - } - - /** - * Return the next key for link 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; - } - - /** - * Returns a link offset in links array from its unique ID. - * - * @param int $id Persistent ID of a link. - * - * @return int Real offset in local array, or null if doesn't exist. - */ - protected function getLinkOffset($id) - { - if (isset($this->ids[$id])) { - return $this->ids[$id]; - } - return null; - } -} diff --git a/application/bookmark/LinkFilter.php b/application/bookmark/LinkFilter.php deleted file mode 100644 index 9b966307..00000000 --- a/application/bookmark/LinkFilter.php +++ /dev/null @@ -1,449 +0,0 @@ -links = $links; - } - - /** - * Filter links 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 links - * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG - * - * @return array filtered link list. - */ - 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->links; - } - if (!empty($request[0])) { - $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); - } - if (!empty($request[1])) { - $filtered = (new LinkFilter($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 links - * - * @return array filtered links. - */ - private function noFilter($visibility = 'all') - { - if ($visibility === 'all') { - return $this->links; - } - - $out = array(); - foreach ($this->links as $key => $value) { - if ($value['private'] && $visibility === 'private') { - $out[$key] = $value; - } elseif (!$value['private'] && $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\LinkNotFoundException if the smallhash doesn't match any link. - */ - private function filterSmallHash($smallHash) - { - $filtered = array(); - foreach ($this->links as $key => $l) { - if ($smallHash == $l['shorturl']) { - // Yes, this is ugly and slow - $filtered[$key] = $l; - return $filtered; - } - } - - if (empty($filtered)) { - throw new LinkNotFoundException(); - } - - return $filtered; - } - - /** - * Returns the list of links 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 links. - * - * @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; - } - } - - $keys = array('title', 'description', 'url', 'tags'); - - // Iterate over every stored link. - foreach ($this->links as $id => $link) { - // ignore non private links when 'privatonly' is on. - if ($visibility !== 'all') { - if (!$link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - - // Concatenate link fields to search across fields. - // Adds a '\' separator for exact search terms. - $content = ''; - foreach ($keys as $key) { - $content .= mb_convert_case($link[$key], 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 links 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 links. - * - * @return array filtered links. - */ - 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); - } - - // build regex from all tags - $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/'; - if (!$casesensitive) { - // make regex case insensitive - $re .= 'i'; - } - - // create resulting array - $filtered = array(); - - // iterate over each link - foreach ($this->links as $key => $link) { - // check level of visibility - // ignore non private links when 'privateonly' is on. - if ($visibility !== 'all') { - if (!$link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - $search = $link['tags']; // build search string, start with tags of current link - if (strlen(trim($link['description'])) && strpos($link['description'], '#') !== 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( - '/(?links as $key => $link) { - if ($visibility !== 'all') { - if (!$link['private'] && $visibility === 'private') { - continue; - } elseif ($link['private'] && $visibility === 'public') { - continue; - } - } - - if (empty(trim($link['tags']))) { - $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->links as $key => $l) { - if ($l['created']->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/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/BookmarkNotFoundException.php b/application/bookmark/exception/BookmarkNotFoundException.php new file mode 100644 index 00000000..827a3d35 --- /dev/null +++ b/application/bookmark/exception/BookmarkNotFoundException.php @@ -0,0 +1,15 @@ +message = t('The link you are trying to reach does not exist or has been deleted.'); + } +} 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/LinkNotFoundException.php b/application/bookmark/exception/LinkNotFoundException.php deleted file mode 100644 index f9414428..00000000 --- a/application/bookmark/exception/LinkNotFoundException.php +++ /dev/null @@ -1,15 +0,0 @@ -message = t('The link you are trying to reach does not exist or has been deleted.'); - } -} 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.'; + } +} -- cgit v1.2.3