Introduce Bookmark object and Service layer to retrieve them

See https://github.com/shaarli/Shaarli/issues/1307 for details
This commit is contained in:
ArthurHoaro 2019-05-25 15:46:47 +02:00
parent 796c4c57d0
commit 336a28fa4a
21 changed files with 3228 additions and 66 deletions

View file

@ -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);
}
}
}

View file

@ -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;
}
}
}

View file

@ -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()
);
}
}
}

View file

@ -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);
}
}

View file

@ -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'));
}
}

View file

@ -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);
}
}

View file

@ -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();
}

View file

@ -1,6 +1,6 @@
<?php <?php
use Shaarli\Bookmark\LinkDB; use Shaarli\Bookmark\Bookmark;
/** /**
* Get cURL callback function for CURLOPT_WRITEFUNCTION * Get cURL callback function for CURLOPT_WRITEFUNCTION
@ -188,30 +188,11 @@ function html_extract_tag($tag, $html)
} }
/** /**
* Count private links in given linklist. * In a string, converts URLs to clickable bookmarks.
*
* @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.
* *
* @param string $text input string. * @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 * @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) function link_small_hash($date, $id)
{ {
return smallHash($date->format(LinkDB::LINK_DATE_FORMAT) . $id); return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id);
} }
/** /**

View file

@ -3,7 +3,7 @@
use Exception; use Exception;
class LinkNotFoundException extends Exception class BookmarkNotFoundException extends Exception
{ {
/** /**
* LinkNotFoundException constructor. * LinkNotFoundException constructor.

View file

@ -0,0 +1,7 @@
<?php
namespace Shaarli\Bookmark\Exception;
class EmptyDataStoreException extends \Exception {}

View file

@ -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);
}
}
}

View file

@ -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.';
}
}

View file

@ -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());
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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 {}

View file

@ -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);
}
}

View file

@ -1,17 +1,17 @@
<?php <?php
namespace Shaarli\Bookmark; namespace Shaarli\Legacy;
use ArrayAccess; use ArrayAccess;
use Countable; use Countable;
use DateTime; use DateTime;
use Iterator; use Iterator;
use Shaarli\Bookmark\Exception\LinkNotFoundException; use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
use Shaarli\Exceptions\IOException; use Shaarli\Exceptions\IOException;
use Shaarli\FileUtils; use Shaarli\FileUtils;
/** /**
* Data storage for links. * Data storage for bookmarks.
* *
* This object behaves like an associative array. * This object behaves like an associative array.
* *
@ -29,8 +29,8 @@
* - private: Is this link private? 0=no, other value=yes * - private: Is this link private? 0=no, other value=yes
* - tags: tags attached to this entry (separated by spaces) * - tags: tags attached to this entry (separated by spaces)
* - title Title of the link * - title Title of the link
* - url URL of the link. Used for displayable links. * - url URL of the link. Used for displayable bookmarks.
* Can be absolute or relative in the database but the relative links * Can be absolute or relative in the database but the relative bookmarks
* will be converted to absolute ones in templates. * will be converted to absolute ones in templates.
* - real_url Raw URL in stored in the DB (absolute or relative). * - real_url Raw URL in stored in the DB (absolute or relative).
* - shorturl Permalink smallhash * - shorturl Permalink smallhash
@ -49,11 +49,13 @@
* Example: * Example:
* - DB: link #1 (2010-01-01) link #2 (2016-01-01) * - DB: link #1 (2010-01-01) link #2 (2016-01-01)
* - Order: #2 #1 * - 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) * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
* - Real order: #2 #3 #1 * - 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 // Links are stored as a PHP serialized string
private $datastore; private $datastore;
@ -61,7 +63,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
// Link date storage format // Link date storage format
const LINK_DATE_FORMAT = 'Ymd_His'; const LINK_DATE_FORMAT = 'Ymd_His';
// List of links (associative array) // List of bookmarks (associative array)
// - key: link date (e.g. "20110823_124546"), // - key: link date (e.g. "20110823_124546"),
// - value: associative array (keys: title, description...) // - value: associative array (keys: title, description...)
private $links; private $links;
@ -71,7 +73,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
private $urls; 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. * Map: id->offset.
*/ */
protected $ids; protected $ids;
@ -82,10 +84,10 @@ class LinkDB implements Iterator, Countable, ArrayAccess
// Position in the $this->keys array (for the Iterator interface) // Position in the $this->keys array (for the Iterator interface)
private $position; private $position;
// Is the user logged in? (used to filter private links) // Is the user logged in? (used to filter private bookmarks)
private $loggedIn; private $loggedIn;
// Hide public links // Hide public bookmarks
private $hidePublicLinks; private $hidePublicLinks;
/** /**
@ -95,7 +97,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
* *
* @param string $datastore datastore file path. * @param string $datastore datastore file path.
* @param boolean $isLoggedIn is the user logged in? * @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( public function __construct(
$datastore, $datastore,
@ -280,7 +282,7 @@ private function check()
*/ */
private function read() 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) { if ($this->hidePublicLinks && !$this->loggedIn) {
$this->links = array(); $this->links = array();
return; return;
@ -310,7 +312,7 @@ private function read()
$link['sticky'] = isset($link['sticky']) ? $link['sticky'] : false; $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'])) { if (!isset($link['created'])) {
$link['id'] = $link['linkdate']; $link['id'] = $link['linkdate'];
$link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']); $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
@ -375,13 +377,13 @@ public function getLinkFromUrl($url)
* *
* @return array $filtered array containing permalink data. * @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) public function filterHash($request)
{ {
$request = substr($request, 0, 6); $request = substr($request, 0, 6);
$linkFilter = new LinkFilter($this->links); $linkFilter = new LegacyLinkFilter($this->links);
return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request); return $linkFilter->filter(LegacyLinkFilter::$FILTER_HASH, $request);
} }
/** /**
@ -393,21 +395,21 @@ public function filterHash($request)
*/ */
public function filterDay($request) public function filterDay($request)
{ {
$linkFilter = new LinkFilter($this->links); $linkFilter = new LegacyLinkFilter($this->links);
return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request); 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: * @param array $filterRequest Search request content. Supported keys:
* - searchtags: list of tags * - searchtags: list of tags
* - searchterm: term search * - searchterm: term search
* @param bool $casesensitive Optional: Perform case sensitive filter * @param bool $casesensitive Optional: Perform case sensitive filter
* @param string $visibility return only all/private/public links * @param string $visibility return only all/private/public bookmarks
* @param bool $untaggedonly return only untagged links * @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( public function filterSearch(
$filterRequest = array(), $filterRequest = array(),
@ -420,19 +422,19 @@ public function filterSearch(
$searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : ''; $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
$searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : ''; $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
// Search tags + fullsearch - blank string parameter will return all links. // Search tags + fullsearch - blank string parameter will return all bookmarks.
$type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext" $type = LegacyLinkFilter::$FILTER_TAG | LegacyLinkFilter::$FILTER_TEXT; // == "vuotext"
$request = [$searchtags, $searchterm]; $request = [$searchtags, $searchterm];
$linkFilter = new LinkFilter($this); $linkFilter = new LegacyLinkFilter($this);
return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly); 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 array $filteringTags tags selecting the bookmarks to consider
* @param string $visibility process only all/private/public links * @param string $visibility process only all/private/public bookmarks
* *
* @return array tag => linksCount * @return array tag => linksCount
*/ */
@ -471,12 +473,12 @@ public function linksCountPerTag($filteringTags = [], $visibility = 'all')
} }
/** /**
* Rename or delete a tag across all links. * Rename or delete a tag across all bookmarks.
* *
* @param string $from Tag to rename * @param string $from Tag to rename
* @param string $to New tag. If none is provided, the from tag will be deleted * @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) public function renameTag($from, $to)
{ {
@ -519,7 +521,7 @@ public function days()
} }
/** /**
* Reorder links by creation date (newest first). * Reorder bookmarks by creation date (newest first).
* *
* Also update the urls and ids mapping arrays. * Also update the urls and ids mapping arrays.
* *
@ -562,7 +564,7 @@ public function getNextId()
} }
/** /**
* 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. * @param int $id Persistent ID of a link.
* *

View file

@ -1,16 +1,18 @@
<?php <?php
namespace Shaarli\Bookmark; namespace Shaarli\Legacy;
use Exception; use Exception;
use Shaarli\Bookmark\Exception\LinkNotFoundException; use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
/** /**
* Class LinkFilter. * Class LinkFilter.
* *
* Perform search and filter operation on link data list. * Perform search and filter operation on link data list.
*
* @deprecated
*/ */
class LinkFilter class LegacyLinkFilter
{ {
/** /**
* @var string permalinks. * @var string permalinks.
@ -38,12 +40,12 @@ class LinkFilter
public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}'; public static $HASHTAG_CHARS = '\p{Pc}\p{N}\p{L}\p{Mn}';
/** /**
* @var LinkDB all available links. * @var LegacyLinkDB all available links.
*/ */
private $links; private $links;
/** /**
* @param LinkDB $links initialization. * @param LegacyLinkDB $links initialization.
*/ */
public function __construct($links) public function __construct($links)
{ {
@ -84,10 +86,10 @@ public function filter($type, $request, $casesensitive = false, $visibility = 'a
$filtered = $this->links; $filtered = $this->links;
} }
if (!empty($request[0])) { 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])) { if (!empty($request[1])) {
$filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility); $filtered = (new LegacyLinkFilter($filtered))->filterFulltext($request[1], $visibility);
} }
return $filtered; return $filtered;
case self::$FILTER_TEXT: case self::$FILTER_TEXT:
@ -137,7 +139,7 @@ private function noFilter($visibility = 'all')
* *
* @return array $filtered array containing permalink data. * @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) private function filterSmallHash($smallHash)
{ {
@ -151,7 +153,7 @@ private function filterSmallHash($smallHash)
} }
if (empty($filtered)) { if (empty($filtered)) {
throw new LinkNotFoundException(); throw new BookmarkNotFoundException();
} }
return $filtered; return $filtered;

View file

@ -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;
}
}

View file

@ -50,7 +50,9 @@
"Shaarli\\Config\\Exception\\": "application/config/exception", "Shaarli\\Config\\Exception\\": "application/config/exception",
"Shaarli\\Exceptions\\": "application/exceptions", "Shaarli\\Exceptions\\": "application/exceptions",
"Shaarli\\Feed\\": "application/feed", "Shaarli\\Feed\\": "application/feed",
"Shaarli\\Formatter\\": "application/formatter",
"Shaarli\\Http\\": "application/http", "Shaarli\\Http\\": "application/http",
"Shaarli\\Legacy\\": "application/legacy",
"Shaarli\\Netscape\\": "application/netscape", "Shaarli\\Netscape\\": "application/netscape",
"Shaarli\\Plugin\\": "application/plugin", "Shaarli\\Plugin\\": "application/plugin",
"Shaarli\\Plugin\\Exception\\": "application/plugin/exception", "Shaarli\\Plugin\\Exception\\": "application/plugin/exception",