--- /dev/null
+<?php
+
+namespace Shaarli\Bookmark;
+
+use DateTime;
+use Shaarli\Bookmark\Exception\InvalidBookmarkException;
+
+/**
+ * Class Bookmark
+ *
+ * This class represent a single Bookmark with all its attributes.
+ * Every bookmark should manipulated using this, before being formatted.
+ *
+ * @package Shaarli\Bookmark
+ */
+class Bookmark
+{
+ /** @var string Date format used in string (former ID format) */
+ const LINK_DATE_FORMAT = 'Ymd_His';
+
+ /** @var int Bookmark ID */
+ protected $id;
+
+ /** @var string Permalink identifier */
+ protected $shortUrl;
+
+ /** @var string Bookmark's URL - $shortUrl prefixed with `?` for notes */
+ protected $url;
+
+ /** @var string Bookmark's title */
+ protected $title;
+
+ /** @var string Raw bookmark's description */
+ protected $description;
+
+ /** @var array List of bookmark's tags */
+ protected $tags;
+
+ /** @var string Thumbnail's URL - false if no thumbnail could be found */
+ protected $thumbnail;
+
+ /** @var bool Set to true if the bookmark is set as sticky */
+ protected $sticky;
+
+ /** @var DateTime Creation datetime */
+ protected $created;
+
+ /** @var DateTime Update datetime */
+ protected $updated;
+
+ /** @var bool True if the bookmark can only be seen while logged in */
+ protected $private;
+
+ /**
+ * Initialize a link from array data. Especially useful to create a Bookmark from former link storage format.
+ *
+ * @param array $data
+ *
+ * @return $this
+ */
+ public function fromArray($data)
+ {
+ $this->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);
+ }
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Bookmark;
+
+use Shaarli\Bookmark\Exception\InvalidBookmarkException;
+
+/**
+ * Class BookmarkArray
+ *
+ * Implementing ArrayAccess, this allows us to use the bookmark list
+ * as an array and iterate over it.
+ *
+ * @package Shaarli\Bookmark
+ */
+class BookmarkArray implements \Iterator, \Countable, \ArrayAccess
+{
+ /**
+ * @var Bookmark[]
+ */
+ protected $bookmarks;
+
+ /**
+ * @var array List of all bookmarks IDS mapped with their array offset.
+ * Map: id->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;
+ }
+ }
+}
--- /dev/null
+<?php
+
+
+namespace Shaarli\Bookmark;
+
+
+use Exception;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Bookmark\Exception\EmptyDataStoreException;
+use Shaarli\Config\ConfigManager;
+use Shaarli\History;
+use Shaarli\Legacy\LegacyLinkDB;
+use Shaarli\Legacy\LegacyUpdater;
+use Shaarli\Updater\UpdaterUtils;
+
+/**
+ * Class BookmarksService
+ *
+ * This is the entry point to manipulate the bookmark DB.
+ * It manipulates loads links from a file data store containing all bookmarks.
+ *
+ * It also triggers the legacy format (bookmarks as arrays) migration.
+ */
+class BookmarkFileService implements BookmarkServiceInterface
+{
+ /** @var Bookmark[] instance */
+ protected $bookmarks;
+
+ /** @var BookmarkIO instance */
+ protected $bookmarksIO;
+
+ /** @var BookmarkFilter */
+ protected $bookmarkFilter;
+
+ /** @var ConfigManager instance */
+ protected $conf;
+
+ /** @var History instance */
+ protected $history;
+
+ /** @var bool true for logged in users. Default value to retrieve private bookmarks. */
+ protected $isLoggedIn;
+
+ /**
+ * @inheritDoc
+ */
+ public function __construct(ConfigManager $conf, History $history, $isLoggedIn)
+ {
+ $this->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()
+ );
+ }
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Bookmark;
+
+use Exception;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+
+/**
+ * Class LinkFilter.
+ *
+ * Perform search and filter operation on link data list.
+ */
+class BookmarkFilter
+{
+ /**
+ * @var string permalinks.
+ */
+ public static $FILTER_HASH = 'permalink';
+
+ /**
+ * @var string text search.
+ */
+ public static $FILTER_TEXT = 'fulltext';
+
+ /**
+ * @var string tag filter.
+ */
+ public static $FILTER_TAG = 'tags';
+
+ /**
+ * @var string filter by day.
+ */
+ public static $FILTER_DAY = 'FILTER_DAY';
+
+ /**
+ * @var string filter by day.
+ */
+ public static $DEFAULT = 'NO_FILTER';
+
+ /** @var string Visibility: all */
+ public static $ALL = 'all';
+
+ /** @var string Visibility: public */
+ public static $PUBLIC = 'public';
+
+ /** @var string Visibility: private */
+ public static $PRIVATE = 'private';
+
+ /**
+ * @var string Allowed characters for hashtags (regex syntax).
+ */
+ public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
+
+ /**
+ * @var Bookmark[] all available bookmarks.
+ */
+ private $bookmarks;
+
+ /**
+ * @param Bookmark[] $bookmarks initialization.
+ */
+ public function __construct($bookmarks)
+ {
+ $this->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(
+ '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
+ $link->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);
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Bookmark;
+
+use Shaarli\Bookmark\Exception\EmptyDataStoreException;
+use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkIO
+ *
+ * This class performs read/write operation to the file data store.
+ * Used by BookmarkFileService.
+ *
+ * @package Shaarli\Bookmark
+ */
+class BookmarkIO
+{
+ /**
+ * @var string Datastore file path
+ */
+ protected $datastore;
+
+ /**
+ * @var ConfigManager instance
+ */
+ protected $conf;
+
+ /**
+ * string Datastore PHP prefix
+ */
+ protected static $phpPrefix = '<?php /* ';
+
+ /**
+ * string Datastore PHP suffix
+ */
+ protected static $phpSuffix = ' */ ?>';
+
+ /**
+ * 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'));
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Bookmark;
+
+/**
+ * Class BookmarkInitializer
+ *
+ * This class is used to initialized default bookmarks after a fresh install of Shaarli.
+ * It is no longer call when the data store is empty,
+ * because user might want to delete default bookmarks after the install.
+ *
+ * To prevent data corruption, it does not overwrite existing bookmarks,
+ * even though there should not be any.
+ *
+ * @package Shaarli\Bookmark
+ */
+class BookmarkInitializer
+{
+ /** @var BookmarkServiceInterface */
+ protected $bookmarkService;
+
+ /**
+ * BookmarkInitializer constructor.
+ *
+ * @param BookmarkServiceInterface $bookmarkService
+ */
+ public function __construct($bookmarkService)
+ {
+ $this->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);
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Bookmark;
+
+
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
+use Shaarli\Bookmark\Exception\NotWritableDataStoreException;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Exceptions\IOException;
+use Shaarli\History;
+
+/**
+ * Class BookmarksService
+ *
+ * This is the entry point to manipulate the bookmark DB.
+ */
+interface BookmarkServiceInterface
+{
+ /**
+ * BookmarksService constructor.
+ *
+ * @param ConfigManager $conf instance
+ * @param History $history instance
+ * @param bool $isLoggedIn true if the current user is logged in
+ */
+ public function __construct(ConfigManager $conf, History $history, $isLoggedIn);
+
+ /**
+ * Find a bookmark by hash
+ *
+ * @param string $hash
+ *
+ * @return mixed
+ *
+ * @throws \Exception
+ */
+ public function findByHash($hash);
+
+ /**
+ * @param $url
+ *
+ * @return Bookmark|null
+ */
+ public function findByUrl($url);
+
+ /**
+ * Search bookmarks
+ *
+ * @param mixed $request
+ * @param string $visibility
+ * @param bool $caseSensitive
+ * @param bool $untaggedOnly
+ *
+ * @return Bookmark[]
+ */
+ public function search($request = [], $visibility = null, $caseSensitive = false, $untaggedOnly = false);
+
+ /**
+ * Get a single bookmark by its ID.
+ *
+ * @param int $id Bookmark ID
+ * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+ * exception
+ *
+ * @return Bookmark
+ *
+ * @throws BookmarkNotFoundException
+ * @throws \Exception
+ */
+ public function get($id, $visibility = null);
+
+ /**
+ * Updates an existing bookmark (depending on its ID).
+ *
+ * @param Bookmark $bookmark
+ * @param bool $save Writes to the datastore if set to true
+ *
+ * @return Bookmark Updated bookmark
+ *
+ * @throws BookmarkNotFoundException
+ * @throws \Exception
+ */
+ public function set($bookmark, $save = true);
+
+ /**
+ * Adds a new bookmark (the ID must be empty).
+ *
+ * @param Bookmark $bookmark
+ * @param bool $save Writes to the datastore if set to true
+ *
+ * @return Bookmark new bookmark
+ *
+ * @throws \Exception
+ */
+ public function add($bookmark, $save = true);
+
+ /**
+ * Adds or updates a bookmark depending on its ID:
+ * - a Bookmark without ID will be added
+ * - a Bookmark with an existing ID will be updated
+ *
+ * @param Bookmark $bookmark
+ * @param bool $save
+ *
+ * @return Bookmark
+ *
+ * @throws \Exception
+ */
+ public function addOrSet($bookmark, $save = true);
+
+ /**
+ * Deletes a bookmark.
+ *
+ * @param Bookmark $bookmark
+ * @param bool $save
+ *
+ * @throws \Exception
+ */
+ public function remove($bookmark, $save = true);
+
+ /**
+ * Get a single bookmark by its ID.
+ *
+ * @param int $id Bookmark ID
+ * @param string $visibility all|public|private e.g. with public, accessing a private bookmark will throw an
+ * exception
+ *
+ * @return bool
+ */
+ public function exists($id, $visibility = null);
+
+ /**
+ * Return the number of available bookmarks for given visibility.
+ *
+ * @param string $visibility public|private|all
+ *
+ * @return int Number of bookmarks
+ */
+ public function count($visibility = null);
+
+ /**
+ * Write the datastore.
+ *
+ * @throws NotWritableDataStoreException
+ */
+ public function save();
+
+ /**
+ * 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 => 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();
+}
<?php
-use Shaarli\Bookmark\LinkDB;
+use Shaarli\Bookmark\Bookmark;
/**
* Get cURL callback function for CURLOPT_WRITEFUNCTION
}
/**
- * Count private links in given linklist.
- *
- * @param array|Countable $links Linklist.
- *
- * @return int Number of private links.
- */
-function count_private($links)
-{
- $cpt = 0;
- foreach ($links as $link) {
- if ($link['private']) {
- $cpt += 1;
- }
- }
-
- return $cpt;
-}
-
-/**
- * In a string, converts URLs to clickable links.
+ * In a string, converts URLs to clickable bookmarks.
*
* @param string $text input string.
*
- * @return string returns $text with all links converted to HTML links.
+ * @return string returns $text with all bookmarks converted to HTML bookmarks.
*
* @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
*/
*/
function link_small_hash($date, $id)
{
- return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
+ return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
}
/**
use Exception;
-class LinkNotFoundException extends Exception
+class BookmarkNotFoundException extends Exception
{
/**
* LinkNotFoundException constructor.
--- /dev/null
+<?php
+
+
+namespace Shaarli\Bookmark\Exception;
+
+
+class EmptyDataStoreException extends \Exception {}
--- /dev/null
+<?php
+
+namespace Shaarli\Bookmark\Exception;
+
+use Shaarli\Bookmark\Bookmark;
+
+class InvalidBookmarkException extends \Exception
+{
+ public function __construct($bookmark)
+ {
+ if ($bookmark instanceof Bookmark) {
+ if ($bookmark->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);
+ }
+ }
+}
--- /dev/null
+<?php
+
+
+namespace Shaarli\Bookmark\Exception;
+
+
+class NotWritableDataStoreException extends \Exception
+{
+ /**
+ * NotReadableDataStore constructor.
+ *
+ * @param string $dataStore file path
+ */
+ public function __construct($dataStore)
+ {
+ $this->message = 'Couldn\'t load data from the data store file "'. $dataStore .'". '.
+ 'Your data might be corrupted, or your file isn\'t readable.';
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Formatter;
+
+/**
+ * Class BookmarkDefaultFormatter
+ *
+ * Default bookmark formatter.
+ * Escape values for HTML display and automatically add link to URL and hashtags.
+ *
+ * @package Shaarli\Formatter
+ */
+class BookmarkDefaultFormatter extends BookmarkFormatter
+{
+ /**
+ * @inheritdoc
+ */
+ public function formatTitle($bookmark)
+ {
+ return escape($bookmark->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());
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Formatter;
+
+use DateTime;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Bookmark\Bookmark;
+
+/**
+ * Class BookmarkFormatter
+ *
+ * Abstract class processing all bookmark attributes through methods designed to be overridden.
+ *
+ * @package Shaarli\Formatter
+ */
+abstract class BookmarkFormatter
+{
+ /**
+ * @var ConfigManager
+ */
+ protected $conf;
+
+ /**
+ * @var array Additional parameters than can be used for specific formatting
+ * e.g. index_url for Feed formatting
+ */
+ protected $contextData = [];
+
+ /**
+ * LinkDefaultFormatter constructor.
+ * @param ConfigManager $conf
+ */
+ public function __construct(ConfigManager $conf)
+ {
+ $this->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;
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Formatter;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class BookmarkMarkdownFormatter
+ *
+ * Format bookmark description into Markdown format.
+ *
+ * @package Shaarli\Formatter
+ */
+class BookmarkMarkdownFormatter extends BookmarkDefaultFormatter
+{
+ /**
+ * When this tag is present in a bookmark, its description should not be processed with Markdown
+ */
+ const NO_MD_TAG = 'nomarkdown';
+
+ /** @var \Parsedown instance */
+ protected $parsedown;
+
+ /** @var bool used to escape HTML in Markdown or not.
+ * It MUST be set to true for shared instance as HTML content can
+ * introduce XSS vulnerabilities.
+ */
+ protected $escape;
+
+ /**
+ * @var array List of allowed protocols for links inside bookmark's description.
+ */
+ protected $allowedProtocols;
+
+ /**
+ * LinkMarkdownFormatter constructor.
+ *
+ * @param ConfigManager $conf instance
+ */
+ public function __construct(ConfigManager $conf)
+ {
+ parent::__construct($conf);
+ $this->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 = '<div class="markdown">'. $processedDescription . '</div>';
+ }
+
+ 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 <code> 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 .'[^>]*>(.*</\s*'. $tag .'[^>]*>)?#is',
+ function ($match) {
+ return escape($match[0]);
+ },
+ $description
+ );
+ }
+ $description = preg_replace(
+ '#(<[^>]+\s)on[a-z]*="?[^ "]*"?#is',
+ '$1',
+ $description
+ );
+ return $description;
+ }
+}
--- /dev/null
+<?php
+
+namespace Shaarli\Formatter;
+
+/**
+ * Class BookmarkRawFormatter
+ *
+ * Used to retrieve bookmarks as array with raw values.
+ * Warning: Do NOT use this for HTML content as it can introduce XSS vulnerabilities.
+ *
+ * @package Shaarli\Formatter
+ */
+class BookmarkRawFormatter extends BookmarkFormatter {}
--- /dev/null
+<?php
+
+namespace Shaarli\Formatter;
+
+use Shaarli\Config\ConfigManager;
+
+/**
+ * Class FormatterFactory
+ *
+ * Helper class used to instantiate the proper BookmarkFormatter.
+ *
+ * @package Shaarli\Formatter
+ */
+class FormatterFactory
+{
+ /** @var ConfigManager instance */
+ protected $conf;
+
+ /**
+ * FormatterFactory constructor.
+ *
+ * @param ConfigManager $conf
+ */
+ public function __construct(ConfigManager $conf)
+ {
+ $this->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);
+ }
+}
<?php
-namespace Shaarli\Bookmark;
+namespace Shaarli\Legacy;
use ArrayAccess;
use Countable;
use DateTime;
use Iterator;
-use Shaarli\Bookmark\Exception\LinkNotFoundException;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Exceptions\IOException;
use Shaarli\FileUtils;
/**
- * Data storage for links.
+ * Data storage for bookmarks.
*
* This object behaves like an associative array.
*
* - private: Is this link private? 0=no, other value=yes
* - tags: tags attached to this entry (separated by spaces)
* - title Title of the link
- * - url URL of the link. Used for displayable links.
- * Can be absolute or relative in the database but the relative links
+ * - url URL of the link. Used for displayable bookmarks.
+ * Can be absolute or relative in the database but the relative bookmarks
* will be converted to absolute ones in templates.
* - real_url Raw URL in stored in the DB (absolute or relative).
* - shorturl Permalink smallhash
* Example:
* - DB: link #1 (2010-01-01) link #2 (2016-01-01)
* - Order: #2 #1
- * - Import links containing: link #3 (2013-01-01)
+ * - Import bookmarks containing: link #3 (2013-01-01)
* - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
* - Real order: #2 #3 #1
+ *
+ * @deprecated
*/
-class LinkDB implements Iterator, Countable, ArrayAccess
+class LegacyLinkDB implements Iterator, Countable, ArrayAccess
{
// Links are stored as a PHP serialized string
private $datastore;
// Link date storage format
const LINK_DATE_FORMAT = 'Ymd_His';
- // List of links (associative array)
+ // List of bookmarks (associative array)
// - key: link date (e.g. "20110823_124546"),
// - value: associative array (keys: title, description...)
private $links;
private $urls;
/**
- * @var array List of all links IDS mapped with their array offset.
+ * @var array List of all bookmarks IDS mapped with their array offset.
* Map: id->offset.
*/
protected $ids;
// Position in the $this->keys array (for the Iterator interface)
private $position;
- // Is the user logged in? (used to filter private links)
+ // Is the user logged in? (used to filter private bookmarks)
private $loggedIn;
- // Hide public links
+ // Hide public bookmarks
private $hidePublicLinks;
/**
*
* @param string $datastore datastore file path.
* @param boolean $isLoggedIn is the user logged in?
- * @param boolean $hidePublicLinks if true all links are private.
+ * @param boolean $hidePublicLinks if true all bookmarks are private.
*/
public function __construct(
$datastore,
*/
private function read()
{
- // Public links are hidden and user not logged in => nothing to show
+ // Public bookmarks are hidden and user not logged in => nothing to show
if ($this->hidePublicLinks && !$this->loggedIn) {
$this->links = array();
return;
$link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
- // To be able to load links before running the update, and prepare the update
+ // To be able to load bookmarks before running the update, and prepare the update
if (!isset($link['created'])) {
$link['id'] = $link['linkdate'];
$link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
*
* @return array $filtered array containing permalink data.
*
- * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
+ * @throws BookmarkNotFoundException if the smallhash is malformed or doesn't match any link.
*/
public function filterHash($request)
{
$request = substr($request, 0, 6);
- $linkFilter = new LinkFilter($this->links);
- return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
+ $linkFilter = new LegacyLinkFilter($this->links);
+ return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request);
}
/**
*/
public function filterDay($request)
{
- $linkFilter = new LinkFilter($this->links);
- return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
+ $linkFilter = new LegacyLinkFilter($this->links);
+ return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request);
}
/**
- * Filter links according to search parameters.
+ * Filter bookmarks according to search parameters.
*
* @param array $filterRequest Search request content. Supported keys:
* - searchtags: list of tags
* - searchterm: term search
* @param bool $casesensitive Optional: Perform case sensitive filter
- * @param string $visibility return only all/private/public links
- * @param bool $untaggedonly return only untagged links
+ * @param string $visibility return only all/private/public bookmarks
+ * @param bool $untaggedonly return only untagged bookmarks
*
- * @return array filtered links, all links if no suitable filter was provided.
+ * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
*/
public function filterSearch(
$filterRequest = array(),
$searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
$searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
- // Search tags + fullsearch - blank string parameter will return all links.
- $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
+ // Search tags + fullsearch - blank string parameter will return all bookmarks.
+ $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext"
$request = [$searchtags, $searchterm];
- $linkFilter = new LinkFilter($this);
+ $linkFilter = new LegacyLinkFilter($this);
return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
}
/**
- * Returns the list tags appearing in the links with the given tags
+ * Returns the list tags appearing in the bookmarks with the given tags
*
- * @param array $filteringTags tags selecting the links to consider
- * @param string $visibility process only all/private/public links
+ * @param array $filteringTags tags selecting the bookmarks to consider
+ * @param string $visibility process only all/private/public bookmarks
*
* @return array tag => linksCount
*/
}
/**
- * Rename or delete a tag across all links.
+ * Rename or delete a tag across all bookmarks.
*
* @param string $from Tag to rename
* @param string $to New tag. If none is provided, the from tag will be deleted
*
- * @return array|bool List of altered links or false on error
+ * @return array|bool List of altered bookmarks or false on error
*/
public function renameTag($from, $to)
{
}
/**
- * Reorder links by creation date (newest first).
+ * Reorder bookmarks by creation date (newest first).
*
* Also update the urls and ids mapping arrays.
*
}
/**
- * Returns a link offset in links array from its unique ID.
+ * Returns a link offset in bookmarks array from its unique ID.
*
* @param int $id Persistent ID of a link.
*
<?php
-namespace Shaarli\Bookmark;
+namespace Shaarli\Legacy;
use Exception;
-use Shaarli\Bookmark\Exception\LinkNotFoundException;
+use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
/**
* Class LinkFilter.
*
* Perform search and filter operation on link data list.
+ *
+ * @deprecated
*/
-class LinkFilter
+class LegacyLinkFilter
{
/**
* @var string permalinks.
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
/**
- * @var LinkDB all available links.
+ * @var LegacyLinkDB all available links.
*/
private $links;
/**
- * @param LinkDB $links initialization.
+ * @param LegacyLinkDB $links initialization.
*/
public function __construct($links)
{
$filtered = $this->links;
}
if (!empty($request[0])) {
- $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
+ $filtered = (new LegacyLinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
}
if (!empty($request[1])) {
- $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility);
+ $filtered = (new LegacyLinkFilter($filtered))->filterFulltext($request[1], $visibility);
}
return $filtered;
case self::$FILTER_TEXT:
*
* @return array $filtered array containing permalink data.
*
- * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link.
+ * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
*/
private function filterSmallHash($smallHash)
{
}
if (empty($filtered)) {
- throw new LinkNotFoundException();
+ throw new BookmarkNotFoundException();
}
return $filtered;
--- /dev/null
+<?php
+
+namespace Shaarli\Legacy;
+
+use Exception;
+use RainTPL;
+use ReflectionClass;
+use ReflectionException;
+use ReflectionMethod;
+use Shaarli\ApplicationUtils;
+use Shaarli\Bookmark\Bookmark;
+use Shaarli\Bookmark\BookmarkArray;
+use Shaarli\Bookmark\LinkDB;
+use Shaarli\Bookmark\BookmarkFilter;
+use Shaarli\Bookmark\BookmarkIO;
+use Shaarli\Config\ConfigJson;
+use Shaarli\Config\ConfigManager;
+use Shaarli\Config\ConfigPhp;
+use Shaarli\Exceptions\IOException;
+use Shaarli\Thumbnailer;
+use Shaarli\Updater\Exception\UpdaterException;
+
+/**
+ * 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.
+ *
+ * @deprecated
+ */
+class LegacyUpdater
+{
+ /**
+ * @var array Updates which are already done.
+ */
+ protected $doneUpdates;
+
+ /**
+ * @var LegacyLinkDB instance.
+ */
+ protected $linkDB;
+
+ /**
+ * @var ConfigManager $conf Configuration Manager instance.
+ */
+ protected $conf;
+
+ /**
+ * @var bool True if the user is logged in, false otherwise.
+ */
+ protected $isLoggedIn;
+
+ /**
+ * @var array $_SESSION
+ */
+ protected $session;
+
+ /**
+ * @var ReflectionMethod[] List of current class methods.
+ */
+ protected $methods;
+
+ /**
+ * Object constructor.
+ *
+ * @param array $doneUpdates Updates which are already done.
+ * @param LegacyLinkDB $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
+ */
+ public function __construct($doneUpdates, $linkDB, $conf, $isLoggedIn, &$session = [])
+ {
+ $this->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.<datetime>.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. <a href="?do=thumbs_update">Please synchronize them</a>.'
+ );
+ }
+
+ 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;
+ }
+}
"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",