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 + application/formatter/BookmarkDefaultFormatter.php | 81 +++ application/formatter/BookmarkFormatter.php | 256 +++++++++ .../formatter/BookmarkMarkdownFormatter.php | 198 +++++++ application/formatter/BookmarkRawFormatter.php | 13 + application/formatter/FormatterFactory.php | 46 ++ application/legacy/LegacyLinkDB.php | 580 +++++++++++++++++++ application/legacy/LegacyLinkFilter.php | 451 +++++++++++++++ application/legacy/LegacyUpdater.php | 617 +++++++++++++++++++++ composer.json | 2 + 24 files changed, 4227 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 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 create mode 100644 application/legacy/LegacyLinkDB.php create mode 100644 application/legacy/LegacyLinkFilter.php 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/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.'; + } +} 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/legacy/LegacyLinkDB.php b/application/legacy/LegacyLinkDB.php new file mode 100644 index 00000000..7ccf5e54 --- /dev/null +++ b/application/legacy/LegacyLinkDB.php @@ -0,0 +1,580 @@ +link offset) + private $urls; + + /** + * @var array List of all bookmarks 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 bookmarks) + private $loggedIn; + + // Hide public bookmarks + 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 bookmarks 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 bookmarks 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 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']); + 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 BookmarkNotFoundException if the smallhash is malformed or doesn't match any link. + */ + public function filterHash($request) + { + $request = substr($request, 0, 6); + $linkFilter = new LegacyLinkFilter($this->links); + return $linkFilter->filter(LegacyLinkFilter::$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 LegacyLinkFilter($this->links); + return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request); + } + + /** + * 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 bookmarks + * @param bool $untaggedonly return only untagged bookmarks + * + * @return array filtered bookmarks, all bookmarks 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 bookmarks. + $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext" + $request = [$searchtags, $searchterm]; + + $linkFilter = new LegacyLinkFilter($this); + return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); + } + + /** + * Returns the list tags appearing in the bookmarks with the given tags + * + * @param array $filteringTags tags selecting the bookmarks to consider + * @param string $visibility process only all/private/public bookmarks + * + * @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 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 bookmarks 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 bookmarks 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 bookmarks 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/legacy/LegacyLinkFilter.php b/application/legacy/LegacyLinkFilter.php new file mode 100644 index 00000000..7cf93d60 --- /dev/null +++ b/application/legacy/LegacyLinkFilter.php @@ -0,0 +1,451 @@ +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 LegacyLinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility); + } + if (!empty($request[1])) { + $filtered = (new LegacyLinkFilter($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 BookmarkNotFoundException 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 BookmarkNotFoundException(); + } + + 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/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", -- cgit v1.2.3 From cf92b4dd1521241eefc58eaf6dcd202cd83969d8 Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Sat, 25 May 2019 15:52:27 +0200 Subject: Apply the new system (Bookmark + Service) to the whole code base See https://github.com/shaarli/Shaarli/issues/1307 --- application/History.php | 17 +- application/Utils.php | 2 +- application/api/ApiMiddleware.php | 11 +- application/api/ApiUtils.php | 79 ++- application/api/controllers/ApiController.php | 8 +- application/api/controllers/HistoryController.php | 2 +- application/api/controllers/Info.php | 5 +- application/api/controllers/Links.php | 79 ++- application/api/controllers/Tags.php | 44 +- application/bookmark/BookmarkArray.php | 2 +- application/config/ConfigManager.php | 2 + application/feed/FeedBuilder.php | 78 +-- .../formatter/BookmarkMarkdownFormatter.php | 6 + application/netscape/NetscapeBookmarkUtils.php | 117 ++--- application/render/PageBuilder.php | 24 +- application/updater/Updater.php | 480 +----------------- application/updater/UpdaterUtils.php | 65 +-- assets/common/css/markdown.css | 173 +++++++ doc/md/guides/various-hacks.md | 8 - index.php | 555 +++++++++++---------- plugins/markdown/README.md | 102 ---- plugins/markdown/help.html | 5 - plugins/markdown/markdown.css | 173 ------- plugins/markdown/markdown.meta | 4 - plugins/markdown/markdown.php | 365 -------------- tpl/default/configure.html | 22 + tpl/default/editlink.html | 12 +- tpl/default/includes.html | 3 + tpl/vintage/configure.html | 13 + tpl/vintage/editlink.html | 12 +- tpl/vintage/includes.html | 3 + webpack.config.js | 4 +- 32 files changed, 802 insertions(+), 1673 deletions(-) create mode 100644 assets/common/css/markdown.css delete mode 100644 plugins/markdown/README.md delete mode 100644 plugins/markdown/help.html delete mode 100644 plugins/markdown/markdown.css delete mode 100644 plugins/markdown/markdown.meta delete mode 100644 plugins/markdown/markdown.php diff --git a/application/History.php b/application/History.php index a5846652..4fd2f294 100644 --- a/application/History.php +++ b/application/History.php @@ -3,6 +3,7 @@ namespace Shaarli; use DateTime; use Exception; +use Shaarli\Bookmark\Bookmark; /** * Class History @@ -20,7 +21,7 @@ use Exception; * - UPDATED: link updated * - DELETED: link deleted * - SETTINGS: the settings have been updated through the UI. - * - IMPORT: bulk links import + * - IMPORT: bulk bookmarks import * * Note: new events are put at the beginning of the file and history array. */ @@ -96,31 +97,31 @@ class History /** * Add Event: new link. * - * @param array $link Link data. + * @param Bookmark $link Link data. */ public function addLink($link) { - $this->addEvent(self::CREATED, $link['id']); + $this->addEvent(self::CREATED, $link->getId()); } /** * Add Event: update existing link. * - * @param array $link Link data. + * @param Bookmark $link Link data. */ public function updateLink($link) { - $this->addEvent(self::UPDATED, $link['id']); + $this->addEvent(self::UPDATED, $link->getId()); } /** * Add Event: delete existing link. * - * @param array $link Link data. + * @param Bookmark $link Link data. */ public function deleteLink($link) { - $this->addEvent(self::DELETED, $link['id']); + $this->addEvent(self::DELETED, $link->getId()); } /** @@ -134,7 +135,7 @@ class History /** * Add Event: bulk import. * - * Note: we don't store links add/update one by one since it can have a huge impact on performances. + * Note: we don't store bookmarks add/update one by one since it can have a huge impact on performances. */ public function importLinks() { diff --git a/application/Utils.php b/application/Utils.php index 925e1a22..56f5b9a2 100644 --- a/application/Utils.php +++ b/application/Utils.php @@ -162,7 +162,7 @@ function generateLocation($referer, $host, $loopTerms = array()) $finalReferer = '?'; // No referer if it contains any value in $loopCriteria. - foreach ($loopTerms as $value) { + foreach (array_filter($loopTerms) as $value) { if (strpos($referer, $value) !== false) { return $finalReferer; } diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php index 2d55bda6..4745ac94 100644 --- a/application/api/ApiMiddleware.php +++ b/application/api/ApiMiddleware.php @@ -3,6 +3,7 @@ namespace Shaarli\Api; use Shaarli\Api\Exceptions\ApiAuthorizationException; use Shaarli\Api\Exceptions\ApiException; +use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Config\ConfigManager; use Slim\Container; use Slim\Http\Request; @@ -117,7 +118,7 @@ class ApiMiddleware } /** - * Instantiate a new LinkDB including private links, + * Instantiate a new LinkDB including private bookmarks, * and load in the Slim container. * * FIXME! LinkDB could use a refactoring to avoid this trick. @@ -126,10 +127,10 @@ class ApiMiddleware */ protected function setLinkDb($conf) { - $linkDb = new \Shaarli\Bookmark\LinkDB( - $conf->get('resource.datastore'), - true, - $conf->get('privacy.hide_public_links') + $linkDb = new BookmarkFileService( + $conf, + $this->container->get('history'), + true ); $this->container['db'] = $linkDb; } diff --git a/application/api/ApiUtils.php b/application/api/ApiUtils.php index 5ac07c4d..5156a5f7 100644 --- a/application/api/ApiUtils.php +++ b/application/api/ApiUtils.php @@ -2,6 +2,7 @@ namespace Shaarli\Api; use Shaarli\Api\Exceptions\ApiAuthorizationException; +use Shaarli\Bookmark\Bookmark; use Shaarli\Http\Base64Url; /** @@ -54,28 +55,28 @@ class ApiUtils /** * Format a Link for the REST API. * - * @param array $link Link data read from the datastore. - * @param string $indexUrl Shaarli's index URL (used for relative URL). + * @param Bookmark $bookmark Bookmark data read from the datastore. + * @param string $indexUrl Shaarli's index URL (used for relative URL). * * @return array Link data formatted for the REST API. */ - public static function formatLink($link, $indexUrl) + public static function formatLink($bookmark, $indexUrl) { - $out['id'] = $link['id']; + $out['id'] = $bookmark->getId(); // Not an internal link - if (! is_note($link['url'])) { - $out['url'] = $link['url']; + if (! $bookmark->isNote()) { + $out['url'] = $bookmark->getUrl(); } else { - $out['url'] = $indexUrl . $link['url']; + $out['url'] = $indexUrl . $bookmark->getUrl(); } - $out['shorturl'] = $link['shorturl']; - $out['title'] = $link['title']; - $out['description'] = $link['description']; - $out['tags'] = preg_split('/\s+/', $link['tags'], -1, PREG_SPLIT_NO_EMPTY); - $out['private'] = $link['private'] == true; - $out['created'] = $link['created']->format(\DateTime::ATOM); - if (! empty($link['updated'])) { - $out['updated'] = $link['updated']->format(\DateTime::ATOM); + $out['shorturl'] = $bookmark->getShortUrl(); + $out['title'] = $bookmark->getTitle(); + $out['description'] = $bookmark->getDescription(); + $out['tags'] = $bookmark->getTags(); + $out['private'] = $bookmark->isPrivate(); + $out['created'] = $bookmark->getCreated()->format(\DateTime::ATOM); + if (! empty($bookmark->getUpdated())) { + $out['updated'] = $bookmark->getUpdated()->format(\DateTime::ATOM); } else { $out['updated'] = ''; } @@ -83,7 +84,7 @@ class ApiUtils } /** - * Convert a link given through a request, to a valid link for LinkDB. + * Convert a link given through a request, to a valid Bookmark for the datastore. * * If no URL is provided, it will generate a local note URL. * If no title is provided, it will use the URL as title. @@ -91,50 +92,42 @@ class ApiUtils * @param array $input Request Link. * @param bool $defaultPrivate Request Link. * - * @return array Formatted link. + * @return Bookmark instance. */ public static function buildLinkFromRequest($input, $defaultPrivate) { - $input['url'] = ! empty($input['url']) ? cleanup_url($input['url']) : ''; + $bookmark = new Bookmark(); + $url = ! empty($input['url']) ? cleanup_url($input['url']) : ''; if (isset($input['private'])) { $private = filter_var($input['private'], FILTER_VALIDATE_BOOLEAN); } else { $private = $defaultPrivate; } - $link = [ - 'title' => ! empty($input['title']) ? $input['title'] : $input['url'], - 'url' => $input['url'], - 'description' => ! empty($input['description']) ? $input['description'] : '', - 'tags' => ! empty($input['tags']) ? implode(' ', $input['tags']) : '', - 'private' => $private, - 'created' => new \DateTime(), - ]; - return $link; + $bookmark->setTitle(! empty($input['title']) ? $input['title'] : ''); + $bookmark->setUrl($url); + $bookmark->setDescription(! empty($input['description']) ? $input['description'] : ''); + $bookmark->setTags(! empty($input['tags']) ? $input['tags'] : []); + $bookmark->setPrivate($private); + + return $bookmark; } /** * Update link fields using an updated link object. * - * @param array $oldLink data - * @param array $newLink data + * @param Bookmark $oldLink data + * @param Bookmark $newLink data * - * @return array $oldLink updated with $newLink values + * @return Bookmark $oldLink updated with $newLink values */ public static function updateLink($oldLink, $newLink) { - foreach (['title', 'url', 'description', 'tags', 'private'] as $field) { - $oldLink[$field] = $newLink[$field]; - } - $oldLink['updated'] = new \DateTime(); - - if (empty($oldLink['url'])) { - $oldLink['url'] = '?' . $oldLink['shorturl']; - } - - if (empty($oldLink['title'])) { - $oldLink['title'] = $oldLink['url']; - } + $oldLink->setTitle($newLink->getTitle()); + $oldLink->setUrl($newLink->getUrl()); + $oldLink->setDescription($newLink->getDescription()); + $oldLink->setTags($newLink->getTags()); + $oldLink->setPrivate($newLink->isPrivate()); return $oldLink; } @@ -143,7 +136,7 @@ class ApiUtils * Format a Tag for the REST API. * * @param string $tag Tag name - * @param int $occurrences Number of links using this tag + * @param int $occurrences Number of bookmarks using this tag * * @return array Link data formatted for the REST API. */ diff --git a/application/api/controllers/ApiController.php b/application/api/controllers/ApiController.php index a6e7cbab..c4b3d0c3 100644 --- a/application/api/controllers/ApiController.php +++ b/application/api/controllers/ApiController.php @@ -2,7 +2,7 @@ namespace Shaarli\Api\Controllers; -use Shaarli\Bookmark\LinkDB; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Slim\Container; @@ -26,9 +26,9 @@ abstract class ApiController protected $conf; /** - * @var LinkDB + * @var BookmarkServiceInterface */ - protected $linkDb; + protected $bookmarkService; /** * @var HistoryController @@ -51,7 +51,7 @@ abstract class ApiController { $this->ci = $ci; $this->conf = $ci->get('conf'); - $this->linkDb = $ci->get('db'); + $this->bookmarkService = $ci->get('db'); $this->history = $ci->get('history'); if ($this->conf->get('dev.debug', false)) { $this->jsonStyle = JSON_PRETTY_PRINT; diff --git a/application/api/controllers/HistoryController.php b/application/api/controllers/HistoryController.php index 9afcfa26..505647a9 100644 --- a/application/api/controllers/HistoryController.php +++ b/application/api/controllers/HistoryController.php @@ -41,7 +41,7 @@ class HistoryController extends ApiController throw new ApiBadParametersException('Invalid offset'); } - // limit parameter is either a number of links or 'all' for everything. + // limit parameter is either a number of bookmarks or 'all' for everything. $limit = $request->getParam('limit'); if (empty($limit)) { $limit = count($history); diff --git a/application/api/controllers/Info.php b/application/api/controllers/Info.php index f37dcae5..12f6b2f0 100644 --- a/application/api/controllers/Info.php +++ b/application/api/controllers/Info.php @@ -2,6 +2,7 @@ namespace Shaarli\Api\Controllers; +use Shaarli\Bookmark\BookmarkFilter; use Slim\Http\Request; use Slim\Http\Response; @@ -26,8 +27,8 @@ class Info extends ApiController public function getInfo($request, $response) { $info = [ - 'global_counter' => count($this->linkDb), - 'private_counter' => count_private($this->linkDb), + 'global_counter' => $this->bookmarkService->count(), + 'private_counter' => $this->bookmarkService->count(BookmarkFilter::$PRIVATE), 'settings' => array( 'title' => $this->conf->get('general.title', 'Shaarli'), 'header_link' => $this->conf->get('general.header_link', '?'), diff --git a/application/api/controllers/Links.php b/application/api/controllers/Links.php index ffcfd4c7..29247950 100644 --- a/application/api/controllers/Links.php +++ b/application/api/controllers/Links.php @@ -11,7 +11,7 @@ use Slim\Http\Response; /** * Class Links * - * REST API Controller: all services related to links collection. + * REST API Controller: all services related to bookmarks collection. * * @package Api\Controllers * @see http://shaarli.github.io/api-documentation/#links-links-collection @@ -19,12 +19,12 @@ use Slim\Http\Response; class Links extends ApiController { /** - * @var int Number of links returned if no limit is provided. + * @var int Number of bookmarks returned if no limit is provided. */ public static $DEFAULT_LIMIT = 20; /** - * Retrieve a list of links, allowing different filters. + * Retrieve a list of bookmarks, allowing different filters. * * @param Request $request Slim request. * @param Response $response Slim response. @@ -36,33 +36,32 @@ class Links extends ApiController public function getLinks($request, $response) { $private = $request->getParam('visibility'); - $links = $this->linkDb->filterSearch( + $bookmarks = $this->bookmarkService->search( [ 'searchtags' => $request->getParam('searchtags', ''), 'searchterm' => $request->getParam('searchterm', ''), ], - false, $private ); - // Return links from the {offset}th link, starting from 0. + // Return bookmarks from the {offset}th link, starting from 0. $offset = $request->getParam('offset'); if (! empty($offset) && ! ctype_digit($offset)) { throw new ApiBadParametersException('Invalid offset'); } $offset = ! empty($offset) ? intval($offset) : 0; - if ($offset > count($links)) { + if ($offset > count($bookmarks)) { return $response->withJson([], 200, $this->jsonStyle); } - // limit parameter is either a number of links or 'all' for everything. + // limit parameter is either a number of bookmarks or 'all' for everything. $limit = $request->getParam('limit'); if (empty($limit)) { $limit = self::$DEFAULT_LIMIT; } elseif (ctype_digit($limit)) { $limit = intval($limit); } elseif ($limit === 'all') { - $limit = count($links); + $limit = count($bookmarks); } else { throw new ApiBadParametersException('Invalid limit'); } @@ -72,12 +71,12 @@ class Links extends ApiController $out = []; $index = 0; - foreach ($links as $link) { + foreach ($bookmarks as $bookmark) { if (count($out) >= $limit) { break; } if ($index++ >= $offset) { - $out[] = ApiUtils::formatLink($link, $indexUrl); + $out[] = ApiUtils::formatLink($bookmark, $indexUrl); } } @@ -97,11 +96,11 @@ class Links extends ApiController */ public function getLink($request, $response, $args) { - if (!isset($this->linkDb[$args['id']])) { + if (!$this->bookmarkService->exists($args['id'])) { throw new ApiLinkNotFoundException(); } $index = index_url($this->ci['environment']); - $out = ApiUtils::formatLink($this->linkDb[$args['id']], $index); + $out = ApiUtils::formatLink($this->bookmarkService->get($args['id']), $index); return $response->withJson($out, 200, $this->jsonStyle); } @@ -117,9 +116,11 @@ class Links extends ApiController public function postLink($request, $response) { $data = $request->getParsedBody(); - $link = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); + $bookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); // duplicate by URL, return 409 Conflict - if (! empty($link['url']) && ! empty($dup = $this->linkDb->getLinkFromUrl($link['url']))) { + if (! empty($bookmark->getUrl()) + && ! empty($dup = $this->bookmarkService->findByUrl($bookmark->getUrl())) + ) { return $response->withJson( ApiUtils::formatLink($dup, index_url($this->ci['environment'])), 409, @@ -127,23 +128,9 @@ class Links extends ApiController ); } - $link['id'] = $this->linkDb->getNextId(); - $link['shorturl'] = link_small_hash($link['created'], $link['id']); - - // note: general relative URL - if (empty($link['url'])) { - $link['url'] = '?' . $link['shorturl']; - } - - if (empty($link['title'])) { - $link['title'] = $link['url']; - } - - $this->linkDb[$link['id']] = $link; - $this->linkDb->save($this->conf->get('resource.page_cache')); - $this->history->addLink($link); - $out = ApiUtils::formatLink($link, index_url($this->ci['environment'])); - $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $link['id']]); + $this->bookmarkService->add($bookmark); + $out = ApiUtils::formatLink($bookmark, index_url($this->ci['environment'])); + $redirect = $this->ci->router->relativePathFor('getLink', ['id' => $bookmark->getId()]); return $response->withAddedHeader('Location', $redirect) ->withJson($out, 201, $this->jsonStyle); } @@ -161,18 +148,18 @@ class Links extends ApiController */ public function putLink($request, $response, $args) { - if (! isset($this->linkDb[$args['id']])) { + if (! $this->bookmarkService->exists($args['id'])) { throw new ApiLinkNotFoundException(); } $index = index_url($this->ci['environment']); $data = $request->getParsedBody(); - $requestLink = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); + $requestBookmark = ApiUtils::buildLinkFromRequest($data, $this->conf->get('privacy.default_private_links')); // duplicate URL on a different link, return 409 Conflict - if (! empty($requestLink['url']) - && ! empty($dup = $this->linkDb->getLinkFromUrl($requestLink['url'])) - && $dup['id'] != $args['id'] + if (! empty($requestBookmark->getUrl()) + && ! empty($dup = $this->bookmarkService->findByUrl($requestBookmark->getUrl())) + && $dup->getId() != $args['id'] ) { return $response->withJson( ApiUtils::formatLink($dup, $index), @@ -181,13 +168,11 @@ class Links extends ApiController ); } - $responseLink = $this->linkDb[$args['id']]; - $responseLink = ApiUtils::updateLink($responseLink, $requestLink); - $this->linkDb[$responseLink['id']] = $responseLink; - $this->linkDb->save($this->conf->get('resource.page_cache')); - $this->history->updateLink($responseLink); + $responseBookmark = $this->bookmarkService->get($args['id']); + $responseBookmark = ApiUtils::updateLink($responseBookmark, $requestBookmark); + $this->bookmarkService->set($responseBookmark); - $out = ApiUtils::formatLink($responseLink, $index); + $out = ApiUtils::formatLink($responseBookmark, $index); return $response->withJson($out, 200, $this->jsonStyle); } @@ -204,13 +189,11 @@ class Links extends ApiController */ public function deleteLink($request, $response, $args) { - if (! isset($this->linkDb[$args['id']])) { + if (! $this->bookmarkService->exists($args['id'])) { throw new ApiLinkNotFoundException(); } - $link = $this->linkDb[$args['id']]; - unset($this->linkDb[(int) $args['id']]); - $this->linkDb->save($this->conf->get('resource.page_cache')); - $this->history->deleteLink($link); + $bookmark = $this->bookmarkService->get($args['id']); + $this->bookmarkService->remove($bookmark); return $response->withStatus(204); } diff --git a/application/api/controllers/Tags.php b/application/api/controllers/Tags.php index 82f3ef74..e60e00a7 100644 --- a/application/api/controllers/Tags.php +++ b/application/api/controllers/Tags.php @@ -5,6 +5,7 @@ namespace Shaarli\Api\Controllers; use Shaarli\Api\ApiUtils; use Shaarli\Api\Exceptions\ApiBadParametersException; use Shaarli\Api\Exceptions\ApiTagNotFoundException; +use Shaarli\Bookmark\BookmarkFilter; use Slim\Http\Request; use Slim\Http\Response; @@ -18,7 +19,7 @@ use Slim\Http\Response; class Tags extends ApiController { /** - * @var int Number of links returned if no limit is provided. + * @var int Number of bookmarks returned if no limit is provided. */ public static $DEFAULT_LIMIT = 'all'; @@ -35,7 +36,7 @@ class Tags extends ApiController public function getTags($request, $response) { $visibility = $request->getParam('visibility'); - $tags = $this->linkDb->linksCountPerTag([], $visibility); + $tags = $this->bookmarkService->bookmarksCountPerTag([], $visibility); // Return tags from the {offset}th tag, starting from 0. $offset = $request->getParam('offset'); @@ -47,7 +48,7 @@ class Tags extends ApiController return $response->withJson([], 200, $this->jsonStyle); } - // limit parameter is either a number of links or 'all' for everything. + // limit parameter is either a number of bookmarks or 'all' for everything. $limit = $request->getParam('limit'); if (empty($limit)) { $limit = self::$DEFAULT_LIMIT; @@ -87,7 +88,7 @@ class Tags extends ApiController */ public function getTag($request, $response, $args) { - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); if (!isset($tags[$args['tagName']])) { throw new ApiTagNotFoundException(); } @@ -111,7 +112,7 @@ class Tags extends ApiController */ public function putTag($request, $response, $args) { - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); if (! isset($tags[$args['tagName']])) { throw new ApiTagNotFoundException(); } @@ -121,13 +122,19 @@ class Tags extends ApiController throw new ApiBadParametersException('New tag name is required in the request body'); } - $updated = $this->linkDb->renameTag($args['tagName'], $data['name']); - $this->linkDb->save($this->conf->get('resource.page_cache')); - foreach ($updated as $link) { - $this->history->updateLink($link); + $bookmarks = $this->bookmarkService->search( + ['searchtags' => $args['tagName']], + BookmarkFilter::$ALL, + true + ); + foreach ($bookmarks as $bookmark) { + $bookmark->renameTag($args['tagName'], $data['name']); + $this->bookmarkService->set($bookmark, false); + $this->history->updateLink($bookmark); } + $this->bookmarkService->save(); - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); $out = ApiUtils::formatTag($data['name'], $tags[$data['name']]); return $response->withJson($out, 200, $this->jsonStyle); } @@ -145,15 +152,22 @@ class Tags extends ApiController */ public function deleteTag($request, $response, $args) { - $tags = $this->linkDb->linksCountPerTag(); + $tags = $this->bookmarkService->bookmarksCountPerTag(); if (! isset($tags[$args['tagName']])) { throw new ApiTagNotFoundException(); } - $updated = $this->linkDb->renameTag($args['tagName'], null); - $this->linkDb->save($this->conf->get('resource.page_cache')); - foreach ($updated as $link) { - $this->history->updateLink($link); + + $bookmarks = $this->bookmarkService->search( + ['searchtags' => $args['tagName']], + BookmarkFilter::$ALL, + true + ); + foreach ($bookmarks as $bookmark) { + $bookmark->deleteTag($args['tagName']); + $this->bookmarkService->set($bookmark, false); + $this->history->updateLink($bookmark); } + $this->bookmarkService->save(); return $response->withStatus(204); } diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php index b427c91a..d87d43b4 100644 --- a/application/bookmark/BookmarkArray.php +++ b/application/bookmark/BookmarkArray.php @@ -118,7 +118,7 @@ class BookmarkArray implements \Iterator, \Countable, \ArrayAccess $realOffset = $this->getBookmarkOffset($offset); $url = $this->bookmarks[$realOffset]->getUrl(); unset($this->urls[$url]); - unset($this->ids[$realOffset]); + unset($this->ids[$offset]); unset($this->bookmarks[$realOffset]); } diff --git a/application/config/ConfigManager.php b/application/config/ConfigManager.php index c95e6800..e45bb4c3 100644 --- a/application/config/ConfigManager.php +++ b/application/config/ConfigManager.php @@ -389,6 +389,8 @@ class ConfigManager $this->setEmpty('translation.extensions', []); $this->setEmpty('plugins', array()); + + $this->setEmpty('formatter', 'markdown'); } /** diff --git a/application/feed/FeedBuilder.php b/application/feed/FeedBuilder.php index 957c8273..40bd4f15 100644 --- a/application/feed/FeedBuilder.php +++ b/application/feed/FeedBuilder.php @@ -2,6 +2,9 @@ namespace Shaarli\Feed; use DateTime; +use Shaarli\Bookmark\Bookmark; +use Shaarli\Bookmark\BookmarkServiceInterface; +use Shaarli\Formatter\BookmarkFormatter; /** * FeedBuilder class. @@ -26,15 +29,20 @@ class FeedBuilder public static $DEFAULT_LANGUAGE = 'en-en'; /** - * @var int Number of links to display in a feed by default. + * @var int Number of bookmarks to display in a feed by default. */ public static $DEFAULT_NB_LINKS = 50; /** - * @var \Shaarli\Bookmark\LinkDB instance. + * @var BookmarkServiceInterface instance. */ protected $linkDB; + /** + * @var BookmarkFormatter instance. + */ + protected $formatter; + /** * @var string RSS or ATOM feed. */ @@ -56,7 +64,7 @@ class FeedBuilder protected $isLoggedIn; /** - * @var boolean Use permalinks instead of direct links if true. + * @var boolean Use permalinks instead of direct bookmarks if true. */ protected $usePermalinks; @@ -78,16 +86,17 @@ class FeedBuilder /** * Feed constructor. * - * @param \Shaarli\Bookmark\LinkDB $linkDB LinkDB instance. + * @param BookmarkServiceInterface $linkDB LinkDB instance. + * @param BookmarkFormatter $formatter instance. * @param string $feedType Type of feed. * @param array $serverInfo $_SERVER. * @param array $userInput $_GET. - * @param boolean $isLoggedIn True if the user is currently logged in, - * false otherwise. + * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. */ - public function __construct($linkDB, $feedType, $serverInfo, $userInput, $isLoggedIn) + public function __construct($linkDB, $formatter, $feedType, $serverInfo, $userInput, $isLoggedIn) { $this->linkDB = $linkDB; + $this->formatter = $formatter; $this->feedType = $feedType; $this->serverInfo = $serverInfo; $this->userInput = $userInput; @@ -101,13 +110,13 @@ class FeedBuilder */ public function buildData() { - // Search for untagged links + // Search for untagged bookmarks if (isset($this->userInput['searchtags']) && empty($this->userInput['searchtags'])) { $this->userInput['searchtags'] = false; } // Optionally filter the results: - $linksToDisplay = $this->linkDB->filterSearch($this->userInput); + $linksToDisplay = $this->linkDB->search($this->userInput); $nblinksToDisplay = $this->getNbLinks(count($linksToDisplay)); @@ -118,6 +127,7 @@ class FeedBuilder } $pageaddr = escape(index_url($this->serverInfo)); + $this->formatter->addContextData('index_url', $pageaddr); $linkDisplayed = array(); for ($i = 0; $i < $nblinksToDisplay && $i < count($keys); $i++) { $linkDisplayed[$keys[$i]] = $this->buildItem($linksToDisplay[$keys[$i]], $pageaddr); @@ -139,54 +149,44 @@ class FeedBuilder /** * Build a feed item (one per shaare). * - * @param array $link Single link array extracted from LinkDB. - * @param string $pageaddr Index URL. + * @param Bookmark $link Single link array extracted from LinkDB. + * @param string $pageaddr Index URL. * * @return array Link array with feed attributes. */ protected function buildItem($link, $pageaddr) { - $link['guid'] = $pageaddr . '?' . $link['shorturl']; - // Prepend the root URL for notes - if (is_note($link['url'])) { - $link['url'] = $pageaddr . $link['url']; - } + $data = $this->formatter->format($link); + $data['guid'] = $pageaddr . '?' . $data['shorturl']; if ($this->usePermalinks === true) { - $permalink = '' . t('Direct link') . ''; + $permalink = ''. t('Direct link') .''; } else { - $permalink = '' . t('Permalink') . ''; + $permalink = ''. t('Permalink') .''; } - $link['description'] = format_description($link['description'], $pageaddr); - $link['description'] .= PHP_EOL . PHP_EOL . '
— ' . $permalink; + $data['description'] .= PHP_EOL . PHP_EOL . '
— ' . $permalink; - $pubDate = $link['created']; - $link['pub_iso_date'] = $this->getIsoDate($pubDate); + $data['pub_iso_date'] = $this->getIsoDate($data['created']); // atom:entry elements MUST contain exactly one atom:updated element. - if (!empty($link['updated'])) { - $upDate = $link['updated']; - $link['up_iso_date'] = $this->getIsoDate($upDate, DateTime::ATOM); + if (!empty($link->getUpdated())) { + $data['up_iso_date'] = $this->getIsoDate($data['updated'], DateTime::ATOM); } else { - $link['up_iso_date'] = $this->getIsoDate($pubDate, DateTime::ATOM); + $data['up_iso_date'] = $this->getIsoDate($data['created'], DateTime::ATOM); } // Save the more recent item. - if (empty($this->latestDate) || $this->latestDate < $pubDate) { - $this->latestDate = $pubDate; + if (empty($this->latestDate) || $this->latestDate < $data['created']) { + $this->latestDate = $data['created']; } - if (!empty($upDate) && $this->latestDate < $upDate) { - $this->latestDate = $upDate; + if (!empty($data['updated']) && $this->latestDate < $data['updated']) { + $this->latestDate = $data['updated']; } - $taglist = array_filter(explode(' ', $link['tags']), 'strlen'); - uasort($taglist, 'strcasecmp'); - $link['taglist'] = $taglist; - - return $link; + return $data; } /** - * Set this to true to use permalinks instead of direct links. + * Set this to true to use permalinks instead of direct bookmarks. * * @param boolean $usePermalinks true to force permalinks. */ @@ -273,11 +273,11 @@ class FeedBuilder * Returns the number of link to display according to 'nb' user input parameter. * * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. - * If 'nb' is set to 'all', display all filtered links (max parameter). + * If 'nb' is set to 'all', display all filtered bookmarks (max parameter). * - * @param int $max maximum number of links to display. + * @param int $max maximum number of bookmarks to display. * - * @return int number of links to display. + * @return int number of bookmarks to display. */ public function getNbLinks($max) { diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php index f60c61f4..7797bfbf 100644 --- a/application/formatter/BookmarkMarkdownFormatter.php +++ b/application/formatter/BookmarkMarkdownFormatter.php @@ -57,6 +57,7 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter $processedDescription = $bookmark->getDescription(); $processedDescription = $this->filterProtocols($processedDescription); $processedDescription = $this->formatHashTags($processedDescription); + $processedDescription = $this->reverseEscapedHtml($processedDescription); $processedDescription = $this->parsedown ->setMarkupEscaped($this->escape) ->setBreaksEnabled(true) @@ -195,4 +196,9 @@ class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter ); return $description; } + + protected function reverseEscapedHtml($description) + { + return unescape($description); + } } diff --git a/application/netscape/NetscapeBookmarkUtils.php b/application/netscape/NetscapeBookmarkUtils.php index 28665941..d64eef7f 100644 --- a/application/netscape/NetscapeBookmarkUtils.php +++ b/application/netscape/NetscapeBookmarkUtils.php @@ -7,8 +7,10 @@ use DateTimeZone; use Exception; use Katzgrau\KLogger\Logger; use Psr\Log\LogLevel; -use Shaarli\Bookmark\LinkDB; +use Shaarli\Bookmark\Bookmark; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; +use Shaarli\Formatter\BookmarkFormatter; use Shaarli\History; use Shaarli\NetscapeBookmarkParser\NetscapeBookmarkParser; @@ -20,41 +22,39 @@ class NetscapeBookmarkUtils { /** - * Filters links and adds Netscape-formatted fields + * Filters bookmarks and adds Netscape-formatted fields * * Added fields: * - timestamp link addition date, using the Unix epoch format * - taglist comma-separated tag list * - * @param LinkDB $linkDb Link datastore - * @param string $selection Which links to export: (all|private|public) - * @param bool $prependNoteUrl Prepend note permalinks with the server's URL - * @param string $indexUrl Absolute URL of the Shaarli index page + * @param BookmarkServiceInterface $bookmarkService Link datastore + * @param BookmarkFormatter $formatter instance + * @param string $selection Which bookmarks to export: (all|private|public) + * @param bool $prependNoteUrl Prepend note permalinks with the server's URL + * @param string $indexUrl Absolute URL of the Shaarli index page * - * @throws Exception Invalid export selection + * @return array The bookmarks to be exported, with additional fields + *@throws Exception Invalid export selection * - * @return array The links to be exported, with additional fields */ - public static function filterAndFormat($linkDb, $selection, $prependNoteUrl, $indexUrl) - { + public static function filterAndFormat( + $bookmarkService, + $formatter, + $selection, + $prependNoteUrl, + $indexUrl + ) { // see tpl/export.html for possible values if (!in_array($selection, array('all', 'public', 'private'))) { throw new Exception(t('Invalid export selection:') . ' "' . $selection . '"'); } $bookmarkLinks = array(); - foreach ($linkDb as $link) { - if ($link['private'] != 0 && $selection == 'public') { - continue; - } - if ($link['private'] == 0 && $selection == 'private') { - continue; - } - $date = $link['created']; - $link['timestamp'] = $date->getTimestamp(); - $link['taglist'] = str_replace(' ', ',', $link['tags']); - - if (is_note($link['url']) && $prependNoteUrl) { + foreach ($bookmarkService->search([], $selection) as $bookmark) { + $link = $formatter->format($bookmark); + $link['taglist'] = implode(',', $bookmark->getTags()); + if ($bookmark->isNote() && $prependNoteUrl) { $link['url'] = $indexUrl . $link['url']; } @@ -69,9 +69,9 @@ class NetscapeBookmarkUtils * * @param string $filename name of the file to import * @param int $filesize size of the file to import - * @param int $importCount how many links were imported - * @param int $overwriteCount how many links were overwritten - * @param int $skipCount how many links were skipped + * @param int $importCount how many bookmarks were imported + * @param int $overwriteCount how many bookmarks were overwritten + * @param int $skipCount how many bookmarks were skipped * @param int $duration how many seconds did the import take * * @return string Summary of the bookmark import status @@ -91,7 +91,7 @@ class NetscapeBookmarkUtils $status .= vsprintf( t( 'was successfully processed in %d seconds: ' - . '%d links imported, %d links overwritten, %d links skipped.' + . '%d bookmarks imported, %d bookmarks overwritten, %d bookmarks skipped.' ), [$duration, $importCount, $overwriteCount, $skipCount] ); @@ -102,15 +102,15 @@ class NetscapeBookmarkUtils /** * Imports Web bookmarks from an uploaded Netscape bookmark dump * - * @param array $post Server $_POST parameters - * @param array $files Server $_FILES parameters - * @param LinkDB $linkDb Loaded LinkDB instance - * @param ConfigManager $conf instance - * @param History $history History instance + * @param array $post Server $_POST parameters + * @param array $files Server $_FILES parameters + * @param BookmarkServiceInterface $bookmarkService Loaded LinkDB instance + * @param ConfigManager $conf instance + * @param History $history History instance * * @return string Summary of the bookmark import status */ - public static function import($post, $files, $linkDb, $conf, $history) + public static function import($post, $files, $bookmarkService, $conf, $history) { $start = time(); $filename = $files['filetoupload']['name']; @@ -121,10 +121,10 @@ class NetscapeBookmarkUtils return self::importStatus($filename, $filesize); } - // Overwrite existing links? + // Overwrite existing bookmarks? $overwrite = !empty($post['overwrite']); - // Add tags to all imported links? + // Add tags to all imported bookmarks? if (empty($post['default_tags'])) { $defaultTags = array(); } else { @@ -134,7 +134,7 @@ class NetscapeBookmarkUtils ); } - // links are imported as public by default + // bookmarks are imported as public by default $defaultPrivacy = 0; $parser = new NetscapeBookmarkParser( @@ -164,22 +164,18 @@ class NetscapeBookmarkUtils // use value from the imported file $private = $bkm['pub'] == '1' ? 0 : 1; } elseif ($post['privacy'] == 'private') { - // all imported links are private + // all imported bookmarks are private $private = 1; } elseif ($post['privacy'] == 'public') { - // all imported links are public + // all imported bookmarks are public $private = 0; } - $newLink = array( - 'title' => $bkm['title'], - 'url' => $bkm['uri'], - 'description' => $bkm['note'], - 'private' => $private, - 'tags' => $bkm['tags'] - ); - - $existingLink = $linkDb->getLinkFromUrl($bkm['uri']); + $link = $bookmarkService->findByUrl($bkm['uri']); + $existingLink = $link !== null; + if (! $existingLink) { + $link = new Bookmark(); + } if ($existingLink !== false) { if ($overwrite === false) { @@ -188,28 +184,25 @@ class NetscapeBookmarkUtils continue; } - // Overwrite an existing link, keep its date - $newLink['id'] = $existingLink['id']; - $newLink['created'] = $existingLink['created']; - $newLink['updated'] = new DateTime(); - $newLink['shorturl'] = $existingLink['shorturl']; - $linkDb[$existingLink['id']] = $newLink; - $importCount++; + $link->setUpdated(new DateTime()); $overwriteCount++; - continue; + } else { + $newLinkDate = new DateTime('@' . strval($bkm['time'])); + $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get())); + $link->setCreated($newLinkDate); } - // Add a new link - @ used for UNIX timestamps - $newLinkDate = new DateTime('@' . strval($bkm['time'])); - $newLinkDate->setTimezone(new DateTimeZone(date_default_timezone_get())); - $newLink['created'] = $newLinkDate; - $newLink['id'] = $linkDb->getNextId(); - $newLink['shorturl'] = link_small_hash($newLink['created'], $newLink['id']); - $linkDb[$newLink['id']] = $newLink; + $link->setTitle($bkm['title']); + $link->setUrl($bkm['uri'], $conf->get('security.allowed_protocols')); + $link->setDescription($bkm['note']); + $link->setPrivate($private); + $link->setTagsString($bkm['tags']); + + $bookmarkService->addOrSet($link, false); $importCount++; } - $linkDb->save($conf->get('resource.page_cache')); + $bookmarkService->save(); $history->importLinks(); $duration = time() - $start; diff --git a/application/render/PageBuilder.php b/application/render/PageBuilder.php index 3f86fc26..65e85aaf 100644 --- a/application/render/PageBuilder.php +++ b/application/render/PageBuilder.php @@ -5,7 +5,7 @@ namespace Shaarli\Render; use Exception; use RainTPL; use Shaarli\ApplicationUtils; -use Shaarli\Bookmark\LinkDB; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Config\ConfigManager; use Shaarli\Thumbnailer; @@ -34,9 +34,9 @@ class PageBuilder protected $session; /** - * @var LinkDB $linkDB instance. + * @var BookmarkServiceInterface $bookmarkService instance. */ - protected $linkDB; + protected $bookmarkService; /** * @var null|string XSRF token @@ -52,18 +52,18 @@ class PageBuilder * PageBuilder constructor. * $tpl is initialized at false for lazy loading. * - * @param ConfigManager $conf Configuration Manager instance (reference). - * @param array $session $_SESSION array - * @param LinkDB $linkDB instance. - * @param string $token Session token - * @param bool $isLoggedIn + * @param ConfigManager $conf Configuration Manager instance (reference). + * @param array $session $_SESSION array + * @param BookmarkServiceInterface $linkDB instance. + * @param string $token Session token + * @param bool $isLoggedIn */ public function __construct(&$conf, $session, $linkDB = null, $token = null, $isLoggedIn = false) { $this->tpl = false; $this->conf = $conf; $this->session = $session; - $this->linkDB = $linkDB; + $this->bookmarkService = $linkDB; $this->token = $token; $this->isLoggedIn = $isLoggedIn; } @@ -125,8 +125,8 @@ class PageBuilder $this->tpl->assign('language', $this->conf->get('translation.language')); - if ($this->linkDB !== null) { - $this->tpl->assign('tags', $this->linkDB->linksCountPerTag()); + if ($this->bookmarkService !== null) { + $this->tpl->assign('tags', $this->bookmarkService->bookmarksCountPerTag()); } $this->tpl->assign( @@ -141,6 +141,8 @@ class PageBuilder unset($_SESSION['warnings']); } + $this->tpl->assign('formatter', $this->conf->get('formatter', 'default')); + // To be removed with a proper theme configuration. $this->tpl->assign('conf', $this->conf); } diff --git a/application/updater/Updater.php b/application/updater/Updater.php index beb9ea9b..95654d81 100644 --- a/application/updater/Updater.php +++ b/application/updater/Updater.php @@ -2,25 +2,14 @@ namespace Shaarli\Updater; -use Exception; -use RainTPL; -use ReflectionClass; -use ReflectionException; -use ReflectionMethod; -use Shaarli\ApplicationUtils; -use Shaarli\Bookmark\LinkDB; -use Shaarli\Bookmark\LinkFilter; -use Shaarli\Config\ConfigJson; use Shaarli\Config\ConfigManager; -use Shaarli\Config\ConfigPhp; -use Shaarli\Exceptions\IOException; -use Shaarli\Thumbnailer; +use Shaarli\Bookmark\BookmarkServiceInterface; use Shaarli\Updater\Exception\UpdaterException; /** - * Class updater. + * Class Updater. * Used to update stuff when a new Shaarli's version is reached. - * Update methods are ran only once, and the stored in a JSON file. + * Update methods are ran only once, and the stored in a TXT file. */ class Updater { @@ -30,9 +19,9 @@ class Updater protected $doneUpdates; /** - * @var LinkDB instance. + * @var BookmarkServiceInterface instance. */ - protected $linkDB; + protected $linkServices; /** * @var ConfigManager $conf Configuration Manager instance. @@ -45,36 +34,27 @@ class Updater protected $isLoggedIn; /** - * @var array $_SESSION - */ - protected $session; - - /** - * @var ReflectionMethod[] List of current class methods. + * @var \ReflectionMethod[] List of current class methods. */ protected $methods; /** * Object constructor. * - * @param array $doneUpdates Updates which are already done. - * @param LinkDB $linkDB LinkDB instance. - * @param ConfigManager $conf Configuration Manager instance. - * @param boolean $isLoggedIn True if the user is logged in. - * @param array $session $_SESSION (by reference) - * - * @throws ReflectionException + * @param array $doneUpdates Updates which are already done. + * @param BookmarkServiceInterface $linkDB LinksService instance. + * @param ConfigManager $conf Configuration Manager instance. + * @param boolean $isLoggedIn True if the user is logged in. */ - public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = []) + public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) { $this->doneUpdates = $doneUpdates; - $this->linkDB = $linkDB; + $this->linkServices = $linkDB; $this->conf = $conf; $this->isLoggedIn = $isLoggedIn; - $this->session = &$session; // Retrieve all update methods. - $class = new ReflectionClass($this); + $class = new \ReflectionClass($this); $this->methods = $class->getMethods(); } @@ -96,12 +76,12 @@ class Updater } if ($this->methods === null) { - throw new UpdaterException(t('Couldn\'t retrieve updater class methods.')); + throw new UpdaterException('Couldn\'t retrieve LegacyUpdater class methods.'); } foreach ($this->methods as $method) { // Not an update method or already done, pass. - if (!startsWith($method->getName(), 'updateMethod') + if (! startsWith($method->getName(), 'updateMethod') || in_array($method->getName(), $this->doneUpdates) ) { continue; @@ -114,7 +94,7 @@ class Updater if ($res === true) { $updatesRan[] = $method->getName(); } - } catch (Exception $e) { + } catch (\Exception $e) { throw new UpdaterException($method, $e); } } @@ -131,432 +111,4 @@ class Updater { 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() - { - // up to date database - if (isset($this->linkDB[0])) { - 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(LinkFilter::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 links 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 links - * - * @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; - } } diff --git a/application/updater/UpdaterUtils.php b/application/updater/UpdaterUtils.php index 34d4f422..828a49fc 100644 --- a/application/updater/UpdaterUtils.php +++ b/application/updater/UpdaterUtils.php @@ -1,39 +1,44 @@ + * whom created the CSS which this file is based on. + * License: Unlicense + */ + +.markdown p{ + margin:0.75em 0; +} + +.markdown img{ + max-width:100%; +} + +.markdown h1, .markdown h2, .markdown h3, .markdown h4, .markdown h5, .markdown h6{ + font-weight:normal; + font-style:normal; + line-height:1em; + margin:0.75em 0; +} +.markdown h4, .markdown h5, .markdown h6{ font-weight: bold; } +.markdown h1{ font-size:2.5em; } +.markdown h2{ font-size:2em; } +.markdown h3{ font-size:1.5em; } +.markdown h4{ font-size:1.2em; } +.markdown h5{ font-size:1em; } +.markdown h6{ font-size:0.9em; } + +.markdown blockquote{ + color:#666666; + padding-left: 3em; + border-left: 0.5em #EEE solid; + margin:0.75em 0; +} +.markdown hr { display: block; height: 2px; border: 0; border-top: 1px solid #aaa;border-bottom: 1px solid #eee; margin: 1em 0; padding: 0; } +.markdown pre, .markdown code, .markdown kbd, .markdown samp { + font-family: monospace, 'courier new'; + font-size: 0.98em; +} +.markdown pre { white-space: pre; white-space: pre-wrap; word-wrap: break-word; } + +.markdown b, .markdown strong { font-weight: bold; } + +.markdown dfn, .markdown em { font-style: italic; } + +.markdown ins { background: #ff9; color: #000; text-decoration: none; } + +.markdown mark { background: #ff0; color: #000; font-style: italic; font-weight: bold; } + +.markdown sub, .markdown sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline; } +.markdown sup { top: -0.5em; } +.markdown sub { bottom: -0.25em; } + +.markdown ul, .markdown ol { margin: 1em 0; padding: 0 0 0 2em; } +.markdown li p:last-child { margin:0 } +.markdown dd { margin: 0 0 0 2em; } + +.markdown img { border: 0; -ms-interpolation-mode: bicubic; vertical-align: middle; } + +.markdown table { border-collapse: collapse; border-spacing: 0; } +.markdown td { vertical-align: top; } + +@media only screen and (min-width: 480px) { + .markdown {font-size:0.9em;} +} + +@media only screen and (min-width: 768px) { + .markdown {font-size:1em;} +} + +#linklist .markdown li { + padding: 0; + border: none; + background: none; +} + +#linklist .markdown ul li { + list-style: circle; +} + +#linklist .markdown ol li { + list-style: decimal; +} + +.markdown table { + padding: 0; +} +.markdown table tr { + border-top: 1px solid #cccccc; + background-color: white; + margin: 0; + padding: 0; +} +.markdown table tr:nth-child(2n) { + background-color: #f8f8f8; +} +.markdown table tr th { + font-weight: bold; + border: 1px solid #cccccc; + text-align: left; + margin: 0; + padding: 6px 13px; +} +.markdown table tr td { + border: 1px solid #cccccc; + text-align: left; + margin: 0; + padding: 6px 13px; +} +.markdown table tr th :first-child, .markdown table tr td :first-child { + margin-top: 0; +} +.markdown table tr th :last-child, table tr td :last-child { + margin-bottom: 0; +} + +.markdown pre { + background-color: #eee; + padding: 4px 9px; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; + overflow: auto; + box-shadow: 0 -1px 0 #e5e5e5,0 0 1px rgba(0,0,0,0.12),0 1px 2px rgba(0,0,0,0.24); +} + +.markdown pre code { + color: black; + font-family: 'Consolas', 'Monaco', 'Andale Mono', monospace; + direction: ltr; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + line-height: 1.7; + font-size: 11.5px; + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +.markdown :not(pre) code { + background-color: #eee; + padding: 1px 3px; + border-radius: 1px; + box-shadow: 0 -1px 0 #e5e5e5,0 0 1px rgba(0,0,0,0.12),0 1px 1px rgba(0,0,0,0.24); +} + +#pageheader .md_help { + color: white; +} + +/* + Remove header bookmarks style + */ +#pageheader .md_help a { + color: lightgray; + font-weight: bold; + text-decoration: underline; + + background: none; + box-shadow: none; + padding: 0; + margin: 0; +} + +#pageheader .md_help a:hover { + color: white; +} diff --git a/doc/md/guides/various-hacks.md b/doc/md/guides/various-hacks.md index b3aa869d..0cef99df 100644 --- a/doc/md/guides/various-hacks.md +++ b/doc/md/guides/various-hacks.md @@ -17,14 +17,6 @@ Alternatively, you can transform to JSON format (and pretty-print if you have `j php -r 'print(json_encode(unserialize(gzinflate(base64_decode(preg_replace("!.*/\* (.+) \*/.*!", "$1", file_get_contents("data/datastore.php")))))));' | jq . ``` -### Changing the timestamp for a shaare - -- Look for `` in `tpl/editlink.tpl` (line 14) -- Replace `type="hidden"` with `type="text"` from this line -- A new date/time field becomes available in the edit/new link dialog. -- You can set the timestamp manually by entering it in the format `YYYMMDD_HHMMS`. - - ### See also - [Add a new custom field to shaares (example patch)](https://gist.github.com/nodiscc/8b0194921f059d7b9ad89a581ecd482c) diff --git a/index.php b/index.php index 9783539a..ae74bc7e 100644 --- a/index.php +++ b/index.php @@ -35,9 +35,6 @@ ini_set('upload_max_filesize', '16M'); // See all error except warnings error_reporting(E_ALL^E_WARNING); -// See all errors (for debugging only) -//error_reporting(-1); - // 3rd-party libraries if (! file_exists(__DIR__ . '/vendor/autoload.php')) { @@ -65,11 +62,15 @@ require_once 'application/TimeZone.php'; require_once 'application/Utils.php'; use \Shaarli\ApplicationUtils; -use \Shaarli\Bookmark\Exception\LinkNotFoundException; -use \Shaarli\Bookmark\LinkDB; +use Shaarli\Bookmark\BookmarkServiceInterface; +use \Shaarli\Bookmark\Exception\BookmarkNotFoundException; +use Shaarli\Bookmark\Bookmark; +use Shaarli\Bookmark\BookmarkFilter; +use Shaarli\Bookmark\BookmarkFileService; use \Shaarli\Config\ConfigManager; use \Shaarli\Feed\CachedPage; use \Shaarli\Feed\FeedBuilder; +use Shaarli\Formatter\FormatterFactory; use \Shaarli\History; use \Shaarli\Languages; use \Shaarli\Netscape\NetscapeBookmarkUtils; @@ -81,6 +82,7 @@ use \Shaarli\Security\LoginManager; use \Shaarli\Security\SessionManager; use \Shaarli\Thumbnailer; use \Shaarli\Updater\Updater; +use \Shaarli\Updater\UpdaterUtils; // Ensure the PHP version is supported try { @@ -122,6 +124,17 @@ if (isset($_COOKIE['shaarli']) && !SessionManager::checkId($_COOKIE['shaarli'])) } $conf = new ConfigManager(); + +// In dev mode, throw exception on any warning +if ($conf->get('dev.debug', false)) { + // See all errors (for debugging only) + error_reporting(-1); + + set_error_handler(function($errno, $errstr, $errfile, $errline, array $errcontext) { + throw new ErrorException($errstr, 0, $errno, $errfile, $errline); + }); +} + $sessionManager = new SessionManager($_SESSION, $conf); $loginManager = new LoginManager($conf, $sessionManager); $loginManager->generateStaySignedInToken($_SERVER['REMOTE_ADDR']); @@ -140,7 +153,7 @@ if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { new Languages(setlocale(LC_MESSAGES, 0), $conf); $conf->setEmpty('general.timezone', date_default_timezone_get()); -$conf->setEmpty('general.title', t('Shared links on '). escape(index_url($_SERVER))); +$conf->setEmpty('general.title', t('Shared bookmarks on '). escape(index_url($_SERVER))); RainTPL::$tpl_dir = $conf->get('resource.raintpl_tpl').'/'.$conf->get('resource.theme').'/'; // template directory RainTPL::$cache_dir = $conf->get('resource.raintpl_tmp'); // cache directory @@ -283,14 +296,15 @@ if (!isset($_SESSION['tokens'])) { } /** - * Daily RSS feed: 1 RSS entry per day giving all the links on that day. - * Gives the last 7 days (which have links). + * Daily RSS feed: 1 RSS entry per day giving all the bookmarks on that day. + * Gives the last 7 days (which have bookmarks). * This RSS feed cannot be filtered. * - * @param ConfigManager $conf Configuration Manager instance - * @param LoginManager $loginManager LoginManager instance + * @param BookmarkServiceInterface $bookmarkService + * @param ConfigManager $conf Configuration Manager instance + * @param LoginManager $loginManager LoginManager instance */ -function showDailyRSS($conf, $loginManager) +function showDailyRSS($bookmarkService, $conf, $loginManager) { // Cache system $query = $_SERVER['QUERY_STRING']; @@ -305,28 +319,20 @@ function showDailyRSS($conf, $loginManager) exit; } - // If cached was not found (or not usable), then read the database and build the response: - // Read links from database (and filter private links if used it not logged in). - $LINKSDB = new LinkDB( - $conf->get('resource.datastore'), - $loginManager->isLoggedIn(), - $conf->get('privacy.hide_public_links') - ); - - /* Some Shaarlies may have very few links, so we need to look + /* Some Shaarlies may have very few bookmarks, so we need to look back in time until we have enough days ($nb_of_days). */ $nb_of_days = 7; // We take 7 days. $today = date('Ymd'); $days = array(); - foreach ($LINKSDB as $link) { - $day = $link['created']->format('Ymd'); // Extract day (without time) + foreach ($bookmarkService->search() as $bookmark) { + $day = $bookmark->getCreated()->format('Ymd'); // Extract day (without time) if (strcmp($day, $today) < 0) { if (empty($days[$day])) { $days[$day] = array(); } - $days[$day][] = $link; + $days[$day][] = $bookmark; } if (count($days) > $nb_of_days) { @@ -341,30 +347,38 @@ function showDailyRSS($conf, $loginManager) echo ''; echo 'Daily - '. $conf->get('general.title') . ''; echo ''. $pageaddr .''; - echo 'Daily shared links'; + echo 'Daily shared bookmarks'; echo 'en-en'; echo ''. $pageaddr .''. PHP_EOL; + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter(); + $formatter->addContextData('index_url', index_url($_SERVER)); // For each day. - foreach ($days as $day => $links) { - $dayDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $day.'_000000'); + /** @var Bookmark[] $bookmarks */ + foreach ($days as $day => $bookmarks) { + $formattedBookmarks = []; + $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); $absurl = escape(index_url($_SERVER).'?do=daily&day='.$day); // Absolute URL of the corresponding "Daily" page. // We pre-format some fields for proper output. - foreach ($links as &$link) { - $link['formatedDescription'] = format_description($link['description']); - $link['timestamp'] = $link['created']->getTimestamp(); - if (is_note($link['url'])) { - $link['url'] = index_url($_SERVER) . $link['url']; // make permalink URL absolute + foreach ($bookmarks as $key => $bookmark) { + $formattedBookmarks[$key] = $formatter->format($bookmark); + // This page is a bit specific, we need raw description to calculate the length + $formattedBookmarks[$key]['formatedDescription'] = $formattedBookmarks[$key]['description']; + $formattedBookmarks[$key]['description'] = $bookmark->getDescription(); + + if ($bookmark->isNote()) { + $link['url'] = index_url($_SERVER) . $bookmark->getUrl(); // make permalink URL absolute } } // Then build the HTML for this day: - $tpl = new RainTPL; + $tpl = new RainTPL(); $tpl->assign('title', $conf->get('general.title')); $tpl->assign('daydate', $dayDate->getTimestamp()); $tpl->assign('absurl', $absurl); - $tpl->assign('links', $links); + $tpl->assign('links', $formattedBookmarks); $tpl->assign('rssdate', escape($dayDate->format(DateTime::RSS))); $tpl->assign('hide_timestamps', $conf->get('privacy.hide_timestamps', false)); $tpl->assign('index_url', $pageaddr); @@ -382,13 +396,13 @@ function showDailyRSS($conf, $loginManager) /** * Show the 'Daily' page. * - * @param PageBuilder $pageBuilder Template engine wrapper. - * @param LinkDB $LINKSDB LinkDB instance. - * @param ConfigManager $conf Configuration Manager instance. - * @param PluginManager $pluginManager Plugin Manager instance. - * @param LoginManager $loginManager Login Manager instance + * @param PageBuilder $pageBuilder Template engine wrapper. + * @param BookmarkServiceInterface $bookmarkService instance. + * @param ConfigManager $conf Configuration Manager instance. + * @param PluginManager $pluginManager Plugin Manager instance. + * @param LoginManager $loginManager Login Manager instance */ -function showDaily($pageBuilder, $LINKSDB, $conf, $pluginManager, $loginManager) +function showDaily($pageBuilder, $bookmarkService, $conf, $pluginManager, $loginManager) { if (isset($_GET['day'])) { $day = $_GET['day']; @@ -402,10 +416,10 @@ function showDaily($pageBuilder, $LINKSDB, $conf, $pluginManager, $loginManager) $pageBuilder->assign('dayDesc', t('Today')); } - $days = $LINKSDB->days(); + $days = $bookmarkService->days(); $i = array_search($day, $days); if ($i === false && count($days)) { - // no links for day, but at least one day with links + // no bookmarks for day, but at least one day with bookmarks $i = count($days) - 1; $day = $days[$i]; } @@ -414,29 +428,30 @@ function showDaily($pageBuilder, $LINKSDB, $conf, $pluginManager, $loginManager) if ($i !== false) { if ($i >= 1) { - $previousday=$days[$i - 1]; + $previousday = $days[$i - 1]; } if ($i < count($days) - 1) { $nextday = $days[$i + 1]; } } try { - $linksToDisplay = $LINKSDB->filterDay($day); + $linksToDisplay = $bookmarkService->filterDay($day); } catch (Exception $exc) { error_log($exc); - $linksToDisplay = array(); + $linksToDisplay = []; } + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter(); // We pre-format some fields for proper output. - foreach ($linksToDisplay as $key => $link) { - $taglist = explode(' ', $link['tags']); - uasort($taglist, 'strcasecmp'); - $linksToDisplay[$key]['taglist']=$taglist; - $linksToDisplay[$key]['formatedDescription'] = format_description($link['description']); - $linksToDisplay[$key]['timestamp'] = $link['created']->getTimestamp(); + foreach ($linksToDisplay as $key => $bookmark) { + $linksToDisplay[$key] = $formatter->format($bookmark); + // This page is a bit specific, we need raw description to calculate the length + $linksToDisplay[$key]['formatedDescription'] = $linksToDisplay[$key]['description']; + $linksToDisplay[$key]['description'] = $bookmark->getDescription(); } - $dayDate = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $day.'_000000'); + $dayDate = DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, $day.'_000000'); $data = array( 'pagetitle' => $conf->get('general.title') .' - '. format_date($dayDate, false), 'linksToDisplay' => $linksToDisplay, @@ -457,19 +472,19 @@ function showDaily($pageBuilder, $LINKSDB, $conf, $pluginManager, $loginManager) */ $columns = array(array(), array(), array()); // Entries to display, for each column. $fill = array(0, 0, 0); // Rough estimate of columns fill. - foreach ($data['linksToDisplay'] as $key => $link) { + foreach ($data['linksToDisplay'] as $key => $bookmark) { // Roughly estimate length of entry (by counting characters) // Title: 30 chars = 1 line. 1 line is 30 pixels height. // Description: 836 characters gives roughly 342 pixel height. // This is not perfect, but it's usually OK. - $length = strlen($link['title']) + (342 * strlen($link['description'])) / 836; - if (! empty($link['thumbnail'])) { + $length = strlen($bookmark['title']) + (342 * strlen($bookmark['description'])) / 836; + if (! empty($bookmark['thumbnail'])) { $length += 100; // 1 thumbnails roughly takes 100 pixels height. } // Then put in column which is the less filled: $smallest = min($fill); // find smallest value in array. $index = array_search($smallest, $fill); // find index of this smallest value. - array_push($columns[$index], $link); // Put entry in this column. + array_push($columns[$index], $bookmark); // Put entry in this column. $fill[$index] += $length; } @@ -487,40 +502,39 @@ function showDaily($pageBuilder, $LINKSDB, $conf, $pluginManager, $loginManager) /** * Renders the linklist * - * @param pageBuilder $PAGE pageBuilder instance. - * @param LinkDB $LINKSDB LinkDB instance. - * @param ConfigManager $conf Configuration Manager instance. - * @param PluginManager $pluginManager Plugin Manager instance. + * @param pageBuilder $PAGE pageBuilder instance. + * @param BookmarkServiceInterface $linkDb instance. + * @param ConfigManager $conf Configuration Manager instance. + * @param PluginManager $pluginManager Plugin Manager instance. */ -function showLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager) +function showLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager) { - buildLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager); + buildLinkList($PAGE, $linkDb, $conf, $pluginManager, $loginManager); $PAGE->renderPage('linklist'); } /** * Render HTML page (according to URL parameters and user rights) * - * @param ConfigManager $conf Configuration Manager instance. - * @param PluginManager $pluginManager Plugin Manager instance, - * @param LinkDB $LINKSDB - * @param History $history instance - * @param SessionManager $sessionManager SessionManager instance - * @param LoginManager $loginManager LoginManager instance + * @param ConfigManager $conf Configuration Manager instance. + * @param PluginManager $pluginManager Plugin Manager instance, + * @param BookmarkServiceInterface $bookmarkService + * @param History $history instance + * @param SessionManager $sessionManager SessionManager instance + * @param LoginManager $loginManager LoginManager instance */ -function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $loginManager) +function renderPage($conf, $pluginManager, $bookmarkService, $history, $sessionManager, $loginManager) { $updater = new Updater( - read_updates_file($conf->get('resource.updates')), - $LINKSDB, + UpdaterUtils::read_updates_file($conf->get('resource.updates')), + $bookmarkService, $conf, - $loginManager->isLoggedIn(), - $_SESSION + $loginManager->isLoggedIn() ); try { $newUpdates = $updater->update(); if (! empty($newUpdates)) { - write_updates_file( + UpdaterUtils::write_updates_file( $conf->get('resource.updates'), $updater->getDoneUpdates() ); @@ -529,9 +543,9 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, die($e->getMessage()); } - $PAGE = new PageBuilder($conf, $_SESSION, $LINKSDB, $sessionManager->generateToken(), $loginManager->isLoggedIn()); - $PAGE->assign('linkcount', count($LINKSDB)); - $PAGE->assign('privateLinkcount', count_private($LINKSDB)); + $PAGE = new PageBuilder($conf, $_SESSION, $bookmarkService, $sessionManager->generateToken(), $loginManager->isLoggedIn()); + $PAGE->assign('linkcount', $bookmarkService->count(BookmarkFilter::$ALL)); + $PAGE->assign('privateLinkcount', $bookmarkService->count(BookmarkFilter::$PRIVATE)); $PAGE->assign('plugin_errors', $pluginManager->getErrors()); // Determine which page will be rendered. @@ -611,27 +625,28 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, } // Optionally filter the results: - $links = $LINKSDB->filterSearch($_GET); - $linksToDisplay = array(); + $links = $bookmarkService->search($_GET); + $linksToDisplay = []; - // Get only links which have a thumbnail. + // Get only bookmarks which have a thumbnail. // Note: we do not retrieve thumbnails here, the request is too heavy. + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter(); foreach ($links as $key => $link) { - if (isset($link['thumbnail']) && $link['thumbnail'] !== false) { - $linksToDisplay[] = $link; // Add to array. + if ($link->getThumbnail() !== false) { + $linksToDisplay[] = $formatter->format($link); } } - $data = array( + $data = [ 'linksToDisplay' => $linksToDisplay, - ); - $pluginManager->executeHooks('render_picwall', $data, array('loggedin' => $loginManager->isLoggedIn())); + ]; + $pluginManager->executeHooks('render_picwall', $data, ['loggedin' => $loginManager->isLoggedIn()]); foreach ($data as $key => $value) { $PAGE->assign($key, $value); } - $PAGE->renderPage('picwall'); exit; } @@ -640,7 +655,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, if ($targetPage == Router::$PAGE_TAGCLOUD) { $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : ''; $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : []; - $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility); + $tags = $bookmarkService->bookmarksCountPerTag($filteringTags, $visibility); // We sort tags alphabetically, then choose a font size according to count. // First, find max value. @@ -687,7 +702,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, if ($targetPage == Router::$PAGE_TAGLIST) { $visibility = ! empty($_SESSION['visibility']) ? $_SESSION['visibility'] : ''; $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : []; - $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility); + $tags = $bookmarkService->bookmarksCountPerTag($filteringTags, $visibility); foreach ($filteringTags as $tag) { if (array_key_exists($tag, $tags)) { unset($tags[$tag]); @@ -717,7 +732,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, // Daily page. if ($targetPage == Router::$PAGE_DAILY) { - showDaily($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager); + showDaily($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager); } // ATOM and RSS feed. @@ -738,8 +753,16 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, exit; } + $factory = new FormatterFactory($conf); // Generate data. - $feedGenerator = new FeedBuilder($LINKSDB, $feedType, $_SERVER, $_GET, $loginManager->isLoggedIn()); + $feedGenerator = new FeedBuilder( + $bookmarkService, + $factory->getFormatter('raw'), + $feedType, + $_SERVER, + $_GET, + $loginManager->isLoggedIn() + ); $feedGenerator->setLocale(strtolower(setlocale(LC_COLLATE, 0))); $feedGenerator->setHideDates($conf->get('privacy.hide_timestamps') && !$loginManager->isLoggedIn()); $feedGenerator->setUsePermalinks(isset($_GET['permalinks']) || !$conf->get('feed.rss_permalinks')); @@ -845,7 +868,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, exit; } - // -------- User wants to change the number of links per page (linksperpage=...) + // -------- User wants to change the number of bookmarks per page (linksperpage=...) if (isset($_GET['linksperpage'])) { if (is_numeric($_GET['linksperpage'])) { $_SESSION['LINKS_PER_PAGE']=abs(intval($_GET['linksperpage'])); @@ -860,19 +883,19 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, exit; } - // -------- User wants to see only private links (toggle) + // -------- User wants to see only private bookmarks (toggle) if (isset($_GET['visibility'])) { if ($_GET['visibility'] === 'private') { // Visibility not set or not already private, set private, otherwise reset it if (empty($_SESSION['visibility']) || $_SESSION['visibility'] !== 'private') { - // See only private links + // See only private bookmarks $_SESSION['visibility'] = 'private'; } else { unset($_SESSION['visibility']); } } elseif ($_GET['visibility'] === 'public') { if (empty($_SESSION['visibility']) || $_SESSION['visibility'] !== 'public') { - // See only public links + // See only public bookmarks $_SESSION['visibility'] = 'public'; } else { unset($_SESSION['visibility']); @@ -888,7 +911,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, exit; } - // -------- User wants to see only untagged links (toggle) + // -------- User wants to see only untagged bookmarks (toggle) if (isset($_GET['untaggedonly'])) { $_SESSION['untaggedonly'] = empty($_SESSION['untaggedonly']); @@ -916,7 +939,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, exit; } - showLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager); + showLinkList($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager); if (isset($_GET['edit_link'])) { header('Location: ?do=login&edit_link='. escape($_GET['edit_link'])); exit; @@ -1022,7 +1045,11 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $conf->set('privacy.hide_public_links', !empty($_POST['hidePublicLinks'])); $conf->set('api.enabled', !empty($_POST['enableApi'])); $conf->set('api.secret', escape($_POST['apiSecret'])); - $conf->set('translation.language', escape($_POST['language'])); + $conf->set('formatter', escape($_POST['formatter'])); + + if (! empty($_POST['language'])) { + $conf->set('translation.language', escape($_POST['language'])); + } $thumbnailsMode = extension_loaded('gd') ? $_POST['enableThumbnails'] : Thumbnailer::MODE_NONE; if ($thumbnailsMode !== Thumbnailer::MODE_NONE @@ -1056,6 +1083,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $PAGE->assign('title', $conf->get('general.title')); $PAGE->assign('theme', $conf->get('resource.theme')); $PAGE->assign('theme_available', ThemeUtils::getThemes($conf->get('resource.raintpl_tpl'))); + $PAGE->assign('formatter_available', ['default', 'markdown']); list($continents, $cities) = generateTimeZoneData( timezone_identifiers_list(), $conf->get('general.timezone') @@ -1093,17 +1121,25 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, } $toTag = isset($_POST['totag']) ? escape($_POST['totag']) : null; - $alteredLinks = $LINKSDB->renameTag(escape($_POST['fromtag']), $toTag); - $LINKSDB->save($conf->get('resource.page_cache')); - foreach ($alteredLinks as $link) { - $history->updateLink($link); + $fromTag = escape($_POST['fromtag']); + $count = 0; + $bookmarks = $bookmarkService->search(['searchtags' => $fromTag], BookmarkFilter::$ALL, true); + foreach ($bookmarks as $bookmark) { + if ($toTag) { + $bookmark->renameTag($fromTag, $toTag); + } else { + $bookmark->deleteTag($fromTag); + } + $bookmarkService->set($bookmark, false); + $history->updateLink($bookmark); + $count++; } + $bookmarkService->save(); $delete = empty($_POST['totag']); $redirect = $delete ? 'do=changetag' : 'searchtags='. urlencode(escape($_POST['totag'])); - $count = count($alteredLinks); $alert = $delete - ? sprintf(t('The tag was removed from %d link.', 'The tag was removed from %d links.', $count), $count) - : sprintf(t('The tag was renamed in %d link.', 'The tag was renamed in %d links.', $count), $count); + ? sprintf(t('The tag was removed from %d link.', 'The tag was removed from %d bookmarks.', $count), $count) + : sprintf(t('The tag was renamed in %d link.', 'The tag was renamed in %d bookmarks.', $count), $count); echo ''; exit; } @@ -1123,69 +1159,37 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, } // lf_id should only be present if the link exists. - $id = isset($_POST['lf_id']) ? intval(escape($_POST['lf_id'])) : $LINKSDB->getNextId(); - $link['id'] = $id; - // Linkdate is kept here to: - // - use the same permalink for notes as they're displayed when creating them - // - let users hack creation date of their posts - // See: https://shaarli.readthedocs.io/en/master/guides/various-hacks/#changing-the-timestamp-for-a-shaare - $linkdate = escape($_POST['lf_linkdate']); - $link['created'] = DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, $linkdate); - if (isset($LINKSDB[$id])) { + $id = isset($_POST['lf_id']) ? intval(escape($_POST['lf_id'])) : null; + if ($id && $bookmarkService->exists($id)) { // Edit - $link['updated'] = new DateTime(); - $link['shorturl'] = $LINKSDB[$id]['shorturl']; - $link['sticky'] = isset($LINKSDB[$id]['sticky']) ? $LINKSDB[$id]['sticky'] : false; - $new = false; + $bookmark = $bookmarkService->get($id); } else { // New link - $link['updated'] = null; - $link['shorturl'] = link_small_hash($link['created'], $id); - $link['sticky'] = false; - $new = true; - } - - // Remove multiple spaces. - $tags = trim(preg_replace('/\s\s+/', ' ', $_POST['lf_tags'])); - // Remove first '-' char in tags. - $tags = preg_replace('/(^| )\-/', '$1', $tags); - // Remove duplicates. - $tags = implode(' ', array_unique(explode(' ', $tags))); - - if (empty(trim($_POST['lf_url']))) { - $_POST['lf_url'] = '?' . smallHash($linkdate . $id); + $bookmark = new Bookmark(); } - $url = whitelist_protocols(trim($_POST['lf_url']), $conf->get('security.allowed_protocols')); - $link = array_merge($link, [ - 'title' => trim($_POST['lf_title']), - 'url' => $url, - 'description' => $_POST['lf_description'], - 'private' => (isset($_POST['lf_private']) ? 1 : 0), - 'tags' => str_replace(',', ' ', $tags), - ]); - - // If title is empty, use the URL as title. - if ($link['title'] == '') { - $link['title'] = $link['url']; - } + $bookmark->setTitle($_POST['lf_title']); + $bookmark->setDescription($_POST['lf_description']); + $bookmark->setUrl($_POST['lf_url'], $conf->get('security.allowed_protocols')); + $bookmark->setPrivate(isset($_POST['lf_private'])); + $bookmark->setTagsString($_POST['lf_tags']); if ($conf->get('thumbnails.mode', Thumbnailer::MODE_NONE) !== Thumbnailer::MODE_NONE - && ! is_note($link['url']) + && ! $bookmark->isNote() ) { $thumbnailer = new Thumbnailer($conf); - $link['thumbnail'] = $thumbnailer->get($url); + $bookmark->setThumbnail($thumbnailer->get($bookmark->getUrl())); } + $bookmarkService->addOrSet($bookmark, false); - $pluginManager->executeHooks('save_link', $link); + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter('raw'); + $data = $formatter->format($bookmark); + $pluginManager->executeHooks('save_link', $data); - $LINKSDB[$id] = $link; - $LINKSDB->save($conf->get('resource.page_cache')); - if ($new) { - $history->addLink($link); - } else { - $history->updateLink($link); - } + $bookmark->fromArray($data); + $bookmarkService->set($bookmark); // If we are called from the bookmarklet, we must close the popup: if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { @@ -1196,32 +1200,12 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $returnurl = !empty($_POST['returnurl']) ? $_POST['returnurl'] : '?'; $location = generateLocation($returnurl, $_SERVER['HTTP_HOST'], array('addlink', 'post', 'edit_link')); // Scroll to the link which has been edited. - $location .= '#' . $link['shorturl']; + $location .= '#' . $bookmark->getShortUrl(); // After saving the link, redirect to the page the user was on. header('Location: '. $location); exit; } - // -------- User clicked the "Cancel" button when editing a link. - if (isset($_POST['cancel_edit'])) { - $id = isset($_POST['lf_id']) ? (int) escape($_POST['lf_id']) : false; - if (! isset($LINKSDB[$id])) { - header('Location: ?'); - } - // If we are called from the bookmarklet, we must close the popup: - if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { - echo ''; - exit; - } - $link = $LINKSDB[$id]; - $returnurl = ( isset($_POST['returnurl']) ? $_POST['returnurl'] : '?' ); - // Scroll to the link which has been edited. - $returnurl .= '#'. $link['shorturl']; - $returnurl = generateLocation($returnurl, $_SERVER['HTTP_HOST'], array('addlink', 'post', 'edit_link')); - header('Location: '.$returnurl); // After canceling, redirect to the page the user was on. - exit; - } - // -------- User clicked the "Delete" button when editing a link: Delete link from database. if ($targetPage == Router::$PAGE_DELETELINK) { if (! $sessionManager->checkToken($_GET['token'])) { @@ -1231,23 +1215,31 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $ids = trim($_GET['lf_linkdate']); if (strpos($ids, ' ') !== false) { // multiple, space-separated ids provided - $ids = array_values(array_filter(preg_split('/\s+/', escape($ids)))); + $ids = array_values(array_filter( + preg_split('/\s+/', escape($ids)), + function ($item) { + return $item !== ''; + } + )); } else { // only a single id provided + $shortUrl = $bookmarkService->get($ids)->getShortUrl(); $ids = [$ids]; } // assert at least one id is given if (!count($ids)) { die('no id provided'); } + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter('raw'); foreach ($ids as $id) { $id = (int) escape($id); - $link = $LINKSDB[$id]; - $pluginManager->executeHooks('delete_link', $link); - $history->deleteLink($link); - unset($LINKSDB[$id]); + $bookmark = $bookmarkService->get($id); + $data = $formatter->format($bookmark); + $pluginManager->executeHooks('delete_link', $data); + $bookmarkService->remove($bookmark, false); } - $LINKSDB->save($conf->get('resource.page_cache')); // save to disk + $bookmarkService->save(); // If we are called from the bookmarklet, we must close the popup: if (isset($_GET['source']) && ($_GET['source']=='bookmarklet' || $_GET['source']=='firefoxsocialapi')) { @@ -1261,7 +1253,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $location = generateLocation( $_SERVER['HTTP_REFERER'], $_SERVER['HTTP_HOST'], - ['delete_link', 'edit_link', $link['shorturl']] + ['delete_link', 'edit_link', ! empty($shortUrl) ? $shortUrl : null] ); } @@ -1294,14 +1286,21 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, } else { $private = $_GET['newVisibility'] === 'private'; } + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter('raw'); foreach ($ids as $id) { $id = (int) escape($id); - $link = $LINKSDB[$id]; - $link['private'] = $private; - $pluginManager->executeHooks('save_link', $link); - $LINKSDB[$id] = $link; + $bookmark = $bookmarkService->get($id); + $bookmark->setPrivate($private); + + // To preserve backward compatibility with 3rd parties, plugins still use arrays + $data = $formatter->format($bookmark); + $pluginManager->executeHooks('save_link', $data); + $bookmark->fromArray($data); + + $bookmarkService->set($bookmark); } - $LINKSDB->save($conf->get('resource.page_cache')); // save to disk + $bookmarkService->save(); $location = '?'; if (isset($_SERVER['HTTP_REFERER'])) { @@ -1317,17 +1316,22 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, // -------- User clicked the "EDIT" button on a link: Display link edit form. if (isset($_GET['edit_link'])) { $id = (int) escape($_GET['edit_link']); - $link = $LINKSDB[$id]; // Read database - if (!$link) { + try { + $link = $bookmarkService->get($id); // Read database + } catch (BookmarkNotFoundException $e) { + // Link not found in database. header('Location: ?'); exit; - } // Link not found in database. - $link['linkdate'] = $link['created']->format(LinkDB::LINK_DATE_FORMAT); + } + + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter('raw'); + $formattedLink = $formatter->format($link); $data = array( - 'link' => $link, + 'link' => $formattedLink, 'link_is_new' => false, 'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''), - 'tags' => $LINKSDB->linksCountPerTag(), + 'tags' => $bookmarkService->bookmarksCountPerTag(), ); $pluginManager->executeHooks('render_editlink', $data); @@ -1346,10 +1350,9 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $link_is_new = false; // Check if URL is not already in database (in this case, we will edit the existing link) - $link = $LINKSDB->getLinkFromUrl($url); - if (! $link) { + $bookmark = $bookmarkService->findByUrl($url); + if (! $bookmark) { $link_is_new = true; - $linkdate = strval(date(LinkDB::LINK_DATE_FORMAT)); // Get title if it was provided in URL (by the bookmarklet). $title = empty($_GET['title']) ? '' : escape($_GET['title']); // Get description if it was provided in URL (by the bookmarklet). [Bronco added that] @@ -1375,32 +1378,32 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, } if ($url == '') { - $url = '?' . smallHash($linkdate . $LINKSDB->getNextId()); $title = $conf->get('general.default_note_title', t('Note: ')); } $url = escape($url); $title = escape($title); - $link = array( - 'linkdate' => $linkdate, + $link = [ 'title' => $title, 'url' => $url, 'description' => $description, 'tags' => $tags, 'private' => $private, - ); + ]; } else { - $link['linkdate'] = $link['created']->format(LinkDB::LINK_DATE_FORMAT); + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter('raw'); + $link = $formatter->format($bookmark); } - $data = array( + $data = [ 'link' => $link, 'link_is_new' => $link_is_new, 'http_referer' => (isset($_SERVER['HTTP_REFERER']) ? escape($_SERVER['HTTP_REFERER']) : ''), 'source' => (isset($_GET['source']) ? $_GET['source'] : ''), - 'tags' => $LINKSDB->linksCountPerTag(), + 'tags' => $bookmarkService->bookmarksCountPerTag(), 'default_private_links' => $conf->get('privacy.default_private_links', false), - ); + ]; $pluginManager->executeHooks('render_editlink', $data); foreach ($data as $key => $value) { @@ -1413,7 +1416,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, } if ($targetPage == Router::$PAGE_PINLINK) { - if (! isset($_GET['id']) || empty($LINKSDB[$_GET['id']])) { + if (! isset($_GET['id']) || !$bookmarkService->exists($_GET['id'])) { // FIXME! Use a proper error system. $msg = t('Invalid link ID provided'); echo ''; @@ -1423,16 +1426,15 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, die('Wrong token.'); } - $link = $LINKSDB[$_GET['id']]; - $link['sticky'] = ! $link['sticky']; - $LINKSDB[(int) $_GET['id']] = $link; - $LINKSDB->save($conf->get('resource.page_cache')); + $link = $bookmarkService->get($_GET['id']); + $link->setSticky(! $link->isSticky()); + $bookmarkService->set($link); header('Location: '.index_url($_SERVER)); exit; } if ($targetPage == Router::$PAGE_EXPORT) { - // Export links as a Netscape Bookmarks file + // Export bookmarks as a Netscape Bookmarks file if (empty($_GET['selection'])) { $PAGE->assign('pagetitle', t('Export') .' - '. $conf->get('general.title', 'Shaarli')); @@ -1449,10 +1451,13 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, } try { + $factory = new FormatterFactory($conf); + $formatter = $factory->getFormatter('raw'); $PAGE->assign( 'links', NetscapeBookmarkUtils::filterAndFormat( - $LINKSDB, + $bookmarkService, + $formatter, $selection, $prependNoteUrl, index_url($_SERVER) @@ -1467,7 +1472,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, header('Content-Type: text/html; charset=utf-8'); header( 'Content-disposition: attachment; filename=bookmarks_' - .$selection.'_'.$now->format(LinkDB::LINK_DATE_FORMAT).'.html' + .$selection.'_'.$now->format(Bookmark::LINK_DATE_FORMAT).'.html' ); $PAGE->assign('date', $now->format(DateTime::RFC822)); $PAGE->assign('eol', PHP_EOL); @@ -1521,7 +1526,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, $status = NetscapeBookmarkUtils::import( $_POST, $_FILES, - $LINKSDB, + $bookmarkService, $conf, $history ); @@ -1592,19 +1597,19 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, // Get a fresh token if ($targetPage == Router::$GET_TOKEN) { header('Content-Type:text/plain'); - echo $sessionManager->generateToken($conf); + echo $sessionManager->generateToken(); exit; } // -------- Thumbnails Update if ($targetPage == Router::$PAGE_THUMBS_UPDATE) { $ids = []; - foreach ($LINKSDB as $link) { + foreach ($bookmarkService->search() as $bookmark) { // A note or not HTTP(S) - if (is_note($link['url']) || ! startsWith(strtolower($link['url']), 'http')) { + if ($bookmark->isNote() || ! startsWith(strtolower($bookmark->getUrl()), 'http')) { continue; } - $ids[] = $link['id']; + $ids[] = $bookmark->getId(); } $PAGE->assign('ids', $ids); $PAGE->assign('pagetitle', t('Thumbnails update') .' - '. $conf->get('general.title', 'Shaarli')); @@ -1619,37 +1624,40 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history, $sessionManager, exit; } $id = (int) $_POST['id']; - if (empty($LINKSDB[$id])) { + if (! $bookmarkService->exists($id)) { http_response_code(404); exit; } $thumbnailer = new Thumbnailer($conf); - $link = $LINKSDB[$id]; - $link['thumbnail'] = $thumbnailer->get($link['url']); - $LINKSDB[$id] = $link; - $LINKSDB->save($conf->get('resource.page_cache')); + $bookmark = $bookmarkService->get($id); + $bookmark->setThumbnail($thumbnailer->get($bookmark->getUrl())); + $bookmarkService->set($bookmark); - echo json_encode($link); + $factory = new FormatterFactory($conf); + echo json_encode($factory->getFormatter('raw')->format($bookmark)); exit; } - // -------- Otherwise, simply display search form and links: - showLinkList($PAGE, $LINKSDB, $conf, $pluginManager, $loginManager); + // -------- Otherwise, simply display search form and bookmarks: + showLinkList($PAGE, $bookmarkService, $conf, $pluginManager, $loginManager); exit; } /** - * Template for the list of links ( +
+
+ +
+
+
+
+ +
+
diff --git a/tpl/default/editlink.html b/tpl/default/editlink.html index df14535d..d16059a3 100644 --- a/tpl/default/editlink.html +++ b/tpl/default/editlink.html @@ -11,7 +11,6 @@

{if="!$link_is_new"}{'Edit Shaare'|t}{else}{'New Shaare'|t}{/if}

- {if="isset($link.id)"} {/if} @@ -20,7 +19,7 @@
- +
@@ -50,6 +49,15 @@  
+ {if="$formatter==='markdown'"} +
+ {'Description will be rendered with'|t} + + {'Markdown syntax'|t} + . +
+ {/if} + ', - $data['links'][0]['description'] - ); - } - - /** - * Test render_daily hook. - * Only check that there is basic markdown rendering. - */ - public function testMarkdownDaily() - { - $markdown = '# My title' . PHP_EOL . 'Very interesting content.'; - $data = array( - // Columns data - 'linksToDisplay' => array( - // nth link - 0 => array( - 'formatedDescription' => $markdown, - ), - ), - ); - - $data = hook_markdown_render_daily($data, $this->conf); - $this->assertNotFalse(strpos($data['linksToDisplay'][0]['formatedDescription'], '

')); - $this->assertNotFalse(strpos($data['linksToDisplay'][0]['formatedDescription'], '

')); - } - - /** - * Test reverse_text2clickable(). - */ - public function testReverseText2clickable() - { - $text = 'stuff http://hello.there/is=someone#here otherstuff'; - $clickableText = text2clickable($text); - $reversedText = reverse_text2clickable($clickableText); - $this->assertEquals($text, $reversedText); - } - - /** - * Test reverse_text2clickable(). - */ - public function testReverseText2clickableHashtags() - { - $text = file_get_contents('tests/plugins/resources/hashtags.raw'); - $md = file_get_contents('tests/plugins/resources/hashtags.md'); - $clickableText = hashtag_autolink($text); - $reversedText = reverse_text2clickable($clickableText); - $this->assertEquals($md, $reversedText); - } - - /** - * Test reverse_nl2br(). - */ - public function testReverseNl2br() - { - $text = 'stuff' . PHP_EOL . 'otherstuff'; - $processedText = nl2br($text); - $reversedText = reverse_nl2br($processedText); - $this->assertEquals($text, $reversedText); - } - - /** - * Test reverse_space2nbsp(). - */ - public function testReverseSpace2nbsp() - { - $text = ' stuff' . PHP_EOL . ' otherstuff and another'; - $processedText = space2nbsp($text); - $reversedText = reverse_space2nbsp($processedText); - $this->assertEquals($text, $reversedText); - } - - public function testReverseFeedPermalink() - { - $text = 'Description... '; - $text .= '— Permalien'; - $expected = 'Description... — [Permalien](http://domain.tld/?0oc_VQ)'; - $processedText = reverse_feed_permalink($text); - - $this->assertEquals($expected, $processedText); - } - - public function testReverseFeedDirectLink() - { - $text = 'Description... '; - $text .= '— Direct link'; - $expected = 'Description... — [Direct link](http://domain.tld/?0oc_VQ)'; - $processedText = reverse_feed_permalink($text); - - $this->assertEquals($expected, $processedText); - } - - public function testReverseLastFeedPermalink() - { - $text = 'Description... '; - $text .= '
Permalien'; - $expected = $text; - $text .= '
Permalien'; - $expected .= '
— [Permalien](http://domain.tld/?0oc_VQ)'; - $processedText = reverse_feed_permalink($text); - - $this->assertEquals($expected, $processedText); - } - - public function testReverseNoFeedPermalink() - { - $text = 'Hello! Where are you from?'; - $expected = $text; - $processedText = reverse_feed_permalink($text); - - $this->assertEquals($expected, $processedText); - } - - /** - * Test sanitize_html(). - */ - public function testSanitizeHtml() - { - $input = '< script src="js.js"/>'; - $input .= '< script attr>alert(\'xss\');'; - $input .= ''; - $output = escape($input); - $input .= 'link'; - $output .= 'link'; - $input .= 'link'; - $output .= 'link'; - $this->assertEquals($output, sanitize_html($input)); - // Do not touch escaped HTML. - $input = escape($input); - $this->assertEquals($input, sanitize_html($input)); - } - - /** - * Test the no markdown tag. - */ - public function testNoMarkdownTag() - { - $str = 'All _work_ and `no play` makes Jack a *dull* boy.'; - $data = array( - 'links' => array(array( - 'description' => $str, - 'tags' => NO_MD_TAG, - 'taglist' => array(NO_MD_TAG), - )) - ); - - $processed = hook_markdown_render_linklist($data, $this->conf); - $this->assertEquals($str, $processed['links'][0]['description']); - - $processed = hook_markdown_render_feed($data, $this->conf); - $this->assertEquals($str, $processed['links'][0]['description']); - - $data = array( - // Columns data - 'linksToDisplay' => array( - // nth link - 0 => array( - 'formatedDescription' => $str, - 'tags' => NO_MD_TAG, - 'taglist' => array(), - ), - ), - ); - - $data = hook_markdown_render_daily($data, $this->conf); - $this->assertEquals($str, $data['linksToDisplay'][0]['formatedDescription']); - } - - /** - * Test that a close value to nomarkdown is not understand as nomarkdown (previous value `.nomarkdown`). - */ - public function testNoMarkdownNotExcactlyMatching() - { - $str = 'All _work_ and `no play` makes Jack a *dull* boy.'; - $data = array( - 'links' => array(array( - 'description' => $str, - 'tags' => '.' . NO_MD_TAG, - 'taglist' => array('.'. NO_MD_TAG), - )) - ); - - $data = hook_markdown_render_feed($data, $this->conf); - $this->assertContains('', $data['links'][0]['description']); - } - - /** - * Make sure that the generated HTML match the reference HTML file. - */ - public function testMarkdownGlobalProcessDescription() - { - $md = file_get_contents('tests/plugins/resources/markdown.md'); - $md = format_description($md); - $html = file_get_contents('tests/plugins/resources/markdown.html'); - - $data = process_markdown( - $md, - $this->conf->get('security.markdown_escape', true), - $this->conf->get('security.allowed_protocols') - ); - $this->assertEquals($html, $data . PHP_EOL); - } - - /** - * Make sure that the HTML tags are escaped. - */ - public function testMarkdownWithHtmlEscape() - { - $md = '**strong** strong'; - $html = '

strong <strong>strong</strong>

'; - $data = array( - 'links' => array( - 0 => array( - 'description' => $md, - ), - ), - ); - $data = hook_markdown_render_linklist($data, $this->conf); - $this->assertEquals($html, $data['links'][0]['description']); - } - - /** - * Make sure that the HTML tags aren't escaped with the setting set to false. - */ - public function testMarkdownWithHtmlNoEscape() - { - $this->conf->set('security.markdown_escape', false); - $md = '**strong** strong'; - $html = '

strong strong

'; - $data = array( - 'links' => array( - 0 => array( - 'description' => $md, - ), - ), - ); - $data = hook_markdown_render_linklist($data, $this->conf); - $this->assertEquals($html, $data['links'][0]['description']); - } -} diff --git a/tests/updater/DummyUpdater.php b/tests/updater/DummyUpdater.php index 9e866f1f..07c7f5c4 100644 --- a/tests/updater/DummyUpdater.php +++ b/tests/updater/DummyUpdater.php @@ -4,6 +4,7 @@ namespace Shaarli\Updater; use Exception; use ReflectionClass; use ReflectionMethod; +use Shaarli\Bookmark\BookmarkFileService; use Shaarli\Bookmark\LinkDB; use Shaarli\Config\ConfigManager; @@ -16,14 +17,14 @@ class DummyUpdater extends Updater /** * Object constructor. * - * @param array $doneUpdates Updates which are already done. - * @param LinkDB $linkDB LinkDB instance. - * @param ConfigManager $conf Configuration Manager instance. - * @param boolean $isLoggedIn True if the user is logged in. + * @param array $doneUpdates Updates which are already done. + * @param BookmarkFileService $bookmarkService LinkDB instance. + * @param ConfigManager $conf Configuration Manager instance. + * @param boolean $isLoggedIn True if the user is logged in. */ - public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn) + public function __construct($doneUpdates, $bookmarkService, $conf, $isLoggedIn) { - parent::__construct($doneUpdates, $linkDB, $conf, $isLoggedIn); + parent::__construct($doneUpdates, $bookmarkService, $conf, $isLoggedIn); // Retrieve all update methods. // For unit test, only retrieve final methods, diff --git a/tests/updater/UpdaterTest.php b/tests/updater/UpdaterTest.php index ac87e33c..c689982b 100644 --- a/tests/updater/UpdaterTest.php +++ b/tests/updater/UpdaterTest.php @@ -1,15 +1,9 @@ assertEquals(array(), read_updates_file('')); + $this->assertEquals(array(), UpdaterUtils::read_updates_file('')); $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; touch($updatesFile); - $this->assertEquals(array(), read_updates_file($updatesFile)); + $this->assertEquals(array(), UpdaterUtils::read_updates_file($updatesFile)); unlink($updatesFile); } @@ -64,31 +58,31 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase $updatesFile = $this->conf->get('resource.data_dir') . '/updates.txt'; $updatesMethods = array('m1', 'm2', 'm3'); - write_updates_file($updatesFile, $updatesMethods); - $readMethods = read_updates_file($updatesFile); + UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); + $readMethods = UpdaterUtils::read_updates_file($updatesFile); $this->assertEquals($readMethods, $updatesMethods); // Update $updatesMethods[] = 'm4'; - write_updates_file($updatesFile, $updatesMethods); - $readMethods = read_updates_file($updatesFile); + UpdaterUtils::write_updates_file($updatesFile, $updatesMethods); + $readMethods = UpdaterUtils::read_updates_file($updatesFile); $this->assertEquals($readMethods, $updatesMethods); unlink($updatesFile); } /** - * Test errors in write_updates_file(): empty updates file. + * Test errors in UpdaterUtils::write_updates_file(): empty updates file. * * @expectedException Exception * @expectedExceptionMessageRegExp /Updates file path is not set(.*)/ */ public function testWriteEmptyUpdatesFile() { - write_updates_file('', array('test')); + UpdaterUtils::write_updates_file('', array('test')); } /** - * Test errors in write_updates_file(): not writable updates file. + * Test errors in UpdaterUtils::write_updates_file(): not writable updates file. * * @expectedException Exception * @expectedExceptionMessageRegExp /Unable to write(.*)/ @@ -99,7 +93,7 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase touch($updatesFile); chmod($updatesFile, 0444); try { - @write_updates_file($updatesFile, array('test')); + @UpdaterUtils::write_updates_file($updatesFile, array('test')); } catch (Exception $e) { unlink($updatesFile); throw $e; @@ -173,660 +167,4 @@ class UpdaterTest extends \PHPUnit\Framework\TestCase $updater = new DummyUpdater($updates, array(), $this->conf, true); $updater->update(); } - - /** - * Test update mergeDeprecatedConfig: - * 1. init a config file. - * 2. init a options.php file with update value. - * 3. merge. - * 4. check updated value in config file. - */ - public function testUpdateMergeDeprecatedConfig() - { - $this->conf->setConfigFile('tests/utils/config/configPhp'); - $this->conf->reset(); - - $optionsFile = 'tests/updater/options.php'; - $options = 'conf->setConfigFile('tests/updater/config'); - - // merge configs - $updater = new Updater(array(), array(), $this->conf, true); - // This writes a new config file in tests/updater/config.php - $updater->updateMethodMergeDeprecatedConfigFile(); - - // make sure updated field is changed - $this->conf->reload(); - $this->assertTrue($this->conf->get('privacy.default_private_links')); - $this->assertFalse(is_file($optionsFile)); - // Delete the generated file. - unlink($this->conf->getConfigFileExt()); - } - - /** - * Test mergeDeprecatedConfig in without options file. - */ - public function testMergeDeprecatedConfigNoFile() - { - $updater = new Updater(array(), array(), $this->conf, true); - $updater->updateMethodMergeDeprecatedConfigFile(); - - $this->assertEquals('root', $this->conf->get('credentials.login')); - } - - /** - * Test renameDashTags update method. - */ - public function testRenameDashTags() - { - $refDB = new \ReferenceLinkDB(); - $refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $this->assertEmpty($linkDB->filterSearch(array('searchtags' => 'exclude'))); - $updater = new Updater(array(), $linkDB, $this->conf, true); - $updater->updateMethodRenameDashTags(); - $this->assertNotEmpty($linkDB->filterSearch(array('searchtags' => 'exclude'))); - } - - /** - * Convert old PHP config file to JSON config. - */ - public function testConfigToJson() - { - $configFile = 'tests/utils/config/configPhp'; - $this->conf->setConfigFile($configFile); - $this->conf->reset(); - - // The ConfigIO is initialized with ConfigPhp. - $this->assertTrue($this->conf->getConfigIO() instanceof ConfigPhp); - - $updater = new Updater(array(), array(), $this->conf, false); - $done = $updater->updateMethodConfigToJson(); - $this->assertTrue($done); - - // The ConfigIO has been updated to ConfigJson. - $this->assertTrue($this->conf->getConfigIO() instanceof ConfigJson); - $this->assertTrue(file_exists($this->conf->getConfigFileExt())); - - // Check JSON config data. - $this->conf->reload(); - $this->assertEquals('root', $this->conf->get('credentials.login')); - $this->assertEquals('lala', $this->conf->get('redirector.url')); - $this->assertEquals('data/datastore.php', $this->conf->get('resource.datastore')); - $this->assertEquals('1', $this->conf->get('plugins.WALLABAG_VERSION')); - - rename($configFile . '.save.php', $configFile . '.php'); - unlink($this->conf->getConfigFileExt()); - } - - /** - * Launch config conversion update with an existing JSON file => nothing to do. - */ - public function testConfigToJsonNothingToDo() - { - $filetime = filemtime($this->conf->getConfigFileExt()); - $updater = new Updater(array(), array(), $this->conf, false); - $done = $updater->updateMethodConfigToJson(); - $this->assertTrue($done); - $expected = filemtime($this->conf->getConfigFileExt()); - $this->assertEquals($expected, $filetime); - } - - /** - * Test escapeUnescapedConfig with valid data. - */ - public function testEscapeConfig() - { - $sandbox = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandbox . '.json.php'); - $this->conf = new ConfigManager($sandbox); - $title = ''; - $headerLink = ''; - $this->conf->set('general.title', $title); - $this->conf->set('general.header_link', $headerLink); - $updater = new Updater(array(), array(), $this->conf, true); - $done = $updater->updateMethodEscapeUnescapedConfig(); - $this->assertTrue($done); - $this->conf->reload(); - $this->assertEquals(escape($title), $this->conf->get('general.title')); - $this->assertEquals(escape($headerLink), $this->conf->get('general.header_link')); - unlink($sandbox . '.json.php'); - } - - /** - * Test updateMethodApiSettings(): create default settings for the API (enabled + secret). - */ - public function testUpdateApiSettings() - { - $confFile = 'sandbox/config'; - copy(self::$configFile .'.json.php', $confFile .'.json.php'); - $conf = new ConfigManager($confFile); - $updater = new Updater(array(), array(), $conf, true); - - $this->assertFalse($conf->exists('api.enabled')); - $this->assertFalse($conf->exists('api.secret')); - $updater->updateMethodApiSettings(); - $conf->reload(); - $this->assertTrue($conf->get('api.enabled')); - $this->assertTrue($conf->exists('api.secret')); - unlink($confFile .'.json.php'); - } - - /** - * Test updateMethodApiSettings(): already set, do nothing. - */ - public function testUpdateApiSettingsNothingToDo() - { - $confFile = 'sandbox/config'; - copy(self::$configFile .'.json.php', $confFile .'.json.php'); - $conf = new ConfigManager($confFile); - $conf->set('api.enabled', false); - $conf->set('api.secret', ''); - $updater = new Updater(array(), array(), $conf, true); - $updater->updateMethodApiSettings(); - $this->assertFalse($conf->get('api.enabled')); - $this->assertEmpty($conf->get('api.secret')); - unlink($confFile .'.json.php'); - } - - /** - * Test updateMethodDatastoreIds(). - */ - public function testDatastoreIds() - { - $links = array( - '20121206_182539' => array( - 'linkdate' => '20121206_182539', - 'title' => 'Geek and Poke', - 'url' => 'http://geek-and-poke.com/', - 'description' => 'desc', - 'tags' => 'dev cartoon tag1 tag2 tag3 tag4 ', - 'updated' => '20121206_190301', - 'private' => false, - ), - '20121206_172539' => array( - 'linkdate' => '20121206_172539', - 'title' => 'UserFriendly - Samba', - 'url' => 'http://ars.userfriendly.org/cartoons/?id=20010306', - 'description' => '', - 'tags' => 'samba cartoon web', - 'private' => false, - ), - '20121206_142300' => array( - 'linkdate' => '20121206_142300', - 'title' => 'UserFriendly - Web Designer', - 'url' => 'http://ars.userfriendly.org/cartoons/?id=20121206', - 'description' => 'Naming conventions... #private', - 'tags' => 'samba cartoon web', - 'private' => true, - ), - ); - $refDB = new \ReferenceLinkDB(); - $refDB->setLinks($links); - $refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $checksum = hash_file('sha1', self::$testDatastore); - - $this->conf->set('resource.data_dir', 'sandbox'); - $this->conf->set('resource.datastore', self::$testDatastore); - - $updater = new Updater(array(), $linkDB, $this->conf, true); - $this->assertTrue($updater->updateMethodDatastoreIds()); - - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $backup = glob($this->conf->get('resource.data_dir') . '/datastore.'. date('YmdH') .'*.php'); - $backup = $backup[0]; - - $this->assertFileExists($backup); - $this->assertEquals($checksum, hash_file('sha1', $backup)); - unlink($backup); - - $this->assertEquals(3, count($linkDB)); - $this->assertTrue(isset($linkDB[0])); - $this->assertFalse(isset($linkDB[0]['linkdate'])); - $this->assertEquals(0, $linkDB[0]['id']); - $this->assertEquals('UserFriendly - Web Designer', $linkDB[0]['title']); - $this->assertEquals('http://ars.userfriendly.org/cartoons/?id=20121206', $linkDB[0]['url']); - $this->assertEquals('Naming conventions... #private', $linkDB[0]['description']); - $this->assertEquals('samba cartoon web', $linkDB[0]['tags']); - $this->assertTrue($linkDB[0]['private']); - $this->assertEquals( - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_142300'), - $linkDB[0]['created'] - ); - - $this->assertTrue(isset($linkDB[1])); - $this->assertFalse(isset($linkDB[1]['linkdate'])); - $this->assertEquals(1, $linkDB[1]['id']); - $this->assertEquals('UserFriendly - Samba', $linkDB[1]['title']); - $this->assertEquals( - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_172539'), - $linkDB[1]['created'] - ); - - $this->assertTrue(isset($linkDB[2])); - $this->assertFalse(isset($linkDB[2]['linkdate'])); - $this->assertEquals(2, $linkDB[2]['id']); - $this->assertEquals('Geek and Poke', $linkDB[2]['title']); - $this->assertEquals( - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_182539'), - $linkDB[2]['created'] - ); - $this->assertEquals( - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_190301'), - $linkDB[2]['updated'] - ); - } - - /** - * Test updateMethodDatastoreIds() with the update already applied: nothing to do. - */ - public function testDatastoreIdsNothingToDo() - { - $refDB = new \ReferenceLinkDB(); - $refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $this->conf->set('resource.data_dir', 'sandbox'); - $this->conf->set('resource.datastore', self::$testDatastore); - - $checksum = hash_file('sha1', self::$testDatastore); - $updater = new Updater(array(), $linkDB, $this->conf, true); - $this->assertTrue($updater->updateMethodDatastoreIds()); - $this->assertEquals($checksum, hash_file('sha1', self::$testDatastore)); - } - - /** - * Test defaultTheme update with default settings: nothing to do. - */ - public function testDefaultThemeWithDefaultSettings() - { - $sandbox = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandbox . '.json.php'); - $this->conf = new ConfigManager($sandbox); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodDefaultTheme()); - - $this->assertEquals('tpl/', $this->conf->get('resource.raintpl_tpl')); - $this->assertEquals('default', $this->conf->get('resource.theme')); - $this->conf = new ConfigManager($sandbox); - $this->assertEquals('tpl/', $this->conf->get('resource.raintpl_tpl')); - $this->assertEquals('default', $this->conf->get('resource.theme')); - unlink($sandbox . '.json.php'); - } - - /** - * Test defaultTheme update with a custom theme in a subfolder - */ - public function testDefaultThemeWithCustomTheme() - { - $theme = 'iamanartist'; - $sandbox = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandbox . '.json.php'); - $this->conf = new ConfigManager($sandbox); - mkdir('sandbox/'. $theme); - touch('sandbox/'. $theme .'/linklist.html'); - $this->conf->set('resource.raintpl_tpl', 'sandbox/'. $theme .'/'); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodDefaultTheme()); - - $this->assertEquals('sandbox', $this->conf->get('resource.raintpl_tpl')); - $this->assertEquals($theme, $this->conf->get('resource.theme')); - $this->conf = new ConfigManager($sandbox); - $this->assertEquals('sandbox', $this->conf->get('resource.raintpl_tpl')); - $this->assertEquals($theme, $this->conf->get('resource.theme')); - unlink($sandbox . '.json.php'); - unlink('sandbox/'. $theme .'/linklist.html'); - rmdir('sandbox/'. $theme); - } - - /** - * Test updateMethodEscapeMarkdown with markdown plugin enabled - * => setting markdown_escape set to false. - */ - public function testEscapeMarkdownSettingToFalse() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - - $this->conf->set('general.enabled_plugins', ['markdown']); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodEscapeMarkdown()); - $this->assertFalse($this->conf->get('security.markdown_escape')); - - // reload from file - $this->conf = new ConfigManager($sandboxConf); - $this->assertFalse($this->conf->get('security.markdown_escape')); - } - - - /** - * Test updateMethodEscapeMarkdown with markdown plugin disabled - * => setting markdown_escape set to true. - */ - public function testEscapeMarkdownSettingToTrue() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - - $this->conf->set('general.enabled_plugins', []); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodEscapeMarkdown()); - $this->assertTrue($this->conf->get('security.markdown_escape')); - - // reload from file - $this->conf = new ConfigManager($sandboxConf); - $this->assertTrue($this->conf->get('security.markdown_escape')); - } - - /** - * Test updateMethodEscapeMarkdown with nothing to do (setting already enabled) - */ - public function testEscapeMarkdownSettingNothingToDoEnabled() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $this->conf->set('security.markdown_escape', true); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodEscapeMarkdown()); - $this->assertTrue($this->conf->get('security.markdown_escape')); - } - - /** - * Test updateMethodEscapeMarkdown with nothing to do (setting already disabled) - */ - public function testEscapeMarkdownSettingNothingToDoDisabled() - { - $this->conf->set('security.markdown_escape', false); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodEscapeMarkdown()); - $this->assertFalse($this->conf->get('security.markdown_escape')); - } - - /** - * Test updateMethodPiwikUrl with valid data - */ - public function testUpdatePiwikUrlValid() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $url = 'mypiwik.tld'; - $this->conf->set('plugins.PIWIK_URL', $url); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodPiwikUrl()); - $this->assertEquals('http://'. $url, $this->conf->get('plugins.PIWIK_URL')); - - // reload from file - $this->conf = new ConfigManager($sandboxConf); - $this->assertEquals('http://'. $url, $this->conf->get('plugins.PIWIK_URL')); - } - - /** - * Test updateMethodPiwikUrl without setting - */ - public function testUpdatePiwikUrlEmpty() - { - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodPiwikUrl()); - $this->assertEmpty($this->conf->get('plugins.PIWIK_URL')); - } - - /** - * Test updateMethodPiwikUrl: valid URL, nothing to do - */ - public function testUpdatePiwikUrlNothingToDo() - { - $url = 'https://mypiwik.tld'; - $this->conf->set('plugins.PIWIK_URL', $url); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodPiwikUrl()); - $this->assertEquals($url, $this->conf->get('plugins.PIWIK_URL')); - } - - /** - * Test updateMethodAtomDefault with show_atom set to false - * => update to true. - */ - public function testUpdateMethodAtomDefault() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $this->conf->set('feed.show_atom', false); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodAtomDefault()); - $this->assertTrue($this->conf->get('feed.show_atom')); - // reload from file - $this->conf = new ConfigManager($sandboxConf); - $this->assertTrue($this->conf->get('feed.show_atom')); - } - /** - * Test updateMethodAtomDefault with show_atom not set. - * => nothing to do - */ - public function testUpdateMethodAtomDefaultNoExist() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodAtomDefault()); - $this->assertTrue($this->conf->get('feed.show_atom')); - } - /** - * Test updateMethodAtomDefault with show_atom set to true. - * => nothing to do - */ - public function testUpdateMethodAtomDefaultAlreadyTrue() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $this->conf->set('feed.show_atom', true); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodAtomDefault()); - $this->assertTrue($this->conf->get('feed.show_atom')); - } - - /** - * Test updateMethodDownloadSizeAndTimeoutConf, it should be set if none is already defined. - */ - public function testUpdateMethodDownloadSizeAndTimeoutConf() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf()); - $this->assertEquals(4194304, $this->conf->get('general.download_max_size')); - $this->assertEquals(30, $this->conf->get('general.download_timeout')); - - $this->conf = new ConfigManager($sandboxConf); - $this->assertEquals(4194304, $this->conf->get('general.download_max_size')); - $this->assertEquals(30, $this->conf->get('general.download_timeout')); - } - - /** - * Test updateMethodDownloadSizeAndTimeoutConf, it shouldn't be set if it is already defined. - */ - public function testUpdateMethodDownloadSizeAndTimeoutConfIgnore() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $this->conf->set('general.download_max_size', 38); - $this->conf->set('general.download_timeout', 70); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf()); - $this->assertEquals(38, $this->conf->get('general.download_max_size')); - $this->assertEquals(70, $this->conf->get('general.download_timeout')); - } - - /** - * Test updateMethodDownloadSizeAndTimeoutConf, only the maz size should be set here. - */ - public function testUpdateMethodDownloadSizeAndTimeoutConfOnlySize() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $this->conf->set('general.download_max_size', 38); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf()); - $this->assertEquals(38, $this->conf->get('general.download_max_size')); - $this->assertEquals(30, $this->conf->get('general.download_timeout')); - } - - /** - * Test updateMethodDownloadSizeAndTimeoutConf, only the time out should be set here. - */ - public function testUpdateMethodDownloadSizeAndTimeoutConfOnlyTimeout() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $this->conf->set('general.download_timeout', 3); - $updater = new Updater([], [], $this->conf, true); - $this->assertTrue($updater->updateMethodDownloadSizeAndTimeoutConf()); - $this->assertEquals(4194304, $this->conf->get('general.download_max_size')); - $this->assertEquals(3, $this->conf->get('general.download_timeout')); - } - - /** - * Test updateMethodWebThumbnailer with thumbnails enabled. - */ - public function testUpdateMethodWebThumbnailerEnabled() - { - $this->conf->remove('thumbnails'); - $this->conf->set('thumbnail.enable_thumbnails', true); - $updater = new Updater([], [], $this->conf, true, $_SESSION); - $this->assertTrue($updater->updateMethodWebThumbnailer()); - $this->assertFalse($this->conf->exists('thumbnail')); - $this->assertEquals(\Shaarli\Thumbnailer::MODE_ALL, $this->conf->get('thumbnails.mode')); - $this->assertEquals(125, $this->conf->get('thumbnails.width')); - $this->assertEquals(90, $this->conf->get('thumbnails.height')); - $this->assertContains('You have enabled or changed thumbnails', $_SESSION['warnings'][0]); - } - - /** - * Test updateMethodWebThumbnailer with thumbnails disabled. - */ - public function testUpdateMethodWebThumbnailerDisabled() - { - if (isset($_SESSION['warnings'])) { - unset($_SESSION['warnings']); - } - $this->conf->remove('thumbnails'); - $this->conf->set('thumbnail.enable_thumbnails', false); - $updater = new Updater([], [], $this->conf, true, $_SESSION); - $this->assertTrue($updater->updateMethodWebThumbnailer()); - $this->assertFalse($this->conf->exists('thumbnail')); - $this->assertEquals(Thumbnailer::MODE_NONE, $this->conf->get('thumbnails.mode')); - $this->assertEquals(125, $this->conf->get('thumbnails.width')); - $this->assertEquals(90, $this->conf->get('thumbnails.height')); - $this->assertTrue(empty($_SESSION['warnings'])); - } - - /** - * Test updateMethodWebThumbnailer with thumbnails disabled. - */ - public function testUpdateMethodWebThumbnailerNothingToDo() - { - if (isset($_SESSION['warnings'])) { - unset($_SESSION['warnings']); - } - $updater = new Updater([], [], $this->conf, true, $_SESSION); - $this->assertTrue($updater->updateMethodWebThumbnailer()); - $this->assertFalse($this->conf->exists('thumbnail')); - $this->assertEquals(Thumbnailer::MODE_COMMON, $this->conf->get('thumbnails.mode')); - $this->assertEquals(90, $this->conf->get('thumbnails.width')); - $this->assertEquals(53, $this->conf->get('thumbnails.height')); - $this->assertTrue(empty($_SESSION['warnings'])); - } - - /** - * Test updateMethodSetSticky(). - */ - public function testUpdateStickyValid() - { - $blank = [ - 'id' => 1, - 'url' => 'z', - 'title' => '', - 'description' => '', - 'tags' => '', - 'created' => new DateTime(), - ]; - $links = [ - 1 => ['id' => 1] + $blank, - 2 => ['id' => 2] + $blank, - ]; - $refDB = new \ReferenceLinkDB(); - $refDB->setLinks($links); - $refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $updater = new Updater(array(), $linkDB, $this->conf, true); - $this->assertTrue($updater->updateMethodSetSticky()); - - $linkDB = new LinkDB(self::$testDatastore, true, false); - foreach ($linkDB as $link) { - $this->assertFalse($link['sticky']); - } - } - - /** - * Test updateMethodSetSticky(). - */ - public function testUpdateStickyNothingToDo() - { - $blank = [ - 'id' => 1, - 'url' => 'z', - 'title' => '', - 'description' => '', - 'tags' => '', - 'created' => new DateTime(), - ]; - $links = [ - 1 => ['id' => 1, 'sticky' => true] + $blank, - 2 => ['id' => 2] + $blank, - ]; - $refDB = new \ReferenceLinkDB(); - $refDB->setLinks($links); - $refDB->write(self::$testDatastore); - $linkDB = new LinkDB(self::$testDatastore, true, false); - - $updater = new Updater(array(), $linkDB, $this->conf, true); - $this->assertTrue($updater->updateMethodSetSticky()); - - $linkDB = new LinkDB(self::$testDatastore, true, false); - $this->assertTrue($linkDB[1]['sticky']); - } - - /** - * Test updateMethodRemoveRedirector(). - */ - public function testUpdateRemoveRedirector() - { - $sandboxConf = 'sandbox/config'; - copy(self::$configFile . '.json.php', $sandboxConf . '.json.php'); - $this->conf = new ConfigManager($sandboxConf); - $updater = new Updater([], null, $this->conf, true); - $this->assertTrue($updater->updateMethodRemoveRedirector()); - $this->assertFalse($this->conf->exists('redirector')); - $this->conf = new ConfigManager($sandboxConf); - $this->assertFalse($this->conf->exists('redirector')); - } } diff --git a/tests/utils/FakeBookmarkService.php b/tests/utils/FakeBookmarkService.php new file mode 100644 index 00000000..1ec5bc3d --- /dev/null +++ b/tests/utils/FakeBookmarkService.php @@ -0,0 +1,18 @@ +bookmarks; + } +} diff --git a/tests/utils/ReferenceHistory.php b/tests/utils/ReferenceHistory.php index e411c417..516c9f51 100644 --- a/tests/utils/ReferenceHistory.php +++ b/tests/utils/ReferenceHistory.php @@ -76,7 +76,7 @@ class ReferenceHistory } /** - * Returns the number of links in the reference data + * Returns the number of bookmarks in the reference data */ public function count() { diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php index c12bcb67..0095f5a1 100644 --- a/tests/utils/ReferenceLinkDB.php +++ b/tests/utils/ReferenceLinkDB.php @@ -1,30 +1,39 @@ isLegacy = $isLegacy; + if (! $this->isLegacy) { + $this->bookmarks = new BookmarkArray(); + } $this->addLink( 11, 'Pined older', '?PCRizQ', 'This is an older pinned link', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20100309_101010'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20100309_101010'), '', null, 'PCRizQ', @@ -37,7 +46,7 @@ class ReferenceLinkDB '?0gCTjQ', 'This is a pinned link', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121207_152312'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121207_152312'), '', null, '0gCTjQ', @@ -50,7 +59,7 @@ class ReferenceLinkDB '?WDWyig', 'Stallman has a beard and is part of the Free Software Foundation (or not). Seriously, read this. #hashtag', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114651'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114651'), 'sTuff', null, 'WDWyig' @@ -60,9 +69,9 @@ class ReferenceLinkDB 42, 'Note: I have a big ID but an old date', '?WDWyig', - 'Used to test links reordering.', + 'Used to test bookmarks reordering.', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20100310_101010'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20100310_101010'), 'ut' ); @@ -72,7 +81,7 @@ class ReferenceLinkDB 'http://www.php-fig.org/psr/psr-2/', 'This guide extends and expands on PSR-1, the basic coding standard.', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_152312'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121206_152312'), '' ); @@ -82,9 +91,9 @@ class ReferenceLinkDB 'https://static.fsf.org/nosvn/faif-2.0.pdf', 'Richard Stallman and the Free Software Revolution. Read this. #hashtag', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20150310_114633'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20150310_114633'), 'free gnu software stallman -exclude stuff hashtag', - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20160803_093033') + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20160803_093033') ); $this->addLink( @@ -93,9 +102,9 @@ class ReferenceLinkDB 'http://mediagoblin.org/', 'A free software media publishing platform #hashtagOther', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130614_184135'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20130614_184135'), 'gnu media web .hidden hashtag', - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20130615_184230'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20130615_184230'), 'IuWvgA' ); @@ -105,7 +114,7 @@ class ReferenceLinkDB 'https://dvcs.w3.org/hg/markup-validator/summary', 'Mercurial repository for the W3C Validator #private', 1, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20141125_084734'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20141125_084734'), 'css html w3c web Mercurial' ); @@ -115,7 +124,7 @@ class ReferenceLinkDB 'http://ars.userfriendly.org/cartoons/?id=20121206', 'Naming conventions... #private', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_142300'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121206_142300'), 'dev cartoon web' ); @@ -125,7 +134,7 @@ class ReferenceLinkDB 'http://ars.userfriendly.org/cartoons/?id=20010306', 'Tropical printing', 0, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_172539'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121206_172539'), 'samba cartoon web' ); @@ -135,7 +144,7 @@ class ReferenceLinkDB 'http://geek-and-poke.com/', '', 1, - DateTime::createFromFormat(LinkDB::LINK_DATE_FORMAT, '20121206_182539'), + DateTime::createFromFormat(Bookmark::LINK_DATE_FORMAT, '20121206_182539'), 'dev cartoon tag1 tag2 tag3 tag4 ' ); } @@ -164,10 +173,15 @@ class ReferenceLinkDB 'tags' => $tags, 'created' => $date, 'updated' => $updated, - 'shorturl' => $shorturl ? $shorturl : smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id), + 'shorturl' => $shorturl ? $shorturl : smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id), 'sticky' => $pinned ); - $this->_links[$id] = $link; + if (! $this->isLegacy) { + $bookmark = new Bookmark(); + $this->bookmarks[$id] = $bookmark->fromArray($link); + } else { + $this->bookmarks[$id] = $link; + } if ($private) { $this->_privateCount++; @@ -184,37 +198,38 @@ class ReferenceLinkDB $this->reorder(); file_put_contents( $filename, - '_links))).' */ ?>' + 'bookmarks))).' */ ?>' ); } /** * 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') { - // backward compatibility: ignore reorder if the the `created` field doesn't exist - if (! isset(array_values($this->_links)[0]['created'])) { - return; - } - - $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 (! $this->isLegacy) { + $this->bookmarks->reorder($order); + } else { + $order = $order === 'ASC' ? -1 : 1; + // backward compatibility: ignore reorder if the the `created` field doesn't exist + if (! isset(array_values($this->bookmarks)[0]['created'])) { + return; } - return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; - }); + usort($this->bookmarks, function ($a, $b) use ($order) { + if (isset($a['sticky']) && isset($b['sticky']) && $a['sticky'] !== $b['sticky']) { + return $a['sticky'] ? -1 : 1; + } + + return $a['created'] < $b['created'] ? 1 * $order : -1 * $order; + }); + } } /** - * Returns the number of links in the reference data + * Returns the number of bookmarks in the reference data */ public function countLinks() { @@ -222,7 +237,7 @@ class ReferenceLinkDB } /** - * Returns the number of public links in the reference data + * Returns the number of public bookmarks in the reference data */ public function countPublicLinks() { @@ -230,7 +245,7 @@ class ReferenceLinkDB } /** - * Returns the number of private links in the reference data + * Returns the number of private bookmarks in the reference data */ public function countPrivateLinks() { @@ -238,14 +253,20 @@ class ReferenceLinkDB } /** - * Returns the number of links without tag + * Returns the number of bookmarks without tag */ public function countUntaggedLinks() { $cpt = 0; - foreach ($this->_links as $link) { - if (empty($link['tags'])) { - ++$cpt; + foreach ($this->bookmarks as $link) { + if (! $this->isLegacy) { + if (empty($link->getTags())) { + ++$cpt; + } + } else { + if (empty($link['tags'])) { + ++$cpt; + } } } return $cpt; @@ -254,16 +275,16 @@ class ReferenceLinkDB public function getLinks() { $this->reorder(); - return $this->_links; + return $this->bookmarks; } /** * Setter to override link creation. * - * @param array $links List of links. + * @param array $links List of bookmarks. */ public function setLinks($links) { - $this->_links = $links; + $this->bookmarks = $links; } } diff --git a/tests/utils/config/configJson.json.php b/tests/utils/config/configJson.json.php index 1549ddfc..b04dc303 100644 --- a/tests/utils/config/configJson.json.php +++ b/tests/utils/config/configJson.json.php @@ -41,12 +41,12 @@ "foo": "bar" }, "resource": { - "datastore": "tests\/utils\/config\/datastore.php", + "datastore": "sandbox/datastore.php", "data_dir": "sandbox\/", "raintpl_tpl": "tpl\/", "config": "data\/config.php", "ban_file": "data\/ipbans.php", - "updates": "data\/updates.txt", + "updates": "sandbox/updates.txt", "log": "data\/log.txt", "update_check": "data\/lastupdatecheck.txt", "history": "data\/history.php", @@ -59,7 +59,7 @@ "WALLABAG_VERSION": 1 }, "dev": { - "debug": true + "debug": false }, "updates": { "check_updates": false, -- cgit v1.2.3