]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Introduce Bookmark object and Service layer to retrieve them
authorArthurHoaro <arthur@hoa.ro>
Sat, 25 May 2019 13:46:47 +0000 (15:46 +0200)
committerArthurHoaro <arthur@hoa.ro>
Fri, 17 Jan 2020 17:42:11 +0000 (18:42 +0100)
See https://github.com/shaarli/Shaarli/issues/1307 for details

21 files changed:
application/bookmark/Bookmark.php [new file with mode: 0644]
application/bookmark/BookmarkArray.php [new file with mode: 0644]
application/bookmark/BookmarkFileService.php [new file with mode: 0644]
application/bookmark/BookmarkFilter.php [new file with mode: 0644]
application/bookmark/BookmarkIO.php [new file with mode: 0644]
application/bookmark/BookmarkInitializer.php [new file with mode: 0644]
application/bookmark/BookmarkServiceInterface.php [new file with mode: 0644]
application/bookmark/LinkUtils.php
application/bookmark/exception/BookmarkNotFoundException.php [moved from application/bookmark/exception/LinkNotFoundException.php with 84% similarity]
application/bookmark/exception/EmptyDataStoreException.php [new file with mode: 0644]
application/bookmark/exception/InvalidBookmarkException.php [new file with mode: 0644]
application/bookmark/exception/NotWritableDataStoreException.php [new file with mode: 0644]
application/formatter/BookmarkDefaultFormatter.php [new file with mode: 0644]
application/formatter/BookmarkFormatter.php [new file with mode: 0644]
application/formatter/BookmarkMarkdownFormatter.php [new file with mode: 0644]
application/formatter/BookmarkRawFormatter.php [new file with mode: 0644]
application/formatter/FormatterFactory.php [new file with mode: 0644]
application/legacy/LegacyLinkDB.php [moved from application/bookmark/LinkDB.php with 89% similarity]
application/legacy/LegacyLinkFilter.php [moved from application/bookmark/LinkFilter.php with 96% similarity]
application/legacy/LegacyUpdater.php [new file with mode: 0644]
composer.json

diff --git a/application/bookmark/Bookmark.php b/application/bookmark/Bookmark.php
new file mode 100644 (file)
index 0000000..b08e5d6
--- /dev/null
@@ -0,0 +1,461 @@
+<?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);
+        }
+    }
+}
diff --git a/application/bookmark/BookmarkArray.php b/application/bookmark/BookmarkArray.php
new file mode 100644 (file)
index 0000000..b427c91
--- /dev/null
@@ -0,0 +1,259 @@
+<?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;
+        }
+    }
+}
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
new file mode 100644 (file)
index 0000000..a56cc92
--- /dev/null
@@ -0,0 +1,373 @@
+<?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()
+            );
+        }
+    }
+}
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
new file mode 100644 (file)
index 0000000..fd55667
--- /dev/null
@@ -0,0 +1,468 @@
+<?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);
+    }
+}
diff --git a/application/bookmark/BookmarkIO.php b/application/bookmark/BookmarkIO.php
new file mode 100644 (file)
index 0000000..ae9ffcb
--- /dev/null
@@ -0,0 +1,108 @@
+<?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'));
+    }
+}
diff --git a/application/bookmark/BookmarkInitializer.php b/application/bookmark/BookmarkInitializer.php
new file mode 100644 (file)
index 0000000..9eee9a3
--- /dev/null
@@ -0,0 +1,59 @@
+<?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);
+    }
+}
diff --git a/application/bookmark/BookmarkServiceInterface.php b/application/bookmark/BookmarkServiceInterface.php
new file mode 100644 (file)
index 0000000..7b7a4f0
--- /dev/null
@@ -0,0 +1,180 @@
+<?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();
+}
index 77eb2d95dd480e09663e8639174675df3755a210..8837943037dd52468ff6e73bbbc39f8e669b1b04 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-use Shaarli\Bookmark\LinkDB;
+use Shaarli\Bookmark\Bookmark;
 
 /**
  * Get cURL callback function for CURLOPT_WRITEFUNCTION
@@ -188,30 +188,11 @@ function html_extract_tag($tag, $html)
 }
 
 /**
- * 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
  */
@@ -279,7 +260,7 @@ function format_description($description, $indexUrl = '')
  */
 function link_small_hash($date, $id)
 {
-    return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id);
+    return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
 }
 
 /**
similarity index 84%
rename from application/bookmark/exception/LinkNotFoundException.php
rename to application/bookmark/exception/BookmarkNotFoundException.php
index f9414428aafed9a97c8c9092b7873fb73acbb45c..827a3d358ae98fb0adc15cf482ff06567bbd54ab 100644 (file)
@@ -3,7 +3,7 @@ namespace Shaarli\Bookmark\Exception;
 
 use Exception;
 
-class LinkNotFoundException extends Exception
+class BookmarkNotFoundException extends Exception
 {
     /**
      * LinkNotFoundException constructor.
diff --git a/application/bookmark/exception/EmptyDataStoreException.php b/application/bookmark/exception/EmptyDataStoreException.php
new file mode 100644 (file)
index 0000000..cd48c1e
--- /dev/null
@@ -0,0 +1,7 @@
+<?php
+
+
+namespace Shaarli\Bookmark\Exception;
+
+
+class EmptyDataStoreException extends \Exception {}
diff --git a/application/bookmark/exception/InvalidBookmarkException.php b/application/bookmark/exception/InvalidBookmarkException.php
new file mode 100644 (file)
index 0000000..10c84a6
--- /dev/null
@@ -0,0 +1,30 @@
+<?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);
+        }
+    }
+}
diff --git a/application/bookmark/exception/NotWritableDataStoreException.php b/application/bookmark/exception/NotWritableDataStoreException.php
new file mode 100644 (file)
index 0000000..95f34b5
--- /dev/null
@@ -0,0 +1,19 @@
+<?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.';
+    }
+}
diff --git a/application/formatter/BookmarkDefaultFormatter.php b/application/formatter/BookmarkDefaultFormatter.php
new file mode 100644 (file)
index 0000000..7550c55
--- /dev/null
@@ -0,0 +1,81 @@
+<?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());
+    }
+}
diff --git a/application/formatter/BookmarkFormatter.php b/application/formatter/BookmarkFormatter.php
new file mode 100644 (file)
index 0000000..c82c345
--- /dev/null
@@ -0,0 +1,256 @@
+<?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;
+    }
+}
diff --git a/application/formatter/BookmarkMarkdownFormatter.php b/application/formatter/BookmarkMarkdownFormatter.php
new file mode 100644 (file)
index 0000000..f60c61f
--- /dev/null
@@ -0,0 +1,198 @@
+<?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;
+    }
+}
diff --git a/application/formatter/BookmarkRawFormatter.php b/application/formatter/BookmarkRawFormatter.php
new file mode 100644 (file)
index 0000000..bc37227
--- /dev/null
@@ -0,0 +1,13 @@
+<?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 {}
diff --git a/application/formatter/FormatterFactory.php b/application/formatter/FormatterFactory.php
new file mode 100644 (file)
index 0000000..0d2c046
--- /dev/null
@@ -0,0 +1,46 @@
+<?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);
+    }
+}
similarity index 89%
rename from application/bookmark/LinkDB.php
rename to application/legacy/LegacyLinkDB.php
index f01c7ee6cadaea8422baf7e650ee85e639e84172..7ccf5e54a9c4cb9dfb70974a99197a545ef2c92a 100644 (file)
@@ -1,17 +1,17 @@
 <?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.
  *
@@ -29,8 +29,8 @@ use Shaarli\FileUtils;
  *  - 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
@@ -49,11 +49,13 @@ use Shaarli\FileUtils;
  *   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;
@@ -61,7 +63,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
     // 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;
@@ -71,7 +73,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
     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;
@@ -82,10 +84,10 @@ class LinkDB implements Iterator, Countable, ArrayAccess
     // Position in the $this->keys array (for the Iterator interface)
     private $position;
 
-    // Is the user logged in? (used to filter private links)
+    // Is the user logged in? (used to filter private bookmarks)
     private $loggedIn;
 
-    // Hide public links
+    // Hide public bookmarks
     private $hidePublicLinks;
 
     /**
@@ -95,7 +97,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
      *
      * @param string  $datastore        datastore file path.
      * @param boolean $isLoggedIn       is the user logged in?
-     * @param boolean $hidePublicLinks  if true all links are private.
+     * @param boolean $hidePublicLinks  if true all bookmarks are private.
      */
     public function __construct(
         $datastore,
@@ -280,7 +282,7 @@ You use the community supported version of the original Shaarli project, by Seba
      */
     private function read()
     {
-        // Public links are hidden and user not logged in => nothing to show
+        // Public bookmarks are hidden and user not logged in => nothing to show
         if ($this->hidePublicLinks && !$this->loggedIn) {
             $this->links = array();
             return;
@@ -310,7 +312,7 @@ You use the community supported version of the original Shaarli project, by Seba
 
             $link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false;
 
-            // To be able to load links before running the update, and prepare the update
+            // To be able to load bookmarks before running the update, and prepare the update
             if (!isset($link['created'])) {
                 $link['id'] = $link['linkdate'];
                 $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
@@ -375,13 +377,13 @@ You use the community supported version of the original Shaarli project, by Seba
      *
      * @return array $filtered array containing permalink data.
      *
-     * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
+     * @throws BookmarkNotFoundException if the smallhash is malformed or doesn't match any link.
      */
     public function filterHash($request)
     {
         $request = substr($request, 0, 6);
-        $linkFilter = new LinkFilter($this->links);
-        return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
+        $linkFilter = new LegacyLinkFilter($this->links);
+        return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request);
     }
 
     /**
@@ -393,21 +395,21 @@ You use the community supported version of the original Shaarli project, by Seba
      */
     public function filterDay($request)
     {
-        $linkFilter = new LinkFilter($this->links);
-        return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
+        $linkFilter = new LegacyLinkFilter($this->links);
+        return $linkFilter->filter(LegacyLinkFilter::$FILTER_DAY, $request);
     }
 
     /**
-     * Filter links according to search parameters.
+     * Filter bookmarks according to search parameters.
      *
      * @param array  $filterRequest  Search request content. Supported keys:
      *                                - searchtags: list of tags
      *                                - searchterm: term search
      * @param bool   $casesensitive  Optional: Perform case sensitive filter
-     * @param string $visibility     return only all/private/public links
-     * @param bool   $untaggedonly   return only untagged links
+     * @param string $visibility     return only all/private/public bookmarks
+     * @param bool   $untaggedonly   return only untagged bookmarks
      *
-     * @return array filtered links, all links if no suitable filter was provided.
+     * @return array filtered bookmarks, all bookmarks if no suitable filter was provided.
      */
     public function filterSearch(
         $filterRequest = array(),
@@ -420,19 +422,19 @@ You use the community supported version of the original Shaarli project, by Seba
         $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
         $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
 
-        // Search tags + fullsearch - blank string parameter will return all links.
-        $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
+        // Search tags + fullsearch - blank string parameter will return all bookmarks.
+        $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext"
         $request = [$searchtags, $searchterm];
 
-        $linkFilter = new LinkFilter($this);
+        $linkFilter = new LegacyLinkFilter($this);
         return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
     }
 
     /**
-     * Returns the list tags appearing in the links with the given tags
+     * Returns the list tags appearing in the bookmarks with the given tags
      *
-     * @param array  $filteringTags tags selecting the links to consider
-     * @param string $visibility    process only all/private/public links
+     * @param array  $filteringTags tags selecting the bookmarks to consider
+     * @param string $visibility    process only all/private/public bookmarks
      *
      * @return array tag => linksCount
      */
@@ -471,12 +473,12 @@ You use the community supported version of the original Shaarli project, by Seba
     }
 
     /**
-     * Rename or delete a tag across all links.
+     * Rename or delete a tag across all bookmarks.
      *
      * @param string $from Tag to rename
      * @param string $to   New tag. If none is provided, the from tag will be deleted
      *
-     * @return array|bool List of altered links or false on error
+     * @return array|bool List of altered bookmarks or false on error
      */
     public function renameTag($from, $to)
     {
@@ -519,7 +521,7 @@ You use the community supported version of the original Shaarli project, by Seba
     }
 
     /**
-     * Reorder links by creation date (newest first).
+     * Reorder bookmarks by creation date (newest first).
      *
      * Also update the urls and ids mapping arrays.
      *
@@ -562,7 +564,7 @@ You use the community supported version of the original Shaarli project, by Seba
     }
 
     /**
-     * Returns a link offset in links array from its unique ID.
+     * Returns a link offset in bookmarks array from its unique ID.
      *
      * @param int $id Persistent ID of a link.
      *
similarity index 96%
rename from application/bookmark/LinkFilter.php
rename to application/legacy/LegacyLinkFilter.php
index 9b96630737008cb2075886e339b85d6b2bbeb109..7cf93d60ca3ae2a05d0f61f45062bad4ded672b2 100644 (file)
@@ -1,16 +1,18 @@
 <?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.
@@ -38,12 +40,12 @@ class LinkFilter
     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)
     {
@@ -84,10 +86,10 @@ class LinkFilter
                     $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:
@@ -137,7 +139,7 @@ class LinkFilter
      *
      * @return array $filtered array containing permalink data.
      *
-     * @throws \Shaarli\Bookmark\Exception\LinkNotFoundException if the smallhash doesn't match any link.
+     * @throws BookmarkNotFoundException if the smallhash doesn't match any link.
      */
     private function filterSmallHash($smallHash)
     {
@@ -151,7 +153,7 @@ class LinkFilter
         }
 
         if (empty($filtered)) {
-            throw new LinkNotFoundException();
+            throw new BookmarkNotFoundException();
         }
 
         return $filtered;
diff --git a/application/legacy/LegacyUpdater.php b/application/legacy/LegacyUpdater.php
new file mode 100644 (file)
index 0000000..3a5de79
--- /dev/null
@@ -0,0 +1,617 @@
+<?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;
+    }
+}
index a028e99a3cbcbe7001c98aa3666290cabc822f44..ada06a7472dbcbb02ff46842efcfa956e646ee1a 100644 (file)
@@ -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",