| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| 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(): int | ||
| { | ||
| 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): void | ||
| { | ||
| 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): bool | ||
| { | ||
| return array_key_exists($this->getBookmarkOffset($offset), $this->bookmarks); | ||
| } | ||
|
|
||
| /** | ||
| * ArrayAccess - Unsets an offset | ||
| * | ||
| * @param int $offset Bookmark ID | ||
| */ | ||
| public function offsetUnset($offset): void | ||
| { | ||
| $realOffset = $this->getBookmarkOffset($offset); | ||
| $url = $this->bookmarks[$realOffset]->getUrl(); | ||
| unset($this->urls[$url]); | ||
| unset($this->ids[$offset]); | ||
| 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): ?Bookmark | ||
| { | ||
| $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(): Bookmark | ||
| { | ||
| 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(): int | ||
| { | ||
| return $this->keys[$this->position]; | ||
| } | ||
|
|
||
| /** | ||
| * Iterator - Moves forward to next element | ||
| */ | ||
| public function next(): void | ||
| { | ||
| ++$this->position; | ||
| } | ||
|
|
||
| /** | ||
| * Iterator - Rewinds the Iterator to the first element | ||
| * | ||
| * Entries are sorted by date (latest first) | ||
| */ | ||
| public function rewind(): void | ||
| { | ||
| $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(): bool | ||
| { | ||
| return isset($this->keys[$this->position]); | ||
| } | ||
|
|
||
| /** | ||
| * Returns a bookmark offset in bookmarks array from its unique ID. | ||
| * | ||
| * @param int|null $id Persistent ID of a bookmark. | ||
| * | ||
| * @return int Real offset in local array, or null if doesn't exist. | ||
| */ | ||
| protected function getBookmarkOffset(?int $id): ?int | ||
| { | ||
| if ($id !== null && 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(): int | ||
| { | ||
| if (!empty($this->ids)) { | ||
| return max(array_keys($this->ids)) + 1; | ||
| } | ||
| return 0; | ||
| } | ||
|
|
||
| /** | ||
| * @param string $url | ||
| * | ||
| * @return Bookmark|null | ||
| */ | ||
| public function getByUrl(string $url): ?Bookmark | ||
| { | ||
| 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 | ||
| * @param bool $ignoreSticky If set to true, sticky bookmarks won't be first | ||
| */ | ||
| public function reorder(string $order = 'DESC', bool $ignoreSticky = false): void | ||
| { | ||
| $order = $order === 'ASC' ? -1 : 1; | ||
| // Reorder array by dates. | ||
| usort($this->bookmarks, function ($a, $b) use ($order, $ignoreSticky) { | ||
| /** @var $a Bookmark */ | ||
| /** @var $b Bookmark */ | ||
| if (false === $ignoreSticky && $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; | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,177 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Bookmark; | ||
|
|
||
| use malkusch\lock\exception\LockAcquireException; | ||
| use malkusch\lock\mutex\Mutex; | ||
| use malkusch\lock\mutex\NoMutex; | ||
| use Shaarli\Bookmark\Exception\DatastoreNotInitializedException; | ||
| use Shaarli\Bookmark\Exception\EmptyDataStoreException; | ||
| use Shaarli\Bookmark\Exception\InvalidWritableDataException; | ||
| use Shaarli\Bookmark\Exception\NotEnoughSpaceException; | ||
| 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; | ||
|
|
||
|
|
||
| /** @var Mutex */ | ||
| protected $mutex; | ||
|
|
||
| /** | ||
| * string Datastore PHP prefix | ||
| */ | ||
| protected static $phpPrefix = '<?php /* '; | ||
| /** | ||
| * string Datastore PHP suffix | ||
| */ | ||
| protected static $phpSuffix = ' */ ?>'; | ||
|
|
||
| /** | ||
| * LinksIO constructor. | ||
| * | ||
| * @param ConfigManager $conf instance | ||
| */ | ||
| public function __construct(ConfigManager $conf, Mutex $mutex = null) | ||
| { | ||
| if ($mutex === null) { | ||
| // This should only happen with legacy classes | ||
| $mutex = new NoMutex(); | ||
| } | ||
| $this->conf = $conf; | ||
| $this->datastore = $conf->get('resource.datastore'); | ||
| $this->mutex = $mutex; | ||
| } | ||
|
|
||
| /** | ||
| * Reads database from disk to memory | ||
| * | ||
| * @return Bookmark[] | ||
| * | ||
| * @throws NotWritableDataStoreException Data couldn't be loaded | ||
| * @throws EmptyDataStoreException Datastore file exists but does not contain any bookmark | ||
| * @throws DatastoreNotInitializedException File does not exists | ||
| */ | ||
| public function read() | ||
| { | ||
| if (! file_exists($this->datastore)) { | ||
| throw new DatastoreNotInitializedException(); | ||
| } | ||
|
|
||
| if (!is_writable($this->datastore)) { | ||
| throw new NotWritableDataStoreException($this->datastore); | ||
| } | ||
|
|
||
| $content = null; | ||
| $this->synchronized(function () use (&$content) { | ||
| $content = file_get_contents($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($content, 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 Bookmark[] $links | ||
| * | ||
| * @throws NotWritableDataStoreException the datastore is not writable | ||
| * @throws InvalidWritableDataException | ||
| */ | ||
| 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); | ||
| } elseif (!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)); | ||
| } | ||
|
|
||
| $data = base64_encode(gzdeflate(serialize($links))); | ||
|
|
||
| if (empty($data)) { | ||
| throw new InvalidWritableDataException(); | ||
| } | ||
|
|
||
| $data = self::$phpPrefix . $data . self::$phpSuffix; | ||
|
|
||
| $this->synchronized(function () use ($data) { | ||
| if (!$this->checkDiskSpace($data)) { | ||
| throw new NotEnoughSpaceException(); | ||
| } | ||
|
|
||
| file_put_contents( | ||
| $this->datastore, | ||
| $data | ||
| ); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Wrapper applying mutex to provided function. | ||
| * If the lock can't be acquired (e.g. some shared hosting provider), we execute the function without mutex. | ||
| * | ||
| * @see https://github.com/shaarli/Shaarli/issues/1650 | ||
| * | ||
| * @param callable $function | ||
| */ | ||
| protected function synchronized(callable $function): void | ||
| { | ||
| try { | ||
| $this->mutex->synchronized($function); | ||
| } catch (LockAcquireException $exception) { | ||
| $function(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Make sure that there is enough disk space available to save the current data store. | ||
| * We add an arbitrary margin of 500kB. | ||
| * | ||
| * @param string $data to be saved | ||
| * | ||
| * @return bool True if data can safely be saved | ||
| */ | ||
| public function checkDiskSpace(string $data): bool | ||
| { | ||
| if (function_exists('disk_free_space') === false) { | ||
| return true; | ||
| } | ||
|
|
||
| return disk_free_space(dirname($this->datastore)) > (strlen($data) + 1024 * 500); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Bookmark; | ||
|
|
||
| /** | ||
| * Class BookmarkInitializer | ||
| * | ||
| * This class is used to initialized default bookmarks after a fresh install of Shaarli. | ||
| * It should be only called if the datastore file does not exist(users might want to delete the default bookmarks). | ||
| * | ||
| * To prevent data corruption, it does not overwrite existing bookmarks, | ||
| * even though there should not be any. | ||
| * | ||
| * We disable this because otherwise it creates indentation issues, and heredoc is not supported by PHP gettext. | ||
| * @phpcs:disable Generic.Files.LineLength.TooLong | ||
| * | ||
| * @package Shaarli\Bookmark | ||
| */ | ||
| class BookmarkInitializer | ||
| { | ||
| /** @var BookmarkServiceInterface */ | ||
| protected $bookmarkService; | ||
|
|
||
| /** | ||
| * BookmarkInitializer constructor. | ||
| * | ||
| * @param BookmarkServiceInterface $bookmarkService | ||
| */ | ||
| public function __construct(BookmarkServiceInterface $bookmarkService) | ||
| { | ||
| $this->bookmarkService = $bookmarkService; | ||
| } | ||
|
|
||
| /** | ||
| * Initialize the data store with default bookmarks | ||
| */ | ||
| public function initialize(): void | ||
| { | ||
| $bookmark = new Bookmark(); | ||
| $bookmark->setTitle('Calm Jazz Music - YouTube ' . t('(private bookmark with thumbnail demo)')); | ||
| $bookmark->setUrl('https://www.youtube.com/watch?v=DVEUcbPkb-c'); | ||
| $bookmark->setDescription(t( | ||
| 'Shaarli will automatically pick up the thumbnail for links to a variety of websites. | ||
| Explore your new Shaarli instance by trying out controls and menus. | ||
| Visit the project on [Github](https://github.com/shaarli/Shaarli) or [the documentation](https://shaarli.readthedocs.io/en/master/) to learn more about Shaarli. | ||
| Now you can edit or delete the default shaares. | ||
| ' | ||
| )); | ||
| $bookmark->setTagsString('shaarli help thumbnail'); | ||
| $bookmark->setPrivate(true); | ||
| $this->bookmarkService->add($bookmark, false); | ||
|
|
||
| $bookmark = new Bookmark(); | ||
| $bookmark->setTitle(t('Note: Shaare descriptions')); | ||
| $bookmark->setDescription(t( | ||
| 'Adding a shaare without entering a URL creates a text-only "note" post such as this one. | ||
| This note is private, so you are the only one able to see it while logged in. | ||
| You can use this to keep notes, post articles, code snippets, and much more. | ||
| The Markdown formatting setting allows you to format your notes and bookmark description: | ||
| ### Title headings | ||
| #### Multiple headings levels | ||
| * bullet lists | ||
| * _italic_ text | ||
| * **bold** text | ||
| * ~~strike through~~ text | ||
| * `code` blocks | ||
| * images | ||
| * [links](https://en.wikipedia.org/wiki/Markdown) | ||
| Markdown also supports tables: | ||
| | Name | Type | Color | Qty | | ||
| | ------- | --------- | ------ | ----- | | ||
| | Orange | Fruit | Orange | 126 | | ||
| | Apple | Fruit | Any | 62 | | ||
| | Lemon | Fruit | Yellow | 30 | | ||
| | Carrot | Vegetable | Red | 14 | | ||
| ' | ||
| )); | ||
| $bookmark->setTagsString('shaarli help'); | ||
| $bookmark->setPrivate(true); | ||
| $this->bookmarkService->add($bookmark, false); | ||
|
|
||
| $bookmark = new Bookmark(); | ||
| $bookmark->setTitle( | ||
| 'Shaarli - ' . t('The personal, minimalist, super fast, database-free, bookmarking service') | ||
| ); | ||
| $bookmark->setDescription(t( | ||
| 'Welcome to Shaarli! | ||
| Shaarli allows you to bookmark your favorite pages, and share them with others or store them privately. | ||
| You can add a description to your bookmarks, such as this one, and tag them. | ||
| Create a new shaare by clicking the `+Shaare` button, or using any of the recommended tools (browser extension, mobile app, bookmarklet, REST API, etc.). | ||
| You can easily retrieve your links, even with thousands of them, using the internal search engine, or search through tags (e.g. this Shaare is tagged with `shaarli` and `help`). | ||
| Hashtags such as #shaarli #help are also supported. | ||
| You can also filter the available [RSS feed](/feed/atom) and picture wall by tag or plaintext search. | ||
| We hope that you will enjoy using Shaarli, maintained with ❤️ by the community! | ||
| Feel free to open [an issue](https://github.com/shaarli/Shaarli/issues) if you have a suggestion or encounter an issue. | ||
| ' | ||
| )); | ||
| $bookmark->setTagsString('shaarli help'); | ||
| $this->bookmarkService->add($bookmark, false); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Bookmark; | ||
|
|
||
| use Shaarli\Bookmark\Exception\BookmarkNotFoundException; | ||
| use Shaarli\Bookmark\Exception\NotWritableDataStoreException; | ||
|
|
||
| /** | ||
| * Class BookmarksService | ||
| * | ||
| * This is the entry point to manipulate the bookmark DB. | ||
| * | ||
| * Regarding return types of a list of bookmarks, it can either be an array or an ArrayAccess implementation, | ||
| * so until PHP 8.0 is the minimal supported version with union return types it cannot be explicitly added. | ||
| */ | ||
| interface BookmarkServiceInterface | ||
| { | ||
| /** | ||
| * Find a bookmark by hash | ||
| * | ||
| * @param string $hash Bookmark's hash | ||
| * @param string|null $privateKey Optional key used to access private links while logged out | ||
| * | ||
| * @return Bookmark | ||
| * | ||
| * @throws \Exception | ||
| */ | ||
| public function findByHash(string $hash, string $privateKey = null); | ||
|
|
||
| /** | ||
| * @param $url | ||
| * | ||
| * @return Bookmark|null | ||
| */ | ||
| public function findByUrl(string $url): ?Bookmark; | ||
|
|
||
| /** | ||
| * Search bookmarks | ||
| * | ||
| * @param array $request | ||
| * @param ?string $visibility | ||
| * @param bool $caseSensitive | ||
| * @param bool $untaggedOnly | ||
| * @param bool $ignoreSticky | ||
| * @param array $pagination This array can contain the following keys for pagination: limit, offset. | ||
| * | ||
| * @return SearchResult | ||
| */ | ||
| public function search( | ||
| array $request = [], | ||
| string $visibility = null, | ||
| bool $caseSensitive = false, | ||
| bool $untaggedOnly = false, | ||
| bool $ignoreSticky = false, | ||
| array $pagination = [] | ||
| ): SearchResult; | ||
|
|
||
| /** | ||
| * 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(int $id, string $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 $bookmark, bool $save = true): Bookmark; | ||
|
|
||
| /** | ||
| * 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 $bookmark, bool $save = true): Bookmark; | ||
|
|
||
| /** | ||
| * 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 $bookmark, bool $save = true): Bookmark; | ||
|
|
||
| /** | ||
| * Deletes a bookmark. | ||
| * | ||
| * @param Bookmark $bookmark | ||
| * @param bool $save | ||
| * | ||
| * @throws \Exception | ||
| */ | ||
| public function remove(Bookmark $bookmark, bool $save = true): void; | ||
|
|
||
| /** | ||
| * 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(int $id, string $visibility = null): bool; | ||
|
|
||
| /** | ||
| * Return the number of available bookmarks for given visibility. | ||
| * | ||
| * @param ?string $visibility public|private|all | ||
| * | ||
| * @return int Number of bookmarks | ||
| */ | ||
| public function count(string $visibility = null): int; | ||
|
|
||
| /** | ||
| * Write the datastore. | ||
| * | ||
| * @throws NotWritableDataStoreException | ||
| */ | ||
| public function save(): void; | ||
|
|
||
| /** | ||
| * Returns the list tags appearing in the bookmarks with the given tags | ||
| * | ||
| * @param array|null $filteringTags tags selecting the bookmarks to consider | ||
| * @param string|null $visibility process only all/private/public bookmarks | ||
| * | ||
| * @return array tag => bookmarksCount | ||
| */ | ||
| public function bookmarksCountPerTag(array $filteringTags = [], ?string $visibility = null): array; | ||
|
|
||
| /** | ||
| * Return a list of bookmark matching provided period of time. | ||
| * It also update directly previous and next date outside of given period found in the datastore. | ||
| * | ||
| * @param \DateTimeInterface $from Starting date. | ||
| * @param \DateTimeInterface $to Ending date. | ||
| * @param \DateTimeInterface|null $previous (by reference) updated with first created date found before $from. | ||
| * @param \DateTimeInterface|null $next (by reference) updated with first created date found after $to. | ||
| * | ||
| * @return array List of bookmarks matching provided period of time. | ||
| */ | ||
| public function findByDate( | ||
| \DateTimeInterface $from, | ||
| \DateTimeInterface $to, | ||
| ?\DateTimeInterface &$previous, | ||
| ?\DateTimeInterface &$next | ||
| ): array; | ||
|
|
||
| /** | ||
| * Returns the latest bookmark by creation date. | ||
| * | ||
| * @return Bookmark|null Found Bookmark or null if the datastore is empty. | ||
| */ | ||
| public function getLatest(): ?Bookmark; | ||
|
|
||
| /** | ||
| * Creates the default database after a fresh install. | ||
| */ | ||
| public function initialize(): void; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,253 @@ | ||
| <?php | ||
|
|
||
| use Shaarli\Bookmark\Bookmark; | ||
| use Shaarli\Formatter\BookmarkDefaultFormatter; | ||
|
|
||
| /** | ||
| * Extract title from an HTML document. | ||
| * | ||
| * @param string $html HTML content where to look for a title. | ||
| * | ||
| * @return bool|string Extracted title if found, false otherwise. | ||
| */ | ||
| function html_extract_title($html) | ||
| { | ||
| if (preg_match('!<title.*?>(.*?)</title>!is', $html, $matches)) { | ||
| return trim(str_replace("\n", '', $matches[1])); | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Extract charset from HTTP header if it's defined. | ||
| * | ||
| * @param string $header HTTP header Content-Type line. | ||
| * | ||
| * @return bool|string Charset string if found (lowercase), false otherwise. | ||
| */ | ||
| function header_extract_charset($header) | ||
| { | ||
| preg_match('/charset=["\']?([^; "\']+)/i', $header, $match); | ||
| if (! empty($match[1])) { | ||
| return strtolower(trim($match[1])); | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Extract charset HTML content (tag <meta charset>). | ||
| * | ||
| * @param string $html HTML content where to look for charset. | ||
| * | ||
| * @return bool|string Charset string if found, false otherwise. | ||
| */ | ||
| function html_extract_charset($html) | ||
| { | ||
| // Get encoding specified in HTML header. | ||
| preg_match('#<meta .*charset=["\']?([^";\'>/]+)["\']? */?>#Usi', $html, $enc); | ||
| if (!empty($enc[1])) { | ||
| return strtolower($enc[1]); | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Extract meta tag from HTML content in either: | ||
| * - OpenGraph: <meta property="og:[tag]" ...> | ||
| * - Meta tag: <meta name="[tag]" ...> | ||
| * | ||
| * @param string $tag Name of the tag to retrieve. | ||
| * @param string $html HTML content where to look for charset. | ||
| * | ||
| * @return bool|string Charset string if found, false otherwise. | ||
| */ | ||
| function html_extract_tag($tag, $html) | ||
| { | ||
| $propertiesKey = ['property', 'name', 'itemprop']; | ||
| $properties = implode('|', $propertiesKey); | ||
| // We need a OR here to accept either 'property=og:noquote' or 'property="og:unrelated og:my-tag"' | ||
| $orCondition = '["\']?(?:og:)?' . $tag . '["\']?|["\'][^\'"]*?(?:og:)?' . $tag . '[^\'"]*?[\'"]'; | ||
| // Support quotes in double quoted content, and the other way around | ||
| $content = 'content=(["\'])((?:(?!\1).)*)\1'; | ||
| // Try to retrieve OpenGraph tag. | ||
| $ogRegex = '#<meta[^>]+(?:' . $properties . ')=(?:' . $orCondition . ')[^>]*' . $content . '.*?>#'; | ||
| // If the attributes are not in the order property => content (e.g. Github) | ||
| // New regex to keep this readable... more or less. | ||
| $ogRegexReverse = '#<meta[^>]+' . $content . '[^>]+(?:' . $properties . ')=(?:' . $orCondition . ').*?>#'; | ||
|
|
||
| if ( | ||
| preg_match($ogRegex, $html, $matches) > 0 | ||
| || preg_match($ogRegexReverse, $html, $matches) > 0 | ||
| ) { | ||
| return $matches[2]; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * In a string, converts URLs to clickable bookmarks. | ||
| * | ||
| * @param string $text input string. | ||
| * | ||
| * @return string returns $text with all bookmarks converted to HTML bookmarks. | ||
| * | ||
| * @see Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 | ||
| */ | ||
| function text2clickable($text) | ||
| { | ||
| $regex = '!(((?:https?|ftp|file)://|apt:|magnet:)\S+[a-z0-9\(\)]/?)!si'; | ||
| $format = function (array $match): string { | ||
| return '<a href="' . | ||
| str_replace( | ||
| BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN, | ||
| '', | ||
| str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[1]) | ||
| ) . | ||
| '">' . $match[1] . '</a>' | ||
| ; | ||
| }; | ||
|
|
||
| return preg_replace_callback($regex, $format, $text); | ||
| } | ||
|
|
||
| /** | ||
| * Auto-link hashtags. | ||
| * | ||
| * @param string $description Given description. | ||
| * @param string $indexUrl Root URL. | ||
| * | ||
| * @return string Description with auto-linked hashtags. | ||
| */ | ||
| function hashtag_autolink($description, $indexUrl = '') | ||
| { | ||
| $tokens = '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN . ')' . | ||
| '(?:' . BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE . ')' | ||
| ; | ||
| /* | ||
| * 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}' . $tokens . ']+)/mui'; | ||
| $format = function (array $match) use ($indexUrl): string { | ||
| $cleanMatch = str_replace( | ||
| BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_OPEN, | ||
| '', | ||
| str_replace(BookmarkDefaultFormatter::SEARCH_HIGHLIGHT_CLOSE, '', $match[2]) | ||
| ); | ||
| return $match[1] . '<a href="' . $indexUrl . './add-tag/' . $cleanMatch . '"' . | ||
| ' title="Hashtag ' . $cleanMatch . '">' . | ||
| '#' . $match[2] . | ||
| '</a>'; | ||
| }; | ||
|
|
||
| return preg_replace_callback($regex, $format, $description); | ||
| } | ||
|
|
||
| /** | ||
| * This function inserts where relevant so that multiple spaces are properly displayed in HTML | ||
| * even in the absence of <pre> (This is used in description to keep text formatting). | ||
| * | ||
| * @param string $text input text. | ||
| * | ||
| * @return string formatted text. | ||
| */ | ||
| function space2nbsp($text) | ||
| { | ||
| return preg_replace('/(^| ) /m', '$1 ', $text); | ||
| } | ||
|
|
||
| /** | ||
| * Format Shaarli's description | ||
| * | ||
| * @param string $description shaare's description. | ||
| * @param string $indexUrl URL to Shaarli's index. | ||
| * @param bool $autolink Turn on/off automatic linkifications of URLs and hashtags | ||
| * | ||
| * @return string formatted description. | ||
| */ | ||
| function format_description($description, $indexUrl = '', $autolink = true) | ||
| { | ||
| if ($autolink) { | ||
| $description = hashtag_autolink(text2clickable($description), $indexUrl); | ||
| } | ||
|
|
||
| return nl2br(space2nbsp($description)); | ||
| } | ||
|
|
||
| /** | ||
| * Generate a small hash for a link. | ||
| * | ||
| * @param DateTime $date Link creation date. | ||
| * @param int $id Link ID. | ||
| * | ||
| * @return string the small hash generated from link data. | ||
| */ | ||
| function link_small_hash($date, $id) | ||
| { | ||
| return smallHash($date->format(Bookmark::LINK_DATE_FORMAT) . $id); | ||
| } | ||
|
|
||
| /** | ||
| * Returns whether or not the link is an internal note. | ||
| * Its URL starts by `?` because it's actually a permalink. | ||
| * | ||
| * @param string $linkUrl | ||
| * | ||
| * @return bool true if internal note, false otherwise. | ||
| */ | ||
| function is_note($linkUrl) | ||
| { | ||
| return isset($linkUrl[0]) && $linkUrl[0] === '?'; | ||
| } | ||
|
|
||
| /** | ||
| * Extract an array of tags from a given tag string, with provided separator. | ||
| * | ||
| * @param string|null $tags String containing a list of tags separated by $separator. | ||
| * @param string $separator Shaarli's default: ' ' (whitespace) | ||
| * | ||
| * @return array List of tags | ||
| */ | ||
| function tags_str2array(?string $tags, string $separator): array | ||
| { | ||
| // For whitespaces, we use the special \s regex character | ||
| $separator = str_replace([' ', '/'], ['\s', '\/'], $separator); | ||
|
|
||
| return preg_split('/\s*' . $separator . '+\s*/', trim($tags ?? ''), -1, PREG_SPLIT_NO_EMPTY) ?: []; | ||
| } | ||
|
|
||
| /** | ||
| * Return a tag string with provided separator from a list of tags. | ||
| * Note that given array is clean up by tags_filter(). | ||
| * | ||
| * @param array|null $tags List of tags | ||
| * @param string $separator | ||
| * | ||
| * @return string | ||
| */ | ||
| function tags_array2str(?array $tags, string $separator): string | ||
| { | ||
| return implode($separator, tags_filter($tags, $separator)); | ||
| } | ||
|
|
||
| /** | ||
| * Clean an array of tags: trim + remove empty entries | ||
| * | ||
| * @param array|null $tags List of tags | ||
| * @param string $separator | ||
| * | ||
| * @return array | ||
| */ | ||
| function tags_filter(?array $tags, string $separator): array | ||
| { | ||
| $trimDefault = " \t\n\r\0\x0B"; | ||
| return array_values(array_filter(array_map(function (string $entry) use ($separator, $trimDefault): string { | ||
| return trim($entry, $trimDefault . $separator); | ||
| }, $tags ?? []))); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Bookmark; | ||
|
|
||
| /** | ||
| * Read-only class used to represent search result, including pagination. | ||
| */ | ||
| class SearchResult | ||
| { | ||
| /** @var Bookmark[] List of result bookmarks with pagination applied */ | ||
| protected $bookmarks; | ||
|
|
||
| /** @var int number of Bookmarks found, with pagination applied */ | ||
| protected $resultCount; | ||
|
|
||
| /** @var int total number of result found */ | ||
| protected $totalCount; | ||
|
|
||
| /** @var int pagination: limit number of result bookmarks */ | ||
| protected $limit; | ||
|
|
||
| /** @var int pagination: offset to apply to complete result list */ | ||
| protected $offset; | ||
|
|
||
| public function __construct(array $bookmarks, int $totalCount, int $offset, ?int $limit) | ||
| { | ||
| $this->bookmarks = $bookmarks; | ||
| $this->resultCount = count($bookmarks); | ||
| $this->totalCount = $totalCount; | ||
| $this->limit = $limit; | ||
| $this->offset = $offset; | ||
| } | ||
|
|
||
| /** | ||
| * Build a SearchResult from provided full result set and pagination settings. | ||
| * | ||
| * @param Bookmark[] $bookmarks Full set of result which will be filtered | ||
| * @param int $offset Start recording results from $offset | ||
| * @param int|null $limit End recording results after $limit bookmarks is reached | ||
| * @param bool $allowOutOfBounds Set to false to display the last page if the offset is out of bound, | ||
| * return empty result set otherwise (default: false) | ||
| * | ||
| * @return SearchResult | ||
| */ | ||
| public static function getSearchResult( | ||
| $bookmarks, | ||
| int $offset = 0, | ||
| ?int $limit = null, | ||
| bool $allowOutOfBounds = false | ||
| ): self { | ||
| $totalCount = count($bookmarks); | ||
| if (!$allowOutOfBounds && $offset > $totalCount) { | ||
| $offset = $limit === null ? 0 : $limit * -1; | ||
| } | ||
|
|
||
| if ($bookmarks instanceof BookmarkArray) { | ||
| $buffer = []; | ||
| foreach ($bookmarks as $key => $value) { | ||
| $buffer[$key] = $value; | ||
| } | ||
| $bookmarks = $buffer; | ||
| } | ||
|
|
||
| return new static( | ||
| array_slice($bookmarks, $offset, $limit, true), | ||
| $totalCount, | ||
| $offset, | ||
| $limit | ||
| ); | ||
| } | ||
|
|
||
| /** @return Bookmark[] List of result bookmarks with pagination applied */ | ||
| public function getBookmarks(): array | ||
| { | ||
| return $this->bookmarks; | ||
| } | ||
|
|
||
| /** @return int number of Bookmarks found, with pagination applied */ | ||
| public function getResultCount(): int | ||
| { | ||
| return $this->resultCount; | ||
| } | ||
|
|
||
| /** @return int total number of result found */ | ||
| public function getTotalCount(): int | ||
| { | ||
| return $this->totalCount; | ||
| } | ||
|
|
||
| /** @return int pagination: limit number of result bookmarks */ | ||
| public function getLimit(): ?int | ||
| { | ||
| return $this->limit; | ||
| } | ||
|
|
||
| /** @return int pagination: offset to apply to complete result list */ | ||
| public function getOffset(): int | ||
| { | ||
| return $this->offset; | ||
| } | ||
|
|
||
| /** @return int Current page of result set in complete results */ | ||
| public function getPage(): int | ||
| { | ||
| if (empty($this->limit)) { | ||
| return $this->offset === 0 ? 1 : 2; | ||
| } | ||
| $base = $this->offset >= 0 ? $this->offset : $this->totalCount + $this->offset; | ||
|
|
||
| return (int) ceil($base / $this->limit) + 1; | ||
| } | ||
|
|
||
| /** @return int Get the # of the last page */ | ||
| public function getLastPage(): int | ||
| { | ||
| if (empty($this->limit)) { | ||
| return $this->offset === 0 ? 1 : 2; | ||
| } | ||
|
|
||
| return (int) ceil($this->totalCount / $this->limit); | ||
| } | ||
|
|
||
| /** @return bool Either the current page is the last one or not */ | ||
| public function isLastPage(): bool | ||
| { | ||
| return $this->getPage() === $this->getLastPage(); | ||
| } | ||
|
|
||
| /** @return bool Either the current page is the first one or not */ | ||
| public function isFirstPage(): bool | ||
| { | ||
| return $this->offset === 0; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Bookmark\Exception; | ||
|
|
||
| use Exception; | ||
|
|
||
| class BookmarkNotFoundException extends Exception | ||
| { | ||
| /** | ||
| * LinkNotFoundException constructor. | ||
| */ | ||
| public function __construct() | ||
| { | ||
| $this->message = t('The link you are trying to reach does not exist or has been deleted.'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Bookmark\Exception; | ||
|
|
||
| class DatastoreNotInitializedException extends \Exception | ||
| { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Bookmark\Exception; | ||
|
|
||
| class EmptyDataStoreException extends \Exception | ||
| { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Bookmark\Exception; | ||
|
|
||
| class InvalidWritableDataException extends \Exception | ||
| { | ||
| /** | ||
| * InvalidWritableDataException constructor. | ||
| */ | ||
| public function __construct() | ||
| { | ||
| $this->message = 'Couldn\'t generate bookmark data to store in the datastore. Skipping file writing.'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Bookmark\Exception; | ||
|
|
||
| class NotEnoughSpaceException extends \Exception | ||
| { | ||
| /** | ||
| * NotEnoughSpaceException constructor. | ||
| */ | ||
| public function __construct() | ||
| { | ||
| $this->message = 'Not enough available disk space to save the datastore.'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?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.'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Config; | ||
|
|
||
| /** | ||
| * Interface ConfigIO | ||
| * | ||
| * This describes how Config types should store their configuration. | ||
| */ | ||
| interface ConfigIO | ||
| { | ||
| /** | ||
| * Read configuration. | ||
| * | ||
| * @param string $filepath Config file absolute path. | ||
| * | ||
| * @return array All configuration in an array. | ||
| */ | ||
| public function read($filepath); | ||
|
|
||
| /** | ||
| * Write configuration. | ||
| * | ||
| * @param string $filepath Config file absolute path. | ||
| * @param array $conf All configuration in an array. | ||
| */ | ||
| public function write($filepath, $conf); | ||
|
|
||
| /** | ||
| * Get config file extension according to config type. | ||
| * | ||
| * @return string Config file extension. | ||
| */ | ||
| public function getExtension(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| <?php | ||
| namespace Shaarli\Config; | ||
|
|
||
| /** | ||
| * Class ConfigJson (ConfigIO implementation) | ||
| * | ||
| * Handle Shaarli's JSON configuration file. | ||
| */ | ||
| class ConfigJson implements ConfigIO | ||
| { | ||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| public function read($filepath) | ||
| { | ||
| if (! is_readable($filepath)) { | ||
| return array(); | ||
| } | ||
| $data = file_get_contents($filepath); | ||
| $data = str_replace(self::getPhpHeaders(), '', $data); | ||
| $data = str_replace(self::getPhpSuffix(), '', $data); | ||
| $data = json_decode(trim($data), true); | ||
| if ($data === null) { | ||
| $errorCode = json_last_error(); | ||
| $error = sprintf( | ||
| 'An error occurred while parsing JSON configuration file (%s): error code #%d', | ||
| $filepath, | ||
| $errorCode | ||
| ); | ||
| $error .= '<br>âžś <code>' . json_last_error_msg() .'</code>'; | ||
| if ($errorCode === JSON_ERROR_SYNTAX) { | ||
| $error .= '<br>'; | ||
| $error .= 'Please check your JSON syntax (without PHP comment tags) using a JSON lint tool such as '; | ||
| $error .= '<a href="http://jsonlint.com/">jsonlint.com</a>.'; | ||
| } | ||
| throw new \Exception($error); | ||
| } | ||
| return $data; | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| public function write($filepath, $conf) | ||
| { | ||
| // JSON_PRETTY_PRINT is available from PHP 5.4. | ||
| $print = defined('JSON_PRETTY_PRINT') ? JSON_PRETTY_PRINT : 0; | ||
| $data = self::getPhpHeaders() . json_encode($conf, $print) . self::getPhpSuffix(); | ||
| if (empty($filepath) || !file_put_contents($filepath, $data)) { | ||
| throw new \Shaarli\Exceptions\IOException( | ||
| $filepath, | ||
| t('Shaarli could not create the config file. '. | ||
| 'Please make sure Shaarli has the right to write in the folder is it installed in.') | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| public function getExtension() | ||
| { | ||
| return '.json.php'; | ||
| } | ||
|
|
||
| /** | ||
| * The JSON data is wrapped in a PHP file for security purpose. | ||
| * This way, even if the file is accessible, credentials and configuration won't be exposed. | ||
| * | ||
| * Note: this isn't a static field because concatenation isn't supported in field declaration before PHP 5.6. | ||
| * | ||
| * @return string PHP start tag and comment tag. | ||
| */ | ||
| public static function getPhpHeaders() | ||
| { | ||
| return '<?php /*'; | ||
| } | ||
|
|
||
| /** | ||
| * Get PHP comment closing tags. | ||
| * | ||
| * Static method for consistency with getPhpHeaders. | ||
| * | ||
| * @return string PHP comment closing. | ||
| */ | ||
| public static function getPhpSuffix() | ||
| { | ||
| return '*/ ?>'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Config; | ||
|
|
||
| /** | ||
| * Class ConfigPhp (ConfigIO implementation) | ||
| * | ||
| * Handle Shaarli's legacy PHP configuration file. | ||
| * Note: this is only designed to support the transition to JSON configuration. | ||
| */ | ||
| class ConfigPhp implements ConfigIO | ||
| { | ||
| /** | ||
| * @var array List of config key without group. | ||
| */ | ||
| public static $ROOT_KEYS = [ | ||
| 'login', | ||
| 'hash', | ||
| 'salt', | ||
| 'timezone', | ||
| 'title', | ||
| 'titleLink', | ||
| 'redirector', | ||
| 'disablesessionprotection', | ||
| 'privateLinkByDefault', | ||
| ]; | ||
|
|
||
| /** | ||
| * Map legacy config keys with the new ones. | ||
| * If ConfigPhp is used, getting <newkey> will actually look for <legacykey>. | ||
| * The updater will use this array to transform keys when switching to JSON. | ||
| * | ||
| * @var array current key => legacy key. | ||
| */ | ||
| public static $LEGACY_KEYS_MAPPING = [ | ||
| 'credentials.login' => 'login', | ||
| 'credentials.hash' => 'hash', | ||
| 'credentials.salt' => 'salt', | ||
| 'resource.data_dir' => 'config.DATADIR', | ||
| 'resource.config' => 'config.CONFIG_FILE', | ||
| 'resource.datastore' => 'config.DATASTORE', | ||
| 'resource.updates' => 'config.UPDATES_FILE', | ||
| 'resource.log' => 'config.LOG_FILE', | ||
| 'resource.update_check' => 'config.UPDATECHECK_FILENAME', | ||
| 'resource.raintpl_tpl' => 'config.RAINTPL_TPL', | ||
| 'resource.theme' => 'config.theme', | ||
| 'resource.raintpl_tmp' => 'config.RAINTPL_TMP', | ||
| 'resource.thumbnails_cache' => 'config.CACHEDIR', | ||
| 'resource.page_cache' => 'config.PAGECACHE', | ||
| 'resource.ban_file' => 'config.IPBANS_FILENAME', | ||
| 'security.session_protection_disabled' => 'disablesessionprotection', | ||
| 'security.ban_after' => 'config.BAN_AFTER', | ||
| 'security.ban_duration' => 'config.BAN_DURATION', | ||
| 'general.title' => 'title', | ||
| 'general.timezone' => 'timezone', | ||
| 'general.header_link' => 'titleLink', | ||
| 'updates.check_updates' => 'config.ENABLE_UPDATECHECK', | ||
| 'updates.check_updates_branch' => 'config.UPDATECHECK_BRANCH', | ||
| 'updates.check_updates_interval' => 'config.UPDATECHECK_INTERVAL', | ||
| 'privacy.default_private_links' => 'privateLinkByDefault', | ||
| 'feed.rss_permalinks' => 'config.ENABLE_RSS_PERMALINKS', | ||
| 'general.links_per_page' => 'config.LINKS_PER_PAGE', | ||
| 'thumbnail.enable_thumbnails' => 'config.ENABLE_THUMBNAILS', | ||
| 'thumbnail.enable_localcache' => 'config.ENABLE_LOCALCACHE', | ||
| 'general.enabled_plugins' => 'config.ENABLED_PLUGINS', | ||
| 'redirector.url' => 'redirector', | ||
| 'redirector.encode_url' => 'config.REDIRECTOR_URLENCODE', | ||
| 'feed.show_atom' => 'config.SHOW_ATOM', | ||
| 'privacy.hide_public_links' => 'config.HIDE_PUBLIC_LINKS', | ||
| 'privacy.hide_timestamps' => 'config.HIDE_TIMESTAMPS', | ||
| 'security.open_shaarli' => 'config.OPEN_SHAARLI', | ||
| ]; | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| public function read($filepath) | ||
| { | ||
| if (! file_exists($filepath) || ! is_readable($filepath)) { | ||
| return []; | ||
| } | ||
|
|
||
| include $filepath; | ||
|
|
||
| $out = []; | ||
| foreach (self::$ROOT_KEYS as $key) { | ||
| $out[$key] = isset($GLOBALS[$key]) ? $GLOBALS[$key] : ''; | ||
| } | ||
| $out['config'] = isset($GLOBALS['config']) ? $GLOBALS['config'] : []; | ||
| $out['plugins'] = isset($GLOBALS['plugins']) ? $GLOBALS['plugins'] : []; | ||
| return $out; | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| public function write($filepath, $conf) | ||
| { | ||
| $configStr = '<?php ' . PHP_EOL; | ||
| foreach (self::$ROOT_KEYS as $key) { | ||
| if (isset($conf[$key])) { | ||
| $configStr .= '$GLOBALS[\'' . $key . '\'] = ' . var_export($conf[$key], true) . ';' . PHP_EOL; | ||
| } | ||
| } | ||
|
|
||
| // Store all $conf['config'] | ||
| foreach ($conf['config'] as $key => $value) { | ||
| $configStr .= '$GLOBALS[\'config\'][\'' | ||
| . $key | ||
| . '\'] = ' | ||
| . var_export($conf['config'][$key], true) . ';' | ||
| . PHP_EOL; | ||
| } | ||
|
|
||
| if (isset($conf['plugins'])) { | ||
| foreach ($conf['plugins'] as $key => $value) { | ||
| $configStr .= '$GLOBALS[\'plugins\'][\'' | ||
| . $key | ||
| . '\'] = ' | ||
| . var_export($conf['plugins'][$key], true) . ';' | ||
| . PHP_EOL; | ||
| } | ||
| } | ||
|
|
||
| if ( | ||
| !file_put_contents($filepath, $configStr) | ||
| || strcmp(file_get_contents($filepath), $configStr) != 0 | ||
| ) { | ||
| throw new \Shaarli\Exceptions\IOException( | ||
| $filepath, | ||
| t('Shaarli could not create the config file. ' . | ||
| 'Please make sure Shaarli has the right to write in the folder is it installed in.') | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| public function getExtension() | ||
| { | ||
| return '.php'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| <?php | ||
|
|
||
| use Shaarli\Config\Exception\PluginConfigOrderException; | ||
| use Shaarli\Plugin\PluginManager; | ||
|
|
||
| /** | ||
| * Plugin configuration helper functions. | ||
| * | ||
| * Note: no access to configuration files here. | ||
| */ | ||
|
|
||
| /** | ||
| * Process plugin administration form data and save it in an array. | ||
| * | ||
| * @param array $formData Data sent by the plugin admin form. | ||
| * | ||
| * @return array New list of enabled plugin, ordered. | ||
| * | ||
| * @throws PluginConfigOrderException Plugins can't be sorted because their order is invalid. | ||
| */ | ||
| function save_plugin_config($formData) | ||
| { | ||
| // We can only save existing plugins | ||
| $directories = str_replace( | ||
| PluginManager::$PLUGINS_PATH . '/', | ||
| '', | ||
| glob(PluginManager::$PLUGINS_PATH . '/*') | ||
| ); | ||
| $formData = array_filter( | ||
| $formData, | ||
| function ($value, string $key) use ($directories) { | ||
| return startsWith($key, 'order') || in_array($key, $directories); | ||
| }, | ||
| ARRAY_FILTER_USE_BOTH | ||
| ); | ||
|
|
||
| // Make sure there are no duplicates in orders. | ||
| if (!validate_plugin_order($formData)) { | ||
| throw new PluginConfigOrderException(); | ||
| } | ||
|
|
||
| $plugins = []; | ||
| $newEnabledPlugins = []; | ||
| foreach ($formData as $key => $data) { | ||
| if (startsWith($key, 'order')) { | ||
| continue; | ||
| } | ||
|
|
||
| // If there is no order, it means a disabled plugin has been enabled. | ||
| if (isset($formData['order_' . $key])) { | ||
| $plugins[(int) $formData['order_' . $key]] = $key; | ||
| } else { | ||
| $newEnabledPlugins[] = $key; | ||
| } | ||
| } | ||
|
|
||
| // New enabled plugins will be added at the end of order. | ||
| $plugins = array_merge($plugins, $newEnabledPlugins); | ||
|
|
||
| // Sort plugins by order. | ||
| if (!ksort($plugins)) { | ||
| throw new PluginConfigOrderException(); | ||
| } | ||
|
|
||
| $finalPlugins = []; | ||
| // Make plugins order continuous. | ||
| foreach ($plugins as $plugin) { | ||
| $finalPlugins[] = $plugin; | ||
| } | ||
|
|
||
| return $finalPlugins; | ||
| } | ||
|
|
||
| /** | ||
| * Validate plugin array submitted. | ||
| * Will fail if there is duplicate orders value. | ||
| * | ||
| * @param array $formData Data from submitted form. | ||
| * | ||
| * @return bool true if ok, false otherwise. | ||
| */ | ||
| function validate_plugin_order($formData) | ||
| { | ||
| $orders = []; | ||
| foreach ($formData as $key => $value) { | ||
| // No duplicate order allowed. | ||
| if (in_array($value, $orders, true)) { | ||
| return false; | ||
| } | ||
|
|
||
| if (startsWith($key, 'order')) { | ||
| $orders[] = $value; | ||
| } | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Affect plugin parameters values from the ConfigManager into plugins array. | ||
| * | ||
| * @param mixed $plugins Plugins array: | ||
| * $plugins[<plugin_name>]['parameters'][<param_name>] = [ | ||
| * 'value' => <value>, | ||
| * 'desc' => <description> | ||
| * ] | ||
| * @param mixed $conf Plugins configuration. | ||
| * | ||
| * @return mixed Updated $plugins array. | ||
| */ | ||
| function load_plugin_parameter_values($plugins, $conf) | ||
| { | ||
| $out = $plugins; | ||
| foreach ($plugins as $name => $plugin) { | ||
| if (empty($plugin['parameters'])) { | ||
| continue; | ||
| } | ||
|
|
||
| foreach ($plugin['parameters'] as $key => $param) { | ||
| if (!empty($conf[$key])) { | ||
| $out[$name]['parameters'][$key]['value'] = $conf[$key]; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return $out; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Config\Exception; | ||
|
|
||
| /** | ||
| * Exception used if a mandatory field is missing in given configuration. | ||
| */ | ||
| class MissingFieldConfigException extends \Exception | ||
| { | ||
| public $field; | ||
|
|
||
| /** | ||
| * Construct exception. | ||
| * | ||
| * @param string $field field name missing. | ||
| */ | ||
| public function __construct($field) | ||
| { | ||
| $this->field = $field; | ||
| $this->message = sprintf(t('Configuration value is required for %s'), $this->field); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Config\Exception; | ||
|
|
||
| /** | ||
| * Exception used if an error occur while saving plugin configuration. | ||
| */ | ||
| class PluginConfigOrderException extends \Exception | ||
| { | ||
| /** | ||
| * Construct exception. | ||
| */ | ||
| public function __construct() | ||
| { | ||
| $this->message = t('An error occurred while trying to save plugins loading order.'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Config\Exception; | ||
|
|
||
| /** | ||
| * Exception used if an unauthorized attempt to edit configuration has been made. | ||
| */ | ||
| class UnauthorizedConfigException extends \Exception | ||
| { | ||
| /** | ||
| * Construct exception. | ||
| */ | ||
| public function __construct() | ||
| { | ||
| $this->message = t('You are not authorized to alter config.'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Container; | ||
|
|
||
| use malkusch\lock\mutex\FlockMutex; | ||
| use Psr\Log\LoggerInterface; | ||
| use Shaarli\Bookmark\BookmarkFileService; | ||
| use Shaarli\Bookmark\BookmarkServiceInterface; | ||
| use Shaarli\Config\ConfigManager; | ||
| use Shaarli\Feed\FeedBuilder; | ||
| use Shaarli\Formatter\FormatterFactory; | ||
| use Shaarli\Front\Controller\Visitor\ErrorController; | ||
| use Shaarli\Front\Controller\Visitor\ErrorNotFoundController; | ||
| use Shaarli\History; | ||
| use Shaarli\Http\HttpAccess; | ||
| use Shaarli\Http\MetadataRetriever; | ||
| use Shaarli\Netscape\NetscapeBookmarkUtils; | ||
| use Shaarli\Plugin\PluginManager; | ||
| use Shaarli\Render\PageBuilder; | ||
| use Shaarli\Render\PageCacheManager; | ||
| use Shaarli\Security\CookieManager; | ||
| use Shaarli\Security\LoginManager; | ||
| use Shaarli\Security\SessionManager; | ||
| use Shaarli\Thumbnailer; | ||
| use Shaarli\Updater\Updater; | ||
| use Shaarli\Updater\UpdaterUtils; | ||
|
|
||
| /** | ||
| * Class ContainerBuilder | ||
| * | ||
| * Helper used to build a Slim container instance with Shaarli's object dependencies. | ||
| * Note that most injected objects MUST be added as closures, to let the container instantiate | ||
| * only the objects it requires during the execution. | ||
| * | ||
| * @package Container | ||
| */ | ||
| class ContainerBuilder | ||
| { | ||
| /** @var ConfigManager */ | ||
| protected $conf; | ||
|
|
||
| /** @var SessionManager */ | ||
| protected $session; | ||
|
|
||
| /** @var CookieManager */ | ||
| protected $cookieManager; | ||
|
|
||
| /** @var LoginManager */ | ||
| protected $login; | ||
|
|
||
| /** @var PluginManager */ | ||
| protected $pluginManager; | ||
|
|
||
| /** @var LoggerInterface */ | ||
| protected $logger; | ||
|
|
||
| /** @var string|null */ | ||
| protected $basePath = null; | ||
|
|
||
| public function __construct( | ||
| ConfigManager $conf, | ||
| SessionManager $session, | ||
| CookieManager $cookieManager, | ||
| LoginManager $login, | ||
| PluginManager $pluginManager, | ||
| LoggerInterface $logger | ||
| ) { | ||
| $this->conf = $conf; | ||
| $this->session = $session; | ||
| $this->login = $login; | ||
| $this->cookieManager = $cookieManager; | ||
| $this->pluginManager = $pluginManager; | ||
| $this->logger = $logger; | ||
| } | ||
|
|
||
| public function build(): ShaarliContainer | ||
| { | ||
| $container = new ShaarliContainer(); | ||
|
|
||
| $container['conf'] = $this->conf; | ||
| $container['sessionManager'] = $this->session; | ||
| $container['cookieManager'] = $this->cookieManager; | ||
| $container['loginManager'] = $this->login; | ||
| $container['pluginManager'] = $this->pluginManager; | ||
| $container['logger'] = $this->logger; | ||
| $container['basePath'] = $this->basePath; | ||
|
|
||
|
|
||
| $container['history'] = function (ShaarliContainer $container): History { | ||
| return new History($container->conf->get('resource.history')); | ||
| }; | ||
|
|
||
| $container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface { | ||
| return new BookmarkFileService( | ||
| $container->conf, | ||
| $container->pluginManager, | ||
| $container->history, | ||
| new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), | ||
| $container->loginManager->isLoggedIn() | ||
| ); | ||
| }; | ||
|
|
||
| $container['metadataRetriever'] = function (ShaarliContainer $container): MetadataRetriever { | ||
| return new MetadataRetriever($container->conf, $container->httpAccess); | ||
| }; | ||
|
|
||
| $container['pageBuilder'] = function (ShaarliContainer $container): PageBuilder { | ||
| return new PageBuilder( | ||
| $container->conf, | ||
| $container->sessionManager->getSession(), | ||
| $container->logger, | ||
| $container->bookmarkService, | ||
| $container->sessionManager->generateToken(), | ||
| $container->loginManager->isLoggedIn() | ||
| ); | ||
| }; | ||
|
|
||
| $container['formatterFactory'] = function (ShaarliContainer $container): FormatterFactory { | ||
| return new FormatterFactory( | ||
| $container->conf, | ||
| $container->loginManager->isLoggedIn() | ||
| ); | ||
| }; | ||
|
|
||
| $container['pageCacheManager'] = function (ShaarliContainer $container): PageCacheManager { | ||
| return new PageCacheManager( | ||
| $container->conf->get('resource.page_cache'), | ||
| $container->loginManager->isLoggedIn() | ||
| ); | ||
| }; | ||
|
|
||
| $container['feedBuilder'] = function (ShaarliContainer $container): FeedBuilder { | ||
| return new FeedBuilder( | ||
| $container->bookmarkService, | ||
| $container->formatterFactory->getFormatter(), | ||
| $container->environment, | ||
| $container->loginManager->isLoggedIn() | ||
| ); | ||
| }; | ||
|
|
||
| $container['thumbnailer'] = function (ShaarliContainer $container): Thumbnailer { | ||
| return new Thumbnailer($container->conf); | ||
| }; | ||
|
|
||
| $container['httpAccess'] = function (): HttpAccess { | ||
| return new HttpAccess(); | ||
| }; | ||
|
|
||
| $container['netscapeBookmarkUtils'] = function (ShaarliContainer $container): NetscapeBookmarkUtils { | ||
| return new NetscapeBookmarkUtils($container->bookmarkService, $container->conf, $container->history); | ||
| }; | ||
|
|
||
| $container['updater'] = function (ShaarliContainer $container): Updater { | ||
| return new Updater( | ||
| UpdaterUtils::readUpdatesFile($container->conf->get('resource.updates')), | ||
| $container->bookmarkService, | ||
| $container->conf, | ||
| $container->loginManager->isLoggedIn() | ||
| ); | ||
| }; | ||
|
|
||
| $container['notFoundHandler'] = function (ShaarliContainer $container): ErrorNotFoundController { | ||
| return new ErrorNotFoundController($container); | ||
| }; | ||
| $container['errorHandler'] = function (ShaarliContainer $container): ErrorController { | ||
| return new ErrorController($container); | ||
| }; | ||
| $container['phpErrorHandler'] = function (ShaarliContainer $container): ErrorController { | ||
| return new ErrorController($container); | ||
| }; | ||
|
|
||
| return $container; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Container; | ||
|
|
||
| use Psr\Log\LoggerInterface; | ||
| use Shaarli\Bookmark\BookmarkServiceInterface; | ||
| use Shaarli\Config\ConfigManager; | ||
| use Shaarli\Feed\FeedBuilder; | ||
| use Shaarli\Formatter\FormatterFactory; | ||
| use Shaarli\History; | ||
| use Shaarli\Http\HttpAccess; | ||
| use Shaarli\Http\MetadataRetriever; | ||
| use Shaarli\Netscape\NetscapeBookmarkUtils; | ||
| use Shaarli\Plugin\PluginManager; | ||
| use Shaarli\Render\PageBuilder; | ||
| use Shaarli\Render\PageCacheManager; | ||
| use Shaarli\Security\CookieManager; | ||
| use Shaarli\Security\LoginManager; | ||
| use Shaarli\Security\SessionManager; | ||
| use Shaarli\Thumbnailer; | ||
| use Shaarli\Updater\Updater; | ||
| use Slim\Container; | ||
|
|
||
| /** | ||
| * Extension of Slim container to document the injected objects. | ||
| * | ||
| * @property string $basePath Shaarli's instance base path (e.g. `/shaarli/`) | ||
| * @property BookmarkServiceInterface $bookmarkService | ||
| * @property CookieManager $cookieManager | ||
| * @property ConfigManager $conf | ||
| * @property mixed[] $environment $_SERVER automatically injected by Slim | ||
| * @property callable $errorHandler Overrides default Slim exception display | ||
| * @property FeedBuilder $feedBuilder | ||
| * @property FormatterFactory $formatterFactory | ||
| * @property History $history | ||
| * @property HttpAccess $httpAccess | ||
| * @property LoginManager $loginManager | ||
| * @property LoggerInterface $logger | ||
| * @property MetadataRetriever $metadataRetriever | ||
| * @property NetscapeBookmarkUtils $netscapeBookmarkUtils | ||
| * @property callable $notFoundHandler Overrides default Slim exception display | ||
| * @property PageBuilder $pageBuilder | ||
| * @property PageCacheManager $pageCacheManager | ||
| * @property callable $phpErrorHandler Overrides default Slim PHP error display | ||
| * @property PluginManager $pluginManager | ||
| * @property SessionManager $sessionManager | ||
| * @property Thumbnailer $thumbnailer | ||
| * @property Updater $updater | ||
| */ | ||
| class ShaarliContainer extends Container | ||
| { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Exceptions; | ||
|
|
||
| use Exception; | ||
|
|
||
| /** | ||
| * Exception class thrown when a filesystem access failure happens | ||
| */ | ||
| class IOException extends Exception | ||
| { | ||
| private $path; | ||
|
|
||
| /** | ||
| * Construct a new IOException | ||
| * | ||
| * @param string $path path to the resource that cannot be accessed | ||
| * @param string $message Custom exception message. | ||
| */ | ||
| public function __construct($path, $message = '') | ||
| { | ||
| $this->path = $path; | ||
| $this->message = empty($message) ? t('Error accessing') : $message; | ||
| $this->message .= ' "' . $this->path . '"'; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Shaarli\Feed; | ||
|
|
||
| use DatePeriod; | ||
|
|
||
| /** | ||
| * Simple cache system, mainly for the RSS/ATOM feeds | ||
| */ | ||
| class CachedPage | ||
| { | ||
| /** Directory containing page caches */ | ||
| protected $cacheDir; | ||
|
|
||
| /** Should this URL be cached (boolean)? */ | ||
| protected $shouldBeCached; | ||
|
|
||
| /** Name of the cache file for this URL */ | ||
| protected $filename; | ||
|
|
||
| /** @var DatePeriod|null Optionally specify a period of time for cache validity */ | ||
| protected $validityPeriod; | ||
|
|
||
| /** | ||
| * Creates a new CachedPage | ||
| * | ||
| * @param string $cacheDir page cache directory | ||
| * @param string $url page URL | ||
| * @param bool $shouldBeCached whether this page needs to be cached | ||
| * @param ?DatePeriod $validityPeriod Optionally specify a time limit on requested cache | ||
| */ | ||
| public function __construct($cacheDir, $url, $shouldBeCached, ?DatePeriod $validityPeriod) | ||
| { | ||
| // TODO: check write access to the cache directory | ||
| $this->cacheDir = $cacheDir; | ||
| $this->filename = $this->cacheDir . '/' . sha1($url) . '.cache'; | ||
| $this->shouldBeCached = $shouldBeCached; | ||
| $this->validityPeriod = $validityPeriod; | ||
| } | ||
|
|
||
| /** | ||
| * Returns the cached version of a page, if it exists and should be cached | ||
| * | ||
| * @return string a cached version of the page if it exists, null otherwise | ||
| */ | ||
| public function cachedVersion() | ||
| { | ||
| if (!$this->shouldBeCached) { | ||
| return null; | ||
| } | ||
| if (!is_file($this->filename)) { | ||
| return null; | ||
| } | ||
| if ($this->validityPeriod !== null) { | ||
| $cacheDate = \DateTime::createFromFormat('U', (string) filemtime($this->filename)); | ||
| if ( | ||
| $cacheDate < $this->validityPeriod->getStartDate() | ||
| || $cacheDate > $this->validityPeriod->getEndDate() | ||
| ) { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| return file_get_contents($this->filename); | ||
| } | ||
|
|
||
| /** | ||
| * Puts a page in the cache | ||
| * | ||
| * @param string $pageContent XML content to cache | ||
| */ | ||
| public function cache($pageContent) | ||
| { | ||
| if (!$this->shouldBeCached) { | ||
| return; | ||
| } | ||
| file_put_contents($this->filename, $pageContent); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,286 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Feed; | ||
|
|
||
| use DateTime; | ||
| use Shaarli\Bookmark\Bookmark; | ||
| use Shaarli\Bookmark\BookmarkServiceInterface; | ||
| use Shaarli\Formatter\BookmarkFormatter; | ||
|
|
||
| /** | ||
| * FeedBuilder class. | ||
| * | ||
| * Used to build ATOM and RSS feeds data. | ||
| */ | ||
| class FeedBuilder | ||
| { | ||
| /** | ||
| * @var string Constant: RSS feed type. | ||
| */ | ||
| public static $FEED_RSS = 'rss'; | ||
|
|
||
| /** | ||
| * @var string Constant: ATOM feed type. | ||
| */ | ||
| public static $FEED_ATOM = 'atom'; | ||
|
|
||
| /** | ||
| * @var string Default language if the locale isn't set. | ||
| */ | ||
| public static $DEFAULT_LANGUAGE = 'en-en'; | ||
|
|
||
| /** | ||
| * @var int Number of bookmarks to display in a feed by default. | ||
| */ | ||
| public static $DEFAULT_NB_LINKS = 50; | ||
|
|
||
| /** | ||
| * @var BookmarkServiceInterface instance. | ||
| */ | ||
| protected $linkDB; | ||
|
|
||
| /** | ||
| * @var BookmarkFormatter instance. | ||
| */ | ||
| protected $formatter; | ||
|
|
||
| /** @var mixed[] $_SERVER */ | ||
| protected $serverInfo; | ||
|
|
||
| /** | ||
| * @var boolean True if the user is currently logged in, false otherwise. | ||
| */ | ||
| protected $isLoggedIn; | ||
|
|
||
| /** | ||
| * @var boolean Use permalinks instead of direct bookmarks if true. | ||
| */ | ||
| protected $usePermalinks; | ||
|
|
||
| /** | ||
| * @var boolean true to hide dates in feeds. | ||
| */ | ||
| protected $hideDates; | ||
|
|
||
| /** | ||
| * @var string server locale. | ||
| */ | ||
| protected $locale; | ||
| /** | ||
| * @var DateTime Latest item date. | ||
| */ | ||
| protected $latestDate; | ||
|
|
||
| /** | ||
| * Feed constructor. | ||
| * | ||
| * @param BookmarkServiceInterface $linkDB LinkDB instance. | ||
| * @param BookmarkFormatter $formatter instance. | ||
| * @param array $serverInfo $_SERVER. | ||
| * @param boolean $isLoggedIn True if the user is currently logged in, false otherwise. | ||
| */ | ||
| public function __construct($linkDB, $formatter, $serverInfo, $isLoggedIn) | ||
| { | ||
| $this->linkDB = $linkDB; | ||
| $this->formatter = $formatter; | ||
| $this->serverInfo = $serverInfo; | ||
| $this->isLoggedIn = $isLoggedIn; | ||
| } | ||
|
|
||
| /** | ||
| * Build data for feed templates. | ||
| * | ||
| * @param string $feedType Type of feed (RSS/ATOM). | ||
| * @param array $userInput $_GET. | ||
| * | ||
| * @return array Formatted data for feeds templates. | ||
| */ | ||
| public function buildData(string $feedType, ?array $userInput) | ||
| { | ||
| // Search for untagged bookmarks | ||
| if (isset($this->userInput['searchtags']) && empty($userInput['searchtags'])) { | ||
| $userInput['searchtags'] = false; | ||
| } | ||
|
|
||
| $limit = $this->getLimit($userInput); | ||
|
|
||
| // Optionally filter the results: | ||
| $searchResult = $this->linkDB->search($userInput ?? [], null, false, false, true, ['limit' => $limit]); | ||
|
|
||
| $pageaddr = escape(index_url($this->serverInfo)); | ||
| $this->formatter->addContextData('index_url', $pageaddr); | ||
| $links = []; | ||
| foreach ($searchResult->getBookmarks() as $key => $bookmark) { | ||
| $links[$key] = $this->buildItem($feedType, $bookmark, $pageaddr); | ||
| } | ||
|
|
||
| $data['language'] = $this->getTypeLanguage($feedType); | ||
| $data['last_update'] = $this->getLatestDateFormatted($feedType); | ||
| $data['show_dates'] = !$this->hideDates || $this->isLoggedIn; | ||
| // Remove leading path from REQUEST_URI (already contained in $pageaddr). | ||
| $requestUri = preg_replace('#(.*?/)(feed.*)#', '$2', escape($this->serverInfo['REQUEST_URI'])); | ||
| $data['self_link'] = $pageaddr . $requestUri; | ||
| $data['index_url'] = $pageaddr; | ||
| $data['usepermalinks'] = $this->usePermalinks === true; | ||
| $data['links'] = $links; | ||
|
|
||
| return $data; | ||
| } | ||
|
|
||
| /** | ||
| * Set this to true to use permalinks instead of direct bookmarks. | ||
| * | ||
| * @param boolean $usePermalinks true to force permalinks. | ||
| */ | ||
| public function setUsePermalinks($usePermalinks) | ||
| { | ||
| $this->usePermalinks = $usePermalinks; | ||
| } | ||
|
|
||
| /** | ||
| * Set this to true to hide timestamps in feeds. | ||
| * | ||
| * @param boolean $hideDates true to enable. | ||
| */ | ||
| public function setHideDates($hideDates) | ||
| { | ||
| $this->hideDates = $hideDates; | ||
| } | ||
|
|
||
| /** | ||
| * Set the locale. Used to show feed language. | ||
| * | ||
| * @param string $locale The locale (eg. 'fr_FR.UTF8'). | ||
| */ | ||
| public function setLocale($locale) | ||
| { | ||
| $this->locale = strtolower($locale); | ||
| } | ||
|
|
||
| /** | ||
| * Build a feed item (one per shaare). | ||
| * | ||
| * @param string $feedType Type of feed (RSS/ATOM). | ||
| * @param Bookmark $link Single link array extracted from LinkDB. | ||
| * @param string $pageaddr Index URL. | ||
| * | ||
| * @return array Link array with feed attributes. | ||
| */ | ||
| protected function buildItem(string $feedType, $link, $pageaddr) | ||
| { | ||
| $data = $this->formatter->format($link); | ||
| $data['guid'] = rtrim($pageaddr, '/') . '/shaare/' . $data['shorturl']; | ||
| if ($this->usePermalinks === true) { | ||
| $permalink = '<a href="' . $data['url'] . '" title="' . t('Direct link') . '">' . t('Direct link') . '</a>'; | ||
| } else { | ||
| $permalink = '<a href="' . $data['guid'] . '" title="' . t('Permalink') . '">' . t('Permalink') . '</a>'; | ||
| } | ||
| $data['description'] .= PHP_EOL . PHP_EOL . '<br>— ' . $permalink; | ||
|
|
||
| $data['pub_iso_date'] = $this->getIsoDate($feedType, $data['created']); | ||
|
|
||
| // atom:entry elements MUST contain exactly one atom:updated element. | ||
| if (!empty($link->getUpdated())) { | ||
| $data['up_iso_date'] = $this->getIsoDate($feedType, $data['updated'], DateTime::ATOM); | ||
| } else { | ||
| $data['up_iso_date'] = $this->getIsoDate($feedType, $data['created'], DateTime::ATOM); | ||
| } | ||
|
|
||
| // Save the more recent item. | ||
| if (empty($this->latestDate) || $this->latestDate < $data['created']) { | ||
| $this->latestDate = $data['created']; | ||
| } | ||
| if (!empty($data['updated']) && $this->latestDate < $data['updated']) { | ||
| $this->latestDate = $data['updated']; | ||
| } | ||
|
|
||
| return $data; | ||
| } | ||
|
|
||
| /** | ||
| * Get the language according to the feed type, based on the locale: | ||
| * | ||
| * - RSS format: en-us (default: 'en-en'). | ||
| * - ATOM format: fr (default: 'en'). | ||
| * | ||
| * @param string $feedType Type of feed (RSS/ATOM). | ||
| * | ||
| * @return string The language. | ||
| */ | ||
| protected function getTypeLanguage(string $feedType) | ||
| { | ||
| // Use the locale do define the language, if available. | ||
| if (!empty($this->locale) && preg_match('/^\w{2}[_\-]\w{2}/', $this->locale)) { | ||
| $length = ($feedType === self::$FEED_RSS) ? 5 : 2; | ||
| return str_replace('_', '-', substr($this->locale, 0, $length)); | ||
| } | ||
| return ($feedType === self::$FEED_RSS) ? 'en-en' : 'en'; | ||
| } | ||
|
|
||
| /** | ||
| * Format the latest item date found according to the feed type. | ||
| * | ||
| * Return an empty string if invalid DateTime is passed. | ||
| * | ||
| * @param string $feedType Type of feed (RSS/ATOM). | ||
| * | ||
| * @return string Formatted date. | ||
| */ | ||
| protected function getLatestDateFormatted(string $feedType) | ||
| { | ||
| if (empty($this->latestDate) || !$this->latestDate instanceof DateTime) { | ||
| return ''; | ||
| } | ||
|
|
||
| $type = ($feedType == self::$FEED_RSS) ? DateTime::RSS : DateTime::ATOM; | ||
| return $this->latestDate->format($type); | ||
| } | ||
|
|
||
| /** | ||
| * Get ISO date from DateTime according to feed type. | ||
| * | ||
| * @param string $feedType Type of feed (RSS/ATOM). | ||
| * @param DateTime $date Date to format. | ||
| * @param string|bool $format Force format. | ||
| * | ||
| * @return string Formatted date. | ||
| */ | ||
| protected function getIsoDate(string $feedType, DateTime $date, $format = false) | ||
| { | ||
| if ($format !== false) { | ||
| return $date->format($format); | ||
| } | ||
| if ($feedType == self::$FEED_RSS) { | ||
| return $date->format(DateTime::RSS); | ||
| } | ||
| return $date->format(DateTime::ATOM); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the number of link to display according to 'nb' user input parameter. | ||
| * | ||
| * If 'nb' not set or invalid, default value: $DEFAULT_NB_LINKS. | ||
| * If 'nb' is set to 'all', display all filtered bookmarks (max parameter). | ||
| * | ||
| * @param array $userInput $_GET. | ||
| * | ||
| * @return int number of bookmarks to display. | ||
| */ | ||
| protected function getLimit(?array $userInput) | ||
| { | ||
| if (empty($userInput['nb'])) { | ||
| return self::$DEFAULT_NB_LINKS; | ||
| } | ||
|
|
||
| if ($userInput['nb'] == 'all') { | ||
| return null; | ||
| } | ||
|
|
||
| $intNb = intval($userInput['nb']); | ||
| if (!is_int($intNb) || $intNb == 0) { | ||
| return self::$DEFAULT_NB_LINKS; | ||
| } | ||
|
|
||
| return $intNb; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,229 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Formatter; | ||
|
|
||
| use Shaarli\Bookmark\Bookmark; | ||
|
|
||
| /** | ||
| * 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 | ||
| { | ||
| public const SEARCH_HIGHLIGHT_OPEN = 'SHAARLI_O_HIGHLIGHT'; | ||
| public const SEARCH_HIGHLIGHT_CLOSE = 'SHAARLI_C_HIGHLIGHT'; | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatTitle($bookmark) | ||
| { | ||
| return escape($bookmark->getTitle()); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatTitleHtml($bookmark) | ||
| { | ||
| $title = $this->tokenizeSearchHighlightField( | ||
| $bookmark->getTitle() ?? '', | ||
| $bookmark->getAdditionalContentEntry('search_highlight')['title'] ?? [] | ||
| ); | ||
|
|
||
| return $this->replaceTokens(escape($title)); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatDescription($bookmark) | ||
| { | ||
| $indexUrl = ! empty($this->contextData['index_url']) ? $this->contextData['index_url'] : ''; | ||
| $description = $this->tokenizeSearchHighlightField( | ||
| $bookmark->getDescription() ?? '', | ||
| $bookmark->getAdditionalContentEntry('search_highlight')['description'] ?? [] | ||
| ); | ||
| $description = format_description( | ||
| escape($description), | ||
| $indexUrl, | ||
| $this->conf->get('formatter_settings.autolink', true) | ||
| ); | ||
|
|
||
| return $this->replaceTokens($description); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatTagList($bookmark) | ||
| { | ||
| return escape(parent::formatTagList($bookmark)); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatTagListHtml($bookmark) | ||
| { | ||
| $tagsSeparator = $this->conf->get('general.tags_separator', ' '); | ||
| if (empty($bookmark->getAdditionalContentEntry('search_highlight')['tags'])) { | ||
| return $this->formatTagList($bookmark); | ||
| } | ||
|
|
||
| $tags = $this->tokenizeSearchHighlightField( | ||
| $bookmark->getTagsString($tagsSeparator), | ||
| $bookmark->getAdditionalContentEntry('search_highlight')['tags'] | ||
| ); | ||
| $tags = $this->filterTagList(tags_str2array($tags, $tagsSeparator)); | ||
| $tags = escape($tags); | ||
| $tags = $this->replaceTokensArray($tags); | ||
|
|
||
| return $tags; | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatTagString($bookmark) | ||
| { | ||
| return implode($this->conf->get('general.tags_separator'), $this->formatTagList($bookmark)); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatUrl($bookmark) | ||
| { | ||
| if ($bookmark->isNote() && isset($this->contextData['index_url'])) { | ||
| return rtrim($this->contextData['index_url'], '/') . '/' . escape(ltrim($bookmark->getUrl(), '/')); | ||
| } | ||
|
|
||
| return escape($bookmark->getUrl()); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatRealUrl($bookmark) | ||
| { | ||
| if ($bookmark->isNote()) { | ||
| if (isset($this->contextData['index_url'])) { | ||
| $prefix = rtrim($this->contextData['index_url'], '/') . '/'; | ||
| } | ||
|
|
||
| if (isset($this->contextData['base_path'])) { | ||
| $prefix = rtrim($this->contextData['base_path'], '/') . '/'; | ||
| } | ||
|
|
||
| return escape($prefix ?? '') . escape(ltrim($bookmark->getUrl() ?? '', '/')); | ||
| } | ||
|
|
||
| return escape($bookmark->getUrl()); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatUrlHtml($bookmark) | ||
| { | ||
| $url = $this->tokenizeSearchHighlightField( | ||
| $bookmark->getUrl() ?? '', | ||
| $bookmark->getAdditionalContentEntry('search_highlight')['url'] ?? [] | ||
| ); | ||
|
|
||
| return $this->replaceTokens(escape($url)); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
| protected function formatThumbnail($bookmark) | ||
| { | ||
| return escape($bookmark->getThumbnail()); | ||
| } | ||
|
|
||
| /** | ||
| * @inheritDoc | ||
| */ | ||
| protected function formatAdditionalContent(Bookmark $bookmark): array | ||
| { | ||
| $additionalContent = parent::formatAdditionalContent($bookmark); | ||
|
|
||
| unset($additionalContent['search_highlight']); | ||
|
|
||
| return $additionalContent; | ||
| } | ||
|
|
||
| /** | ||
| * Insert search highlight token in provided field content based on a list of search result positions | ||
| * | ||
| * @param string $fieldContent | ||
| * @param array|null $positions List of of search results with 'start' and 'end' positions. | ||
| * | ||
| * @return string Updated $fieldContent. | ||
| */ | ||
| protected function tokenizeSearchHighlightField(string $fieldContent, ?array $positions): string | ||
| { | ||
| if (empty($positions)) { | ||
| return $fieldContent; | ||
| } | ||
|
|
||
| $insertedTokens = 0; | ||
| $tokenLength = strlen(static::SEARCH_HIGHLIGHT_OPEN); | ||
| foreach ($positions as $position) { | ||
| $position = [ | ||
| 'start' => $position['start'] + ($insertedTokens * $tokenLength), | ||
| 'end' => $position['end'] + ($insertedTokens * $tokenLength), | ||
| ]; | ||
|
|
||
| $content = mb_substr($fieldContent, 0, $position['start']); | ||
| $content .= static::SEARCH_HIGHLIGHT_OPEN; | ||
| $content .= mb_substr($fieldContent, $position['start'], $position['end'] - $position['start']); | ||
| $content .= static::SEARCH_HIGHLIGHT_CLOSE; | ||
| $content .= mb_substr($fieldContent, $position['end']); | ||
|
|
||
| $fieldContent = $content; | ||
|
|
||
| $insertedTokens += 2; | ||
| } | ||
|
|
||
| return $fieldContent; | ||
| } | ||
|
|
||
| /** | ||
| * Replace search highlight tokens with HTML highlighted span. | ||
| * | ||
| * @param string $fieldContent | ||
| * | ||
| * @return string updated content. | ||
| */ | ||
| protected function replaceTokens(string $fieldContent): string | ||
| { | ||
| return str_replace( | ||
| [static::SEARCH_HIGHLIGHT_OPEN, static::SEARCH_HIGHLIGHT_CLOSE], | ||
| ['<span class="search-highlight">', '</span>'], | ||
| $fieldContent | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Apply replaceTokens to an array of content strings. | ||
| * | ||
| * @param string[] $fieldContents | ||
| * | ||
| * @return array | ||
| */ | ||
| protected function replaceTokensArray(array $fieldContents): array | ||
| { | ||
| foreach ($fieldContents as &$entry) { | ||
| $entry = $this->replaceTokens($entry); | ||
| } | ||
|
|
||
| return $fieldContents; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,390 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Formatter; | ||
|
|
||
| use DateTimeInterface; | ||
| use Shaarli\Bookmark\Bookmark; | ||
| use Shaarli\Config\ConfigManager; | ||
|
|
||
| /** | ||
| * Class BookmarkFormatter | ||
| * | ||
| * Abstract class processing all bookmark attributes through methods designed to be overridden. | ||
| * | ||
| * List of available formatted fields: | ||
| * - id ID | ||
| * - shorturl Unique identifier, used in permalinks | ||
| * - url URL, can be altered in some way, e.g. passing through an HTTP reverse proxy | ||
| * - real_url (legacy) same as `url` | ||
| * - url_html URL to be displayed in HTML content (it can contain HTML tags) | ||
| * - title Title | ||
| * - title_html Title to be displayed in HTML content (it can contain HTML tags) | ||
| * - description Description content. It most likely contains HTML tags | ||
| * - thumbnail Thumbnail: path to local cache file, false if there is none, null if hasn't been retrieved | ||
| * - taglist List of tags (array) | ||
| * - taglist_urlencoded List of tags (array) URL encoded: it must be used to create a link to a URL containing a tag | ||
| * - taglist_html List of tags (array) to be displayed in HTML content (it can contain HTML tags) | ||
| * - tags Tags separated by a single whitespace | ||
| * - tags_urlencoded Tags separated by a single whitespace, URL encoded: must be used to create a link | ||
| * - sticky Is sticky (bool) | ||
| * - private Is private (bool) | ||
| * - class Additional CSS class | ||
| * - created Creation DateTime | ||
| * - updated Last edit DateTime | ||
| * - timestamp Creation timestamp | ||
| * - updated_timestamp Last edit timestamp | ||
| * | ||
| * @package Shaarli\Formatter | ||
| */ | ||
| abstract class BookmarkFormatter | ||
| { | ||
| /** | ||
| * @var ConfigManager | ||
| */ | ||
| protected $conf; | ||
|
|
||
| /** @var bool */ | ||
| protected $isLoggedIn; | ||
|
|
||
| /** | ||
| * @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, bool $isLoggedIn) | ||
| { | ||
| $this->conf = $conf; | ||
| $this->isLoggedIn = $isLoggedIn; | ||
| } | ||
|
|
||
| /** | ||
| * 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['url_html'] = $this->formatUrlHtml($bookmark); | ||
| $out['title'] = $this->formatTitle($bookmark); | ||
| $out['title_html'] = $this->formatTitleHtml($bookmark); | ||
| $out['description'] = $this->formatDescription($bookmark); | ||
| $out['thumbnail'] = $this->formatThumbnail($bookmark); | ||
| $out['taglist'] = $this->formatTagList($bookmark); | ||
| $out['taglist_urlencoded'] = $this->formatTagListUrlEncoded($bookmark); | ||
| $out['taglist_html'] = $this->formatTagListHtml($bookmark); | ||
| $out['tags'] = $this->formatTagString($bookmark); | ||
| $out['tags_urlencoded'] = $this->formatTagStringUrlEncoded($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); | ||
| $out['additional_content'] = $this->formatAdditionalContent($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; | ||
|
|
||
| return $this; | ||
| } | ||
|
|
||
| /** | ||
| * 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 $this->formatUrl($bookmark); | ||
| } | ||
|
|
||
| /** | ||
| * Format Url Html: to be displayed in HTML content, it can contains HTML tags. | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return string formatted Url HTML | ||
| */ | ||
| protected function formatUrlHtml($bookmark) | ||
| { | ||
| return $this->formatUrl($bookmark); | ||
| } | ||
|
|
||
| /** | ||
| * Format Title | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return string formatted Title | ||
| */ | ||
| protected function formatTitle($bookmark) | ||
| { | ||
| return $bookmark->getTitle(); | ||
| } | ||
|
|
||
| /** | ||
| * Format Title HTML: to be displayed in HTML content, it can contains HTML tags. | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return string formatted Title | ||
| */ | ||
| protected function formatTitleHtml($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 $this->filterTagList($bookmark->getTags()); | ||
| } | ||
|
|
||
| /** | ||
| * Format Url Encoded Tags | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return array formatted Tags | ||
| */ | ||
| protected function formatTagListUrlEncoded($bookmark) | ||
| { | ||
| return array_map('urlencode', $this->filterTagList($bookmark->getTags())); | ||
| } | ||
|
|
||
| /** | ||
| * Format Tags HTML: to be displayed in HTML content, it can contains HTML tags. | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return array formatted Tags | ||
| */ | ||
| protected function formatTagListHtml($bookmark) | ||
| { | ||
| return $this->formatTagList($bookmark); | ||
| } | ||
|
|
||
| /** | ||
| * Format TagString | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return string formatted TagString | ||
| */ | ||
| protected function formatTagString($bookmark) | ||
| { | ||
| return implode($this->conf->get('general.tags_separator', ' '), $this->formatTagList($bookmark)); | ||
| } | ||
|
|
||
| /** | ||
| * Format TagString | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return string formatted TagString | ||
| */ | ||
| protected function formatTagStringUrlEncoded($bookmark) | ||
| { | ||
| return implode(' ', $this->formatTagListUrlEncoded($bookmark)); | ||
| } | ||
|
|
||
| /** | ||
| * 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 DateTimeInterface instance | ||
| */ | ||
| protected function formatCreated(Bookmark $bookmark) | ||
| { | ||
| return $bookmark->getCreated(); | ||
| } | ||
|
|
||
| /** | ||
| * Format Updated | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return DateTimeInterface 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; | ||
| } | ||
|
|
||
| /** | ||
| * Format bookmark's additional content | ||
| * | ||
| * @param Bookmark $bookmark instance | ||
| * | ||
| * @return mixed[] | ||
| */ | ||
| protected function formatAdditionalContent(Bookmark $bookmark): array | ||
| { | ||
| return $bookmark->getAdditionalContent(); | ||
| } | ||
|
|
||
| /** | ||
| * Format tag list, e.g. remove private tags if the user is not logged in. | ||
| * TODO: this method is called multiple time to format tags, the result should be cached. | ||
| * | ||
| * @param array $tags | ||
| * | ||
| * @return array | ||
| */ | ||
| protected function filterTagList(array $tags): array | ||
| { | ||
| if ($this->isLoggedIn === true) { | ||
| return $tags; | ||
| } | ||
|
|
||
| $out = []; | ||
| foreach ($tags as $tag) { | ||
| if (strpos($tag, '.') === 0) { | ||
| continue; | ||
| } | ||
|
|
||
| $out[] = $tag; | ||
| } | ||
|
|
||
| return $out; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <?php | ||
|
|
||
| namespace Shaarli\Formatter; | ||
|
|
||
| use Shaarli\Config\ConfigManager; | ||
| use Shaarli\Formatter\Parsedown\ShaarliParsedownExtra; | ||
|
|
||
| /** | ||
| * Class BookmarkMarkdownExtraFormatter | ||
| * | ||
| * Format bookmark description into MarkdownExtra format. | ||
| * | ||
| * @see https://michelf.ca/projects/php-markdown/extra/ | ||
| * | ||
| * @package Shaarli\Formatter | ||
| */ | ||
| class BookmarkMarkdownExtraFormatter extends BookmarkMarkdownFormatter | ||
| { | ||
| public function __construct(ConfigManager $conf, bool $isLoggedIn) | ||
| { | ||
| parent::__construct($conf, $isLoggedIn); | ||
| $this->parsedown = new ShaarliParsedownExtra(); | ||
| } | ||
| } |