*
* Example:
* $myLinks = new LinkDB();
- * echo $myLinks['20110826_161819']['title'];
+ * echo $myLinks[350]['title'];
* foreach ($myLinks as $link)
* echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
*
* Available keys:
+ * - id: primary key, incremental integer identifier (persistent)
* - description: description of the entry
- * - linkdate: date of the creation of this entry, in the form YYYYMMDD_HHMMSS
- * (e.g.'20110914_192317')
+ * - created: creation date of this entry, DateTime object.
+ * - updated: last modification date of this entry, DateTime object.
* - private: Is this link private? 0=no, other value=yes
* - tags: tags attached to this entry (separated by spaces)
* - title Title of the link
- * - url URL of the link. Can be absolute or relative.
+ * - url URL of the link. Used for displayable links (no redirector, relative, etc.).
+ * Can be absolute or relative.
* Relative URLs are permalinks (e.g.'?m-ukcw')
+ * - real_url Absolute processed URL.
+ * - shorturl Permalink smallhash
*
* Implements 3 interfaces:
* - ArrayAccess: behaves like an associative array;
* - Countable: there is a count() method;
* - Iterator: usable in foreach () loops.
+ *
+ * ID mechanism:
+ * ArrayAccess is implemented in a way that will allow to access a link
+ * with the unique identifier ID directly with $link[ID].
+ * Note that it's not the real key of the link array attribute.
+ * This mechanism is in place to have persistent link IDs,
+ * even though the internal array is reordered by date.
+ * Example:
+ * - DB: link #1 (2010-01-01) link #2 (2016-01-01)
+ * - Order: #2 #1
+ * - Import links containing: link #3 (2013-01-01)
+ * - New DB: link #1 (2010-01-01) link #2 (2016-01-01) link #3 (2013-01-01)
+ * - Real order: #2 #3 #1
*/
class LinkDB implements Iterator, Countable, ArrayAccess
{
// Links are stored as a PHP serialized string
private $datastore;
- // Datastore PHP prefix
- protected static $phpPrefix = '<?php /* ';
-
- // Datastore PHP suffix
- protected static $phpSuffix = ' */ ?>';
+ // Link date storage format
+ const LINK_DATE_FORMAT = 'Ymd_His';
// List of links (associative array)
// - key: link date (e.g. "20110823_124546"),
// - value: associative array (keys: title, description...)
private $links;
- // List of all recorded URLs (key=url, value=linkdate)
- // for fast reserve search (url-->linkdate)
+ // List of all recorded URLs (key=url, value=link offset)
+ // for fast reserve search (url-->link offset)
private $urls;
- // List of linkdate keys (for the Iterator interface implementation)
+ /**
+ * @var array List of all links IDS mapped with their array offset.
+ * Map: id->offset.
+ */
+ protected $ids;
+
+ // List of offset keys (for the Iterator interface implementation)
private $keys;
// Position in the $this->keys array (for the Iterator interface)
// Hide public links
private $hidePublicLinks;
+ // link redirector set in user settings.
+ private $redirector;
+
+ /**
+ * Set this to `true` to urlencode link behind redirector link, `false` to leave it untouched.
+ *
+ * Example:
+ * anonym.to needs clean URL while dereferer.org needs urlencoded URL.
+ *
+ * @var boolean $redirectorEncode parameter: true or false
+ */
+ private $redirectorEncode;
+
/**
* Creates a new LinkDB
*
* Checks if the datastore exists; else, attempts to create a dummy one.
*
- * @param $isLoggedIn is the user logged in?
+ * @param string $datastore datastore file path.
+ * @param boolean $isLoggedIn is the user logged in?
+ * @param boolean $hidePublicLinks if true all links are private.
+ * @param string $redirector link redirector set in user settings.
+ * @param boolean $redirectorEncode Enable urlencode on redirected urls (default: true).
*/
- function __construct($datastore, $isLoggedIn, $hidePublicLinks)
+ public function __construct(
+ $datastore,
+ $isLoggedIn,
+ $hidePublicLinks,
+ $redirector = '',
+ $redirectorEncode = true
+ )
{
$this->datastore = $datastore;
$this->loggedIn = $isLoggedIn;
$this->hidePublicLinks = $hidePublicLinks;
- $this->checkDB();
- $this->readdb();
+ $this->redirector = $redirector;
+ $this->redirectorEncode = $redirectorEncode === true;
+ $this->check();
+ $this->read();
}
/**
if (!$this->loggedIn) {
die('You are not authorized to add a link.');
}
- if (empty($value['linkdate']) || empty($value['url'])) {
- die('Internal Error: A link should always have a linkdate and URL.');
+ if (!isset($value['id']) || empty($value['url'])) {
+ die('Internal Error: A link should always have an id and URL.');
+ }
+ if (($offset !== null && ! is_int($offset)) || ! is_int($value['id'])) {
+ die('You must specify an integer as a key.');
+ }
+ if ($offset !== null && $offset !== $value['id']) {
+ die('Array offset and link ID must be equal.');
}
- if (empty($offset)) {
- die('You must specify a key.');
+
+ // If the link exists, we reuse the real offset, otherwise new entry
+ $existing = $this->getLinkOffset($offset);
+ if ($existing !== null) {
+ $offset = $existing;
+ } else {
+ $offset = count($this->links);
}
$this->links[$offset] = $value;
- $this->urls[$value['url']]=$offset;
+ $this->urls[$value['url']] = $offset;
+ $this->ids[$value['id']] = $offset;
}
/**
*/
public function offsetExists($offset)
{
- return array_key_exists($offset, $this->links);
+ return array_key_exists($this->getLinkOffset($offset), $this->links);
}
/**
// TODO: raise an exception
die('You are not authorized to delete a link.');
}
- $url = $this->links[$offset]['url'];
+ $realOffset = $this->getLinkOffset($offset);
+ $url = $this->links[$realOffset]['url'];
unset($this->urls[$url]);
- unset($this->links[$offset]);
+ unset($this->ids[$realOffset]);
+ unset($this->links[$realOffset]);
}
/**
*/
public function offsetGet($offset)
{
- return isset($this->links[$offset]) ? $this->links[$offset] : null;
+ $realOffset = $this->getLinkOffset($offset);
+ return isset($this->links[$realOffset]) ? $this->links[$realOffset] : null;
}
/**
* Iterator - Returns the current element
*/
- function current()
+ public function current()
{
- return $this->links[$this->keys[$this->position]];
+ return $this[$this->keys[$this->position]];
}
/**
* Iterator - Returns the key of the current element
*/
- function key()
+ public function key()
{
return $this->keys[$this->position];
}
/**
* Iterator - Moves forward to next element
*/
- function next()
+ public function next()
{
++$this->position;
}
*
* Entries are sorted by date (latest first)
*/
- function rewind()
+ public function rewind()
{
- $this->keys = array_keys($this->links);
- rsort($this->keys);
+ $this->keys = array_keys($this->ids);
$this->position = 0;
}
/**
* Iterator - Checks if current position is valid
*/
- function valid()
+ public function valid()
{
return isset($this->keys[$this->position]);
}
*
* If no DB file is found, creates a dummy DB.
*/
- private function checkDB()
+ private function check()
{
if (file_exists($this->datastore)) {
return;
// Create a dummy database for example
$this->links = array();
$link = array(
+ 'id' => 1,
'title'=>' Shaarli: the personal, minimalist, super-fast, no-database delicious clone',
'url'=>'https://github.com/shaarli/Shaarli/wiki',
'description'=>'Welcome to Shaarli! This is your first public bookmark. To edit or delete me, you must first login.
You use the community supported version of the original Shaarli project, by Sebastien Sauvage.',
'private'=>0,
- 'linkdate'=> date('Ymd_His'),
+ 'created'=> new DateTime(),
'tags'=>'opensource software'
);
- $this->links[$link['linkdate']] = $link;
+ $link['shorturl'] = link_small_hash($link['created'], $link['id']);
+ $this->links[1] = $link;
$link = array(
+ 'id' => 0,
'title'=>'My secret stuff... - Pastebin.com',
'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
'description'=>'Shhhh! I\'m a private link only YOU can see. You can delete me too.',
'private'=>1,
- 'linkdate'=> date('Ymd_His', strtotime('-1 minute')),
- 'tags'=>'secretstuff'
+ 'created'=> new DateTime('1 minute ago'),
+ 'tags'=>'secretstuff',
);
- $this->links[$link['linkdate']] = $link;
+ $link['shorturl'] = link_small_hash($link['created'], $link['id']);
+ $this->links[0] = $link;
// Write database to disk
- // TODO: raise an exception if the file is not write-able
- file_put_contents(
- $this->datastore,
- self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
- );
+ $this->write();
}
/**
* Reads database from disk to memory
*/
- private function readdb()
+ private function read()
{
-
// Public links are hidden and user not logged in => nothing to show
if ($this->hidePublicLinks && !$this->loggedIn) {
$this->links = array();
return;
}
- // Read data
- // Note that gzinflate is faster than gzuncompress.
- // See: http://www.php.net/manual/en/function.gzdeflate.php#96439
- $this->links = array();
+ $this->links = FileUtils::readFlatDB($this->datastore, []);
- if (file_exists($this->datastore)) {
- $this->links = unserialize(gzinflate(base64_decode(
- substr(file_get_contents($this->datastore),
- strlen(self::$phpPrefix), -strlen(self::$phpSuffix)))));
- }
+ $toremove = array();
+ foreach ($this->links as $key => &$link) {
+ if (! $this->loggedIn && $link['private'] != 0) {
+ // Transition for not upgraded databases.
+ $toremove[] = $key;
+ continue;
+ }
- // If user is not logged in, filter private links.
- if (!$this->loggedIn) {
- $toremove = array();
- foreach ($this->links as $link) {
- if ($link['private'] != 0) {
- $toremove[] = $link['linkdate'];
+ // Sanitize data fields.
+ sanitizeLink($link);
+
+ // Remove private tags if the user is not logged in.
+ if (! $this->loggedIn) {
+ $link['tags'] = preg_replace('/(^|\s+)\.[^($|\s)]+\s*/', ' ', $link['tags']);
+ }
+
+ // Do not use the redirector for internal links (Shaarli note URL starting with a '?').
+ if (!empty($this->redirector) && !startsWith($link['url'], '?')) {
+ $link['real_url'] = $this->redirector;
+ if ($this->redirectorEncode) {
+ $link['real_url'] .= urlencode(unescape($link['url']));
+ } else {
+ $link['real_url'] .= $link['url'];
}
}
- foreach ($toremove as $linkdate) {
- unset($this->links[$linkdate]);
+ else {
+ $link['real_url'] = $link['url'];
}
- }
- // Keep the list of the mapping URLs-->linkdate up-to-date.
- $this->urls = array();
- foreach ($this->links as $link) {
- $this->urls[$link['url']] = $link['linkdate'];
+ // To be able to load links before running the update, and prepare the update
+ if (! isset($link['created'])) {
+ $link['id'] = $link['linkdate'];
+ $link['created'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['linkdate']);
+ if (! empty($link['updated'])) {
+ $link['updated'] = DateTime::createFromFormat(self::LINK_DATE_FORMAT, $link['updated']);
+ }
+ $link['shorturl'] = smallHash($link['linkdate']);
+ }
}
- // Escape links data
- foreach($this->links as &$link) {
- sanitizeLink($link);
+ // If user is not logged in, filter private links.
+ foreach ($toremove as $offset) {
+ unset($this->links[$offset]);
}
+
+ $this->reorder();
+ }
+
+ /**
+ * Saves the database from memory to disk
+ *
+ * @throws IOException the datastore is not writable
+ */
+ private function write()
+ {
+ FileUtils::writeFlatDB($this->datastore, $this->links);
}
/**
* Saves the database from memory to disk
+ *
+ * @param string $pageCacheDir page cache directory
*/
- public function savedb()
+ public function save($pageCacheDir)
{
if (!$this->loggedIn) {
// TODO: raise an Exception instead
die('You are not authorized to change the database.');
}
- file_put_contents(
- $this->datastore,
- self::$phpPrefix.base64_encode(gzdeflate(serialize($this->links))).self::$phpSuffix
- );
- invalidateCaches();
+
+ $this->write();
+
+ invalidateCaches($pageCacheDir);
}
/**
* Returns the link for a given URL, or False if it does not exist.
+ *
+ * @param string $url URL to search for
+ *
+ * @return mixed the existing link if it exists, else 'false'
*/
public function getLinkFromUrl($url)
{
}
/**
- * Returns the list of links corresponding to a full-text search
+ * Returns the shaare corresponding to a smallHash.
*
- * Searches:
- * - in the URLs, title and description;
- * - are case-insensitive.
+ * @param string $request QUERY_STRING server parameter.
*
- * Example:
- * print_r($mydb->filterFulltext('hollandais'));
+ * @return array $filtered array containing permalink data.
*
- * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
- * - allows to perform searches on Unicode text
- * - see https://github.com/shaarli/Shaarli/issues/75 for examples
+ * @throws LinkNotFoundException if the smallhash is malformed or doesn't match any link.
*/
- public function filterFulltext($searchterms)
+ public function filterHash($request)
{
- // FIXME: explode(' ',$searchterms) and perform a AND search.
- // FIXME: accept double-quotes to search for a string "as is"?
- $filtered = array();
- $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
- $keys = array('title', 'description', 'url', 'tags');
-
- foreach ($this->links as $link) {
- $found = false;
-
- foreach ($keys as $key) {
- if (strpos(mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8'),
- $search) !== false) {
- $found = true;
- }
- }
-
- if ($found) {
- $filtered[$link['linkdate']] = $link;
- }
- }
- krsort($filtered);
- return $filtered;
+ $request = substr($request, 0, 6);
+ $linkFilter = new LinkFilter($this->links);
+ return $linkFilter->filter(LinkFilter::$FILTER_HASH, $request);
}
/**
- * Returns the list of links associated with a given list of tags
+ * Returns the list of articles for a given day.
+ *
+ * @param string $request day to filter. Format: YYYYMMDD.
*
- * You can specify one or more tags, separated by space or a comma, e.g.
- * print_r($mydb->filterTags('linux programming'));
+ * @return array list of shaare found.
*/
- public function filterTags($tags, $casesensitive=false)
- {
- // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
- // FIXME: is $casesensitive ever true?
- $t = str_replace(
- ',', ' ',
- ($casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'))
- );
-
- $searchtags = explode(' ', $t);
- $filtered = array();
-
- foreach ($this->links as $l) {
- $linktags = explode(
- ' ',
- ($casesensitive ? $l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'))
- );
-
- if (count(array_intersect($linktags, $searchtags)) == count($searchtags)) {
- $filtered[$l['linkdate']] = $l;
- }
- }
- krsort($filtered);
- return $filtered;
+ public function filterDay($request) {
+ $linkFilter = new LinkFilter($this->links);
+ return $linkFilter->filter(LinkFilter::$FILTER_DAY, $request);
}
-
/**
- * Returns the list of articles for a given day, chronologically sorted
+ * Filter links according to search parameters.
+ *
+ * @param array $filterRequest Search request content. Supported keys:
+ * - searchtags: list of tags
+ * - searchterm: term search
+ * @param bool $casesensitive Optional: Perform case sensitive filter
+ * @param string $visibility return only all/private/public links
+ * @param string $untaggedonly return only untagged links
*
- * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
- * print_r($mydb->filterDay('20120125'));
+ * @return array filtered links, all links if no suitable filter was provided.
*/
- public function filterDay($day)
+ public function filterSearch($filterRequest = array(), $casesensitive = false, $visibility = 'all', $untaggedonly = false)
{
- if (! checkDateFormat('Ymd', $day)) {
- throw new Exception('Invalid date format');
- }
+ // Filter link database according to parameters.
+ $searchtags = isset($filterRequest['searchtags']) ? escape($filterRequest['searchtags']) : '';
+ $searchterm = isset($filterRequest['searchterm']) ? escape($filterRequest['searchterm']) : '';
- $filtered = array();
- foreach ($this->links as $l) {
- if (startsWith($l['linkdate'], $day)) {
- $filtered[$l['linkdate']] = $l;
- }
- }
- ksort($filtered);
- return $filtered;
+ // Search tags + fullsearch - blank string parameter will return all links.
+ $type = LinkFilter::$FILTER_TAG | LinkFilter::$FILTER_TEXT; // == "vuotext"
+ $request = [$searchtags, $searchterm];
+
+ $linkFilter = new LinkFilter($this);
+ return $linkFilter->filter($type, $request, $casesensitive, $visibility, $untaggedonly);
}
/**
- * Returns the article corresponding to a smallHash
+ * Returns the list tags appearing in the links with the given tags
+ * @param $filteringTags: tags selecting the links to consider
+ * @param $visibility: process only all/private/public links
+ * @return: a tag=>linksCount array
*/
- public function filterSmallHash($smallHash)
+ public function linksCountPerTag($filteringTags = [], $visibility = 'all')
{
- $filtered = array();
- foreach ($this->links as $l) {
- if ($smallHash == smallHash($l['linkdate'])) {
- // Yes, this is ugly and slow
- $filtered[$l['linkdate']] = $l;
- return $filtered;
+ $links = empty($filteringTags) ? $this->links : $this->filterSearch(['searchtags' => $filteringTags], false, $visibility);
+ $tags = array();
+ $caseMapping = array();
+ foreach ($links as $link) {
+ foreach (preg_split('/\s+/', $link['tags'], 0, PREG_SPLIT_NO_EMPTY) as $tag) {
+ if (empty($tag)) {
+ continue;
+ }
+ // The first case found will be displayed.
+ if (!isset($caseMapping[strtolower($tag)])) {
+ $caseMapping[strtolower($tag)] = $tag;
+ $tags[$caseMapping[strtolower($tag)]] = 0;
+ }
+ $tags[$caseMapping[strtolower($tag)]]++;
}
}
- return $filtered;
+ // Sort tags by usage (most used tag first)
+ arsort($tags);
+ return $tags;
}
/**
- * Returns the list of all tags
- * Output: associative array key=tags, value=0
+ * Rename or delete a tag across all links.
+ *
+ * @param string $from Tag to rename
+ * @param string $to New tag. If none is provided, the from tag will be deleted
+ *
+ * @return array|bool List of altered links or false on error
*/
- public function allTags()
+ public function renameTag($from, $to)
{
- $tags = array();
- foreach ($this->links as $link) {
- foreach (explode(' ', $link['tags']) as $tag) {
- if (!empty($tag)) {
- $tags[$tag] = (empty($tags[$tag]) ? 1 : $tags[$tag] + 1);
+ if (empty($from)) {
+ return false;
+ }
+ $delete = empty($to);
+ // True for case-sensitive tag search.
+ $linksToAlter = $this->filterSearch(['searchtags' => $from], true);
+ foreach($linksToAlter as $key => &$value)
+ {
+ $tags = preg_split('/\s+/', trim($value['tags']));
+ if (($pos = array_search($from, $tags)) !== false) {
+ if ($delete) {
+ unset($tags[$pos]); // Remove tag.
+ } else {
+ $tags[$pos] = trim($to);
}
+ $value['tags'] = trim(implode(' ', array_unique($tags)));
+ $this[$value['id']] = $value;
}
}
- // Sort tags by usage (most used tag first)
- arsort($tags);
- return $tags;
+
+ return $linksToAlter;
}
/**
public function days()
{
$linkDays = array();
- foreach (array_keys($this->links) as $day) {
- $linkDays[substr($day, 0, 8)] = 0;
+ foreach ($this->links as $link) {
+ $linkDays[$link['created']->format('Ymd')] = 0;
}
$linkDays = array_keys($linkDays);
sort($linkDays);
+
return $linkDays;
}
+
+ /**
+ * Reorder links by creation date (newest first).
+ *
+ * Also update the urls and ids mapping arrays.
+ *
+ * @param string $order ASC|DESC
+ */
+ public function reorder($order = 'DESC')
+ {
+ $order = $order === 'ASC' ? -1 : 1;
+ // Reorder array by dates.
+ usort($this->links, function($a, $b) use ($order) {
+ return $a['created'] < $b['created'] ? 1 * $order : -1 * $order;
+ });
+
+ $this->urls = array();
+ $this->ids = array();
+ foreach ($this->links as $key => $link) {
+ $this->urls[$link['url']] = $key;
+ $this->ids[$link['id']] = $key;
+ }
+ }
+
+ /**
+ * Return the next key for link creation.
+ * E.g. If the last ID is 597, the next will be 598.
+ *
+ * @return int next ID.
+ */
+ public function getNextId()
+ {
+ if (!empty($this->ids)) {
+ return max(array_keys($this->ids)) + 1;
+ }
+ return 0;
+ }
+
+ /**
+ * Returns a link offset in links array from its unique ID.
+ *
+ * @param int $id Persistent ID of a link.
+ *
+ * @return int Real offset in local array, or null if doesn't exist.
+ */
+ protected function getLinkOffset($id)
+ {
+ if (isset($this->ids[$id])) {
+ return $this->ids[$id];
+ }
+ return null;
+ }
}
-?>