LinkDB: move to a proper file, add test coverage

Relates to #71

LinkDB
 - move to application/LinkDB.php
 - code cleanup
   - indentation
   - whitespaces
   - formatting
 - comment cleanup
   - add missing documentation
   - unify formatting

Test coverage for LinkDB
 - constructor
 - public / private access
 - link-related methods

Shaarli utilities (LinkDB dependencies)
 - move startsWith() and endsWith() functions to application/Utils.php
 - add test coverage

Dev utilities
 - Composer: add PHPUnit to dev dependencies
 - Makefile:
    - update lint targets
    - add test targets
    - generate coverage reports

Signed-off-by: VirtualTam <virtualtam@flibidi.net>
This commit is contained in:
VirtualTam 2015-03-12 00:43:02 +01:00 committed by VirtualTam
parent cbecab7735
commit ca74886f30
12 changed files with 1231 additions and 257 deletions

4
.gitignore vendored
View file

@ -16,5 +16,7 @@ pagecache
composer.lock
/vendor/
# Ignore test output
# Ignore test data & output
coverage
tests/datastore.php
phpmd.html

View file

@ -8,12 +8,15 @@
# - install/update test dependencies:
# $ composer install # 1st setup
# $ composer update
# - install Xdebug for PHPUnit code coverage reports:
# - see http://xdebug.org/docs/install
# - enable in php.ini
BIN = vendor/bin
PHP_SOURCE = index.php
MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
PHP_SOURCE = index.php application tests
PHP_COMMA_SOURCE = index.php,application,tests
all: static_analysis_summary
all: static_analysis_summary test
##
# Concise status of the project
@ -21,6 +24,7 @@ all: static_analysis_summary
##
static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary
@echo
##
# PHP_CodeSniffer
@ -62,6 +66,7 @@ copy_paste:
# Detects PHP syntax errors, sorted by category
# Rules documentation: http://phpmd.org/rules/index.html
##
MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode
mess_title:
@echo "-----------------"
@ -70,11 +75,11 @@ mess_title:
### - all warnings
mess_detector: mess_title
@$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__'
### - all warnings + HTML output contains links to PHPMD's documentation
mess_detector_html:
@$(BIN)/phpmd $(PHP_SOURCE) html $(MESS_DETECTOR_RULES) \
@$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \
--reportfile phpmd.html || exit 0
### - warnings grouped by message, sorted by descending frequency order
@ -85,10 +90,24 @@ mess_detector_grouped: mess_title
### - summary: number of warnings by rule set
mess_detector_summary: mess_title
@for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \
warnings=$$($(BIN)/phpmd $(PHP_SOURCE) text $$rule | wc -l); \
warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \
printf "$$warnings\t$$rule\n"; \
done;
##
# PHPUnit
# Runs unitary and functional tests
# Generates an HTML coverage report if Xdebug is enabled
#
# See phpunit.xml for configuration
# https://phpunit.de/manual/current/en/appendixes.configuration.html
##
test: clean
@echo "-------"
@echo "PHPUNIT"
@echo "-------"
@$(BIN)/phpunit tests
##
# Targets for repository and documentation maintenance
##
@ -107,4 +126,4 @@ doc: clean
htmldoc:
for file in `find doc/ -maxdepth 1 -name "*.md"`; do \
pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \
done;
done;

2
application/.htaccess Normal file
View file

@ -0,0 +1,2 @@
Allow from none
Deny from all

412
application/LinkDB.php Normal file
View file

@ -0,0 +1,412 @@
<?php
/**
* Data storage for links.
*
* This object behaves like an associative array.
*
* Example:
* $myLinks = new LinkDB();
* echo $myLinks['20110826_161819']['title'];
* foreach ($myLinks as $link)
* echo $link['title'].' at url '.$link['url'].'; description:'.$link['description'];
*
* Available keys:
* - description: description of the entry
* - linkdate: date of the creation of this entry, in the form YYYYMMDD_HHMMSS
* (e.g.'20110914_192317')
* - 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.
* Relative URLs are permalinks (e.g.'?m-ukcw')
*
* Implements 3 interfaces:
* - ArrayAccess: behaves like an associative array;
* - Countable: there is a count() method;
* - Iterator: usable in foreach () loops.
*/
class LinkDB implements Iterator, Countable, ArrayAccess
{
// 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)
private $urls;
// List of linkdate keys (for the Iterator interface implementation)
private $keys;
// Position in the $this->keys array (for the Iterator interface)
private $position;
// Is the user logged in? (used to filter private links)
private $loggedIn;
/**
* Creates a new LinkDB
*
* Checks if the datastore exists; else, attempts to create a dummy one.
*
* @param $isLoggedIn is the user logged in?
*/
function __construct($isLoggedIn)
{
// FIXME: do not access $GLOBALS, pass the datastore instead
$this->loggedIn = $isLoggedIn;
$this->checkDB();
$this->readdb();
}
/**
* Countable - Counts elements of an object
*/
public function count()
{
return count($this->links);
}
/**
* ArrayAccess - Assigns a value to the specified offset
*/
public function offsetSet($offset, $value)
{
// TODO: use exceptions instead of "die"
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 (empty($offset)) {
die('You must specify a key.');
}
$this->links[$offset] = $value;
$this->urls[$value['url']]=$offset;
}
/**
* ArrayAccess - Whether or not an offset exists
*/
public function offsetExists($offset)
{
return array_key_exists($offset, $this->links);
}
/**
* ArrayAccess - Unsets an offset
*/
public function offsetUnset($offset)
{
if (!$this->loggedIn) {
// TODO: raise an exception
die('You are not authorized to delete a link.');
}
$url = $this->links[$offset]['url'];
unset($this->urls[$url]);
unset($this->links[$offset]);
}
/**
* ArrayAccess - Returns the value at specified offset
*/
public function offsetGet($offset)
{
return isset($this->links[$offset]) ? $this->links[$offset] : null;
}
/**
* Iterator - Returns the current element
*/
function current()
{
return $this->links[$this->keys[$this->position]];
}
/**
* Iterator - Returns the key of the current element
*/
function key()
{
return $this->keys[$this->position];
}
/**
* Iterator - Moves forward to next element
*/
function next()
{
++$this->position;
}
/**
* Iterator - Rewinds the Iterator to the first element
*
* Entries are sorted by date (latest first)
*/
function rewind()
{
$this->keys = array_keys($this->links);
rsort($this->keys);
$this->position = 0;
}
/**
* Iterator - Checks if current position is valid
*/
function valid()
{
return isset($this->keys[$this->position]);
}
/**
* Checks if the DB directory and file exist
*
* If no DB file is found, creates a dummy DB.
*/
private function checkDB()
{
if (file_exists($GLOBALS['config']['DATASTORE'])) {
return;
}
// Create a dummy database for example
$this->links = array();
$link = array(
'title'=>'Shaarli - sebsauvage.net',
'url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli',
'description'=>'Welcome to Shaarli! This is a bookmark. To edit or delete me, you must first login.',
'private'=>0,
'linkdate'=>'20110914_190000',
'tags'=>'opensource software'
);
$this->links[$link['linkdate']] = $link;
$link = array(
'title'=>'My secret stuff... - Pastebin.com',
'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=',
'description'=>'SShhhh!! I\'m a private link only YOU can see. You can delete me too.',
'private'=>1,
'linkdate'=>'20110914_074522',
'tags'=>'secretstuff'
);
$this->links[$link['linkdate']] = $link;
// Write database to disk
// TODO: raise an exception if the file is not write-able
file_put_contents(
// FIXME: do not use $GLOBALS
$GLOBALS['config']['DATASTORE'],
PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
);
}
/**
* Reads database from disk to memory
*/
private function readdb()
{
// Read data
// Note that gzinflate is faster than gzuncompress.
// See: http://www.php.net/manual/en/function.gzdeflate.php#96439
// FIXME: do not use $GLOBALS
$this->links = array();
if (file_exists($GLOBALS['config']['DATASTORE'])) {
$this->links = unserialize(gzinflate(base64_decode(
substr(file_get_contents($GLOBALS['config']['DATASTORE']),
strlen(PHPPREFIX), -strlen(PHPSUFFIX)))));
}
// 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'];
}
}
foreach ($toremove as $linkdate) {
unset($this->links[$linkdate]);
}
}
// 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'];
}
}
/**
* Saves the database from memory to disk
*/
public function savedb()
{
if (!$this->loggedIn) {
// TODO: raise an Exception instead
die('You are not authorized to change the database.');
}
file_put_contents(
$GLOBALS['config']['DATASTORE'],
PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX
);
invalidateCaches();
}
/**
* Returns the link for a given URL, or False if it does not exist.
*/
public function getLinkFromUrl($url)
{
if (isset($this->urls[$url])) {
return $this->links[$this->urls[$url]];
}
return false;
}
/**
* Returns the list of links corresponding to a full-text search
*
* Searches:
* - in the URLs, title and description;
* - are case-insensitive.
*
* Example:
* print_r($mydb->filterFulltext('hollandais'));
*
* mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
* - allows to perform searches on Unicode text
* - see https://github.com/shaarli/Shaarli/issues/75 for examples
*/
public function filterFulltext($searchterms)
{
// 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 = ['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;
}
/**
* Returns the list of links associated with a given list of tags
*
* You can specify one or more tags, separated by space or a comma, e.g.
* print_r($mydb->filterTags('linux programming'));
*/
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;
}
/**
* Returns the list of articles for a given day, chronologically sorted
*
* Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
* print_r($mydb->filterDay('20120125'));
*/
public function filterDay($day)
{
// TODO: check input format
$filtered = array();
foreach ($this->links as $l) {
if (startsWith($l['linkdate'], $day)) {
$filtered[$l['linkdate']] = $l;
}
}
ksort($filtered);
return $filtered;
}
/**
* Returns the article corresponding to a smallHash
*/
public function filterSmallHash($smallHash)
{
$filtered = array();
foreach ($this->links as $l) {
if ($smallHash == smallHash($l['linkdate'])) {
// Yes, this is ugly and slow
$filtered[$l['linkdate']] = $l;
return $filtered;
}
}
return $filtered;
}
/**
* Returns the list of all tags
* Output: associative array key=tags, value=0
*/
public function allTags()
{
$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);
}
}
}
// Sort tags by usage (most used tag first)
arsort($tags);
return $tags;
}
/**
* Returns the list of days containing articles (oldest first)
* Output: An array containing days (in format YYYYMMDD).
*/
public function days()
{
$linkDays = array();
foreach (array_keys($this->links) as $day) {
$linkDays[substr($day, 0, 8)] = 0;
}
$linkDays = array_keys($linkDays);
sort($linkDays);
return $linkDays;
}
}
?>

45
application/Utils.php Normal file
View file

@ -0,0 +1,45 @@
<?php
/**
* Shaarli utilities
*/
/**
* Returns the small hash of a string, using RFC 4648 base64url format
*
* Small hashes:
* - are unique (well, as unique as crc32, at last)
* - are always 6 characters long.
* - only use the following characters: a-z A-Z 0-9 - _ @
* - are NOT cryptographically secure (they CAN be forged)
*
* In Shaarli, they are used as a tinyurl-like link to individual entries,
* e.g. smallHash('20111006_131924') --> yZH23w
*/
function smallHash($text)
{
$t = rtrim(base64_encode(hash('crc32', $text, true)), '=');
return strtr($t, '+/', '-_');
}
/**
* Tells if a string start with a substring
*/
function startsWith($haystack, $needle, $case=true)
{
if ($case) {
return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
}
return (strcasecmp(substr($haystack, 0, strlen($needle)), $needle) === 0);
}
/**
* Tells if a string ends with a substring
*/
function endsWith($haystack, $needle, $case=true)
{
if ($case) {
return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
}
return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0);
}
?>

View file

@ -8,6 +8,7 @@
"require": {},
"require-dev": {
"phpmd/phpmd" : "@stable",
"phpunit/phpunit": "4.6.*",
"sebastian/phpcpd": "*",
"squizlabs/php_codesniffer": "2.*"
}

259
index.php
View file

@ -68,6 +68,10 @@
error_reporting(E_ALL^E_WARNING); // See all error except warnings.
//error_reporting(-1); // See all errors (for debugging only)
// Shaarli library
require_once 'application/LinkDB.php';
require_once 'application/Utils.php';
include "inc/rain.tpl.class.php"; //include Rain TPL
raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory
raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory
@ -268,21 +272,6 @@ function nl2br_escaped($html)
return str_replace('>','&gt;',str_replace('<','&lt;',nl2br($html)));
}
/* Returns the small hash of a string, using RFC 4648 base64url format
e.g. smallHash('20111006_131924') --> yZH23w
Small hashes:
- are unique (well, as unique as crc32, at last)
- are always 6 characters long.
- only use the following characters: a-z A-Z 0-9 - _ @
- are NOT cryptographically secure (they CAN be forged)
In Shaarli, they are used as a tinyurl-like link to individual entries.
*/
function smallHash($text)
{
$t = rtrim(base64_encode(hash('crc32',$text,true)),'=');
return strtr($t, '+/', '-_');
}
// In a string, converts URLs to clickable links.
// Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722
function text2clickable($url)
@ -536,20 +525,6 @@ function getMaxFileSize()
return $maxsize;
}
// Tells if a string start with a substring or not.
function startsWith($haystack,$needle,$case=true)
{
if($case){return (strcmp(substr($haystack, 0, strlen($needle)),$needle)===0);}
return (strcasecmp(substr($haystack, 0, strlen($needle)),$needle)===0);
}
// Tells if a string ends with a substring or not.
function endsWith($haystack,$needle,$case=true)
{
if($case){return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);}
return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);
}
/* Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch)
(used to build the ADD_DATE attribute in Netscape-bookmarks file)
PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */
@ -710,220 +685,6 @@ public function renderPage($page)
}
}
// ------------------------------------------------------------------------------------------
/* Data storage for links.
This object behaves like an associative array.
Example:
$mylinks = new linkdb();
echo $mylinks['20110826_161819']['title'];
foreach($mylinks as $link)
echo $link['title'].' at url '.$link['url'].' ; description:'.$link['description'];
Available keys:
title : Title of the link
url : URL of the link. Can be absolute or relative. Relative URLs are permalinks (e.g.'?m-ukcw')
description : description of the entry
private : Is this link private? 0=no, other value=yes
linkdate : date of the creation of this entry, in the form YYYYMMDD_HHMMSS (e.g.'20110914_192317')
tags : tags attached to this entry (separated by spaces)
We implement 3 interfaces:
- ArrayAccess so that this object behaves like an associative array.
- Iterator so that this object can be used in foreach() loops.
- Countable interface so that we can do a count() on this object.
*/
class linkdb implements Iterator, Countable, ArrayAccess
{
private $links; // List of links (associative array. Key=linkdate (e.g. "20110823_124546"), value= associative array (keys:title,description...)
private $urls; // List of all recorded URLs (key=url, value=linkdate) for fast reserve search (url-->linkdate)
private $keys; // List of linkdate keys (for the Iterator interface implementation)
private $position; // Position in the $this->keys array. (for the Iterator interface implementation.)
private $loggedin; // Is the user logged in? (used to filter private links)
// Constructor:
function __construct($isLoggedIn)
// Input : $isLoggedIn : is the user logged in?
{
$this->loggedin = $isLoggedIn;
$this->checkdb(); // Make sure data file exists.
$this->readdb(); // Then read it.
}
// ---- Countable interface implementation
public function count() { return count($this->links); }
// ---- ArrayAccess interface implementation
public function offsetSet($offset, $value)
{
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 (empty($offset)) die('You must specify a key.');
$this->links[$offset] = $value;
$this->urls[$value['url']]=$offset;
}
public function offsetExists($offset) { return array_key_exists($offset,$this->links); }
public function offsetUnset($offset)
{
if (!$this->loggedin) die('You are not authorized to delete a link.');
$url = $this->links[$offset]['url']; unset($this->urls[$url]);
unset($this->links[$offset]);
}
public function offsetGet($offset) { return isset($this->links[$offset]) ? $this->links[$offset] : null; }
// ---- Iterator interface implementation
function rewind() { $this->keys=array_keys($this->links); rsort($this->keys); $this->position=0; } // Start over for iteration, ordered by date (latest first).
function key() { return $this->keys[$this->position]; } // current key
function current() { return $this->links[$this->keys[$this->position]]; } // current value
function next() { ++$this->position; } // go to next item
function valid() { return isset($this->keys[$this->position]); } // Check if current position is valid.
// ---- Misc methods
private function checkdb() // Check if db directory and file exists.
{
if (!file_exists($GLOBALS['config']['DATASTORE'])) // Create a dummy database for example.
{
$this->links = array();
$link = array('title'=>'Shaarli - sebsauvage.net','url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli','description'=>'Welcome to Shaarli ! This is a bookmark. To edit or delete me, you must first login.','private'=>0,'linkdate'=>'20110914_190000','tags'=>'opensource software');
$this->links[$link['linkdate']] = $link;
$link = array('title'=>'My secret stuff... - Pastebin.com','url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=','description'=>'SShhhh!! I\'m a private link only YOU can see. You can delete me too.','private'=>1,'linkdate'=>'20110914_074522','tags'=>'secretstuff');
$this->links[$link['linkdate']] = $link;
file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX); // Write database to disk
}
}
// Read database from disk to memory
private function readdb()
{
// Read data
$this->links=(file_exists($GLOBALS['config']['DATASTORE']) ? unserialize(gzinflate(base64_decode(substr(file_get_contents($GLOBALS['config']['DATASTORE']),strlen(PHPPREFIX),-strlen(PHPSUFFIX))))) : array() );
// Note that gzinflate is faster than gzuncompress. See: http://www.php.net/manual/en/function.gzdeflate.php#96439
// 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']; }
foreach($toremove as $linkdate) { unset($this->links[$linkdate]); }
}
// 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']; }
}
// Save database from memory to disk.
public function savedb()
{
if (!$this->loggedin) die('You are not authorized to change the database.');
file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX);
invalidateCaches();
}
// Returns the link for a given URL (if it exists). False if it does not exist.
public function getLinkFromUrl($url)
{
if (isset($this->urls[$url])) return $this->links[$this->urls[$url]];
return false;
}
// Case insensitive search among links (in the URLs, title and description). Returns filtered list of links.
// e.g. print_r($mydb->filterFulltext('hollandais'));
public function filterFulltext($searchterms)
{
// FIXME: explode(' ',$searchterms) and perform a AND search.
// FIXME: accept double-quotes to search for a string "as is"?
// Using mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') allows us to perform searches on
// Unicode text. See https://github.com/shaarli/Shaarli/issues/75 for examples.
$filtered=array();
$s = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
foreach($this->links as $l)
{
$found= (strpos(mb_convert_case($l['title'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
|| (strpos(mb_convert_case($l['description'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
|| (strpos(mb_convert_case($l['url'], MB_CASE_LOWER, 'UTF-8'),$s) !== false)
|| (strpos(mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'),$s) !== false);
if ($found) $filtered[$l['linkdate']] = $l;
}
krsort($filtered);
return $filtered;
}
// Filter by tag.
// You can specify one or more tags (tags can be separated by space or comma).
// e.g. print_r($mydb->filterTags('linux programming'));
public function filterTags($tags,$casesensitive=false)
{
// Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
// TODO: 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;
}
// Filter by day. Day must be in the form 'YYYYMMDD' (e.g. '20120125')
// Sort order is: older articles first.
// e.g. print_r($mydb->filterDay('20120125'));
public function filterDay($day)
{
$filtered=array();
foreach($this->links as $l)
{
if (startsWith($l['linkdate'],$day)) $filtered[$l['linkdate']] = $l;
}
ksort($filtered);
return $filtered;
}
// Filter by smallHash.
// Only 1 article is returned.
public function filterSmallHash($smallHash)
{
$filtered=array();
foreach($this->links as $l)
{
if ($smallHash==smallHash($l['linkdate'])) // Yes, this is ugly and slow
{
$filtered[$l['linkdate']] = $l;
return $filtered;
}
}
return $filtered;
}
// Returns the list of all tags
// Output: associative array key=tags, value=0
public function allTags()
{
$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);
arsort($tags); // Sort tags by usage (most used tag first)
return $tags;
}
// Returns the list of days containing articles (oldest first)
// Output: An array containing days (in format YYYYMMDD).
public function days()
{
$linkdays=array();
foreach(array_keys($this->links) as $day)
{
$linkdays[substr($day,0,8)]=0;
}
$linkdays=array_keys($linkdays);
sort($linkdays);
return $linkdays;
}
}
// ------------------------------------------------------------------------------------------
// Output the last N links in RSS 2.0 format.
function showRSS()
@ -941,7 +702,7 @@ function showRSS()
$cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
// If cached was not found (or not usable), then read the database and build the response:
$LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if user it not logged in).
$LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
// Optionally filter the results:
$linksToDisplay=array();
@ -1019,7 +780,7 @@ function showATOM()
$cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
// If cached was not found (or not usable), then read the database and build the response:
$LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
$LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
// Optionally filter the results:
@ -1104,7 +865,7 @@ function showDailyRSS()
$cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn());
$cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; }
// If cached was not found (or not usable), then read the database and build the response:
$LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
$LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
/* Some Shaarlies may have very few links, so we need to look
back in time (rsort()) until we have enough days ($nb_of_days).
@ -1172,7 +933,7 @@ function showDailyRSS()
// "Daily" page.
function showDaily()
{
$LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
$LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
$day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD.
@ -1240,7 +1001,7 @@ function showDaily()
// Render HTML page (according to URL parameters and user rights)
function renderPage()
{
$LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
$LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
// -------- Display login form.
if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login'))
@ -1822,7 +1583,7 @@ function renderPage()
function importFile()
{
if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); }
$LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in).
$LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']);
$filename=$_FILES['filetoupload']['name'];
$filesize=$_FILES['filetoupload']['size'];
$data=file_get_contents($_FILES['filetoupload']['tmp_name']);

15
phpunit.xml Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.5/phpunit.xsd"
colors="true">
<filter>
<whitelist addUncoveredFilesFromWhitelist="true">
<directory suffix=".php">application</directory>
</whitelist>
</filter>
<logging>
<log type="coverage-html" target="coverage" lowUpperBound="30" highLowerBound="80"/>
<log type="coverage-text" target="php://stdout" showUncoveredFiles="true"/>
</logging>
</phpunit>

2
tests/.htaccess Normal file
View file

@ -0,0 +1,2 @@
Allow from none
Deny from all

509
tests/LinkDBTest.php Normal file
View file

@ -0,0 +1,509 @@
<?php
/**
* Link datastore tests
*/
require_once 'application/LinkDB.php';
require_once 'application/Utils.php';
require_once 'tests/utils/ReferenceLinkDB.php';
define('PHPPREFIX', '<?php /* ');
define('PHPSUFFIX', ' */ ?>');
/**
* Unitary tests for LinkDB
*/
class LinkDBTest extends PHPUnit_Framework_TestCase
{
// datastore to test write operations
protected static $testDatastore = 'tests/datastore.php';
protected static $dummyDatastoreSHA1 = 'e3edea8ea7bb50be4bcb404df53fbb4546a7156e';
protected static $refDB = null;
protected static $publicLinkDB = null;
protected static $privateLinkDB = null;
/**
* Instantiates public and private LinkDBs with test data
*
* The reference datastore contains public and private links that
* will be used to test LinkDB's methods:
* - access filtering (public/private),
* - link searches:
* - by day,
* - by tag,
* - by text,
* - etc.
*/
public static function setUpBeforeClass()
{
self::$refDB = new ReferenceLinkDB();
self::$refDB->write(self::$testDatastore, PHPPREFIX, PHPSUFFIX);
$GLOBALS['config']['DATASTORE'] = self::$testDatastore;
self::$publicLinkDB = new LinkDB(false);
self::$privateLinkDB = new LinkDB(true);
}
/**
* Resets test data for each test
*/
protected function setUp()
{
$GLOBALS['config']['DATASTORE'] = self::$testDatastore;
if (file_exists(self::$testDatastore)) {
unlink(self::$testDatastore);
}
}
/**
* Allows to test LinkDB's private methods
*
* @see
* https://sebastian-bergmann.de/archives/881-Testing-Your-Privates.html
* http://stackoverflow.com/a/2798203
*/
protected static function getMethod($name)
{
$class = new ReflectionClass('LinkDB');
$method = $class->getMethod($name);
$method->setAccessible(true);
return $method;
}
/**
* Instantiate LinkDB objects - logged in user
*/
public function testConstructLoggedIn()
{
new LinkDB(true);
$this->assertFileExists(self::$testDatastore);
}
/**
* Instantiate LinkDB objects - logged out or public instance
*/
public function testConstructLoggedOut()
{
new LinkDB(false);
$this->assertFileExists(self::$testDatastore);
}
/**
* Attempt to instantiate a LinkDB whereas the datastore is not writable
*
* @expectedException PHPUnit_Framework_Error_Warning
* @expectedExceptionMessageRegExp /failed to open stream: No such file or directory/
*/
public function testConstructDatastoreNotWriteable()
{
$GLOBALS['config']['DATASTORE'] = 'null/store.db';
new LinkDB(false);
}
/**
* The DB doesn't exist, ensure it is created with dummy content
*/
public function testCheckDBNew()
{
$linkDB = new LinkDB(false);
unlink(self::$testDatastore);
$this->assertFileNotExists(self::$testDatastore);
$checkDB = self::getMethod('checkDB');
$checkDB->invokeArgs($linkDB, array());
$this->assertFileExists(self::$testDatastore);
// ensure the correct data has been written
$this->assertEquals(
self::$dummyDatastoreSHA1,
sha1_file(self::$testDatastore)
);
}
/**
* The DB exists, don't do anything
*/
public function testCheckDBLoad()
{
$linkDB = new LinkDB(false);
$this->assertEquals(
self::$dummyDatastoreSHA1,
sha1_file(self::$testDatastore)
);
$checkDB = self::getMethod('checkDB');
$checkDB->invokeArgs($linkDB, array());
// ensure the datastore is left unmodified
$this->assertEquals(
self::$dummyDatastoreSHA1,
sha1_file(self::$testDatastore)
);
}
/**
* Load an empty DB
*/
public function testReadEmptyDB()
{
file_put_contents(self::$testDatastore, PHPPREFIX.'S7QysKquBQA='.PHPSUFFIX);
$emptyDB = new LinkDB(false);
$this->assertEquals(0, sizeof($emptyDB));
$this->assertEquals(0, count($emptyDB));
}
/**
* Load public links from the DB
*/
public function testReadPublicDB()
{
$this->assertEquals(
self::$refDB->countPublicLinks(),
sizeof(self::$publicLinkDB)
);
}
/**
* Load public and private links from the DB
*/
public function testReadPrivateDB()
{
$this->assertEquals(
self::$refDB->countLinks(),
sizeof(self::$privateLinkDB)
);
}
/**
* Save the links to the DB
*/
public function testSaveDB()
{
$testDB = new LinkDB(true);
$dbSize = sizeof($testDB);
$link = array(
'title'=>'an additional link',
'url'=>'http://dum.my',
'description'=>'One more',
'private'=>0,
'linkdate'=>'20150518_190000',
'tags'=>'unit test'
);
$testDB[$link['linkdate']] = $link;
// TODO: move PageCache to a proper class/file
function invalidateCaches() {}
$testDB->savedb();
$testDB = new LinkDB(true);
$this->assertEquals($dbSize + 1, sizeof($testDB));
}
/**
* Count existing links
*/
public function testCount()
{
$this->assertEquals(
self::$refDB->countPublicLinks(),
self::$publicLinkDB->count()
);
$this->assertEquals(
self::$refDB->countLinks(),
self::$privateLinkDB->count()
);
}
/**
* List the days for which links have been posted
*/
public function testDays()
{
$this->assertEquals(
['20121206', '20130614', '20150310'],
self::$publicLinkDB->days()
);
$this->assertEquals(
['20121206', '20130614', '20141125', '20150310'],
self::$privateLinkDB->days()
);
}
/**
* The URL corresponds to an existing entry in the DB
*/
public function testGetKnownLinkFromURL()
{
$link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/');
$this->assertNotEquals(false, $link);
$this->assertEquals(
'A free software media publishing platform',
$link['description']
);
}
/**
* The URL is not in the DB
*/
public function testGetUnknownLinkFromURL()
{
$this->assertEquals(
false,
self::$publicLinkDB->getLinkFromUrl('http://dev.null')
);
}
/**
* Lists all tags
*/
public function testAllTags()
{
$this->assertEquals(
[
'web' => 3,
'cartoon' => 2,
'gnu' => 2,
'dev' => 1,
'samba' => 1,
'media' => 1,
'software' => 1,
'stallman' => 1,
'free' => 1
],
self::$publicLinkDB->allTags()
);
$this->assertEquals(
[
'web' => 4,
'cartoon' => 3,
'gnu' => 2,
'dev' => 2,
'samba' => 1,
'media' => 1,
'software' => 1,
'stallman' => 1,
'free' => 1,
'html' => 1,
'w3c' => 1,
'css' => 1,
'Mercurial' => 1
],
self::$privateLinkDB->allTags()
);
}
/**
* Filter links using a tag
*/
public function testFilterOneTag()
{
$this->assertEquals(
3,
sizeof(self::$publicLinkDB->filterTags('web', false))
);
$this->assertEquals(
4,
sizeof(self::$privateLinkDB->filterTags('web', false))
);
}
/**
* Filter links using a tag - case-sensitive
*/
public function testFilterCaseSensitiveTag()
{
$this->assertEquals(
0,
sizeof(self::$privateLinkDB->filterTags('mercurial', true))
);
$this->assertEquals(
1,
sizeof(self::$privateLinkDB->filterTags('Mercurial', true))
);
}
/**
* Filter links using a tag combination
*/
public function testFilterMultipleTags()
{
$this->assertEquals(
1,
sizeof(self::$publicLinkDB->filterTags('dev cartoon', false))
);
$this->assertEquals(
2,
sizeof(self::$privateLinkDB->filterTags('dev cartoon', false))
);
}
/**
* Filter links using a non-existent tag
*/
public function testFilterUnknownTag()
{
$this->assertEquals(
0,
sizeof(self::$publicLinkDB->filterTags('null', false))
);
}
/**
* Return links for a given day
*/
public function testFilterDay()
{
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterDay('20121206'))
);
$this->assertEquals(
3,
sizeof(self::$privateLinkDB->filterDay('20121206'))
);
}
/**
* 404 - day not found
*/
public function testFilterUnknownDay()
{
$this->assertEquals(
0,
sizeof(self::$publicLinkDB->filterDay('19700101'))
);
$this->assertEquals(
0,
sizeof(self::$privateLinkDB->filterDay('19700101'))
);
}
/**
* Use an invalid date format
*/
public function testFilterInvalidDay()
{
$this->assertEquals(
0,
sizeof(self::$privateLinkDB->filterDay('Rainy day, dream away'))
);
// TODO: check input format
$this->assertEquals(
6,
sizeof(self::$privateLinkDB->filterDay('20'))
);
}
/**
* Retrieve a link entry with its hash
*/
public function testFilterSmallHash()
{
$links = self::$privateLinkDB->filterSmallHash('IuWvgA');
$this->assertEquals(
1,
sizeof($links)
);
$this->assertEquals(
'MediaGoblin',
$links['20130614_184135']['title']
);
}
/**
* No link for this hash
*/
public function testFilterUnknownSmallHash()
{
$this->assertEquals(
0,
sizeof(self::$privateLinkDB->filterSmallHash('Iblaah'))
);
}
/**
* Full-text search - result from a link's URL
*/
public function testFilterFullTextURL()
{
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterFullText('ars.userfriendly.org'))
);
}
/**
* Full-text search - result from a link's title only
*/
public function testFilterFullTextTitle()
{
// use miscellaneous cases
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterFullText('userfriendly -'))
);
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterFullText('UserFriendly -'))
);
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterFullText('uSeRFrIendlY -'))
);
// use miscellaneous case and offset
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterFullText('RFrIendL'))
);
}
/**
* Full-text search - result from the link's description only
*/
public function testFilterFullTextDescription()
{
$this->assertEquals(
1,
sizeof(self::$publicLinkDB->filterFullText('media publishing'))
);
}
/**
* Full-text search - result from the link's tags only
*/
public function testFilterFullTextTags()
{
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterFullText('gnu'))
);
}
/**
* Full-text search - result set from mixed sources
*/
public function testFilterFullTextMixed()
{
$this->assertEquals(
2,
sizeof(self::$publicLinkDB->filterFullText('free software'))
);
}
}
?>

78
tests/UtilsTest.php Normal file
View file

@ -0,0 +1,78 @@
<?php
/**
* Utilities' tests
*/
require_once 'application/Utils.php';
/**
* Unitary tests for Shaarli utilities
*/
class UtilsTest extends PHPUnit_Framework_TestCase
{
/**
* Represent a link by its hash
*/
public function testSmallHash()
{
$this->assertEquals('CyAAJw', smallHash('http://test.io'));
$this->assertEquals(6, strlen(smallHash('https://github.com')));
}
/**
* Look for a substring at the beginning of a string
*/
public function testStartsWithCaseInsensitive()
{
$this->assertTrue(startsWith('Lorem ipsum', 'lorem', false));
$this->assertTrue(startsWith('Lorem ipsum', 'LoReM i', false));
}
/**
* Look for a substring at the beginning of a string (case-sensitive)
*/
public function testStartsWithCaseSensitive()
{
$this->assertTrue(startsWith('Lorem ipsum', 'Lorem', true));
$this->assertFalse(startsWith('Lorem ipsum', 'lorem', true));
$this->assertFalse(startsWith('Lorem ipsum', 'LoReM i', true));
}
/**
* Look for a substring at the beginning of a string (Unicode)
*/
public function testStartsWithSpecialChars()
{
$this->assertTrue(startsWith('å!ùµ', 'å!', false));
$this->assertTrue(startsWith('µ$åù', 'µ$', true));
}
/**
* Look for a substring at the end of a string
*/
public function testEndsWithCaseInsensitive()
{
$this->assertTrue(endsWith('Lorem ipsum', 'ipsum', false));
$this->assertTrue(endsWith('Lorem ipsum', 'm IpsUM', false));
}
/**
* Look for a substring at the end of a string (case-sensitive)
*/
public function testEndsWithCaseSensitive()
{
$this->assertTrue(endsWith('lorem Ipsum', 'Ipsum', true));
$this->assertFalse(endsWith('lorem Ipsum', 'ipsum', true));
$this->assertFalse(endsWith('lorem Ipsum', 'M IPsuM', true));
}
/**
* Look for a substring at the end of a string (Unicode)
*/
public function testEndsWithSpecialChars()
{
$this->assertTrue(endsWith('å!ùµ', 'ùµ', false));
$this->assertTrue(endsWith('µ$åù', 'åù', true));
}
}
?>

View file

@ -0,0 +1,128 @@
<?php
/**
* Populates a reference datastore to test LinkDB
*/
class ReferenceLinkDB
{
private $links = array();
private $publicCount = 0;
private $privateCount = 0;
/**
* Populates the test DB with reference data
*/
function __construct()
{
$this->addLink(
'Free as in Freedom 2.0',
'https://static.fsf.org/nosvn/faif-2.0.pdf',
'Richard Stallman and the Free Software Revolution',
0,
'20150310_114633',
'free gnu software stallman'
);
$this->addLink(
'MediaGoblin',
'http://mediagoblin.org/',
'A free software media publishing platform',
0,
'20130614_184135',
'gnu media web'
);
$this->addLink(
'w3c-markup-validator',
'https://dvcs.w3.org/hg/markup-validator/summary',
'Mercurial repository for the W3C Validator',
1,
'20141125_084734',
'css html w3c web Mercurial'
);
$this->addLink(
'UserFriendly - Web Designer',
'http://ars.userfriendly.org/cartoons/?id=20121206',
'Naming conventions...',
0,
'20121206_142300',
'dev cartoon web'
);
$this->addLink(
'UserFriendly - Samba',
'http://ars.userfriendly.org/cartoons/?id=20010306',
'Tropical printing',
0,
'20121206_172539',
'samba cartoon web'
);
$this->addLink(
'Geek and Poke',
'http://geek-and-poke.com/',
'',
1,
'20121206_182539',
'dev cartoon'
);
}
/**
* Adds a new link
*/
protected function addLink($title, $url, $description, $private, $date, $tags)
{
$link = array(
'title' => $title,
'url' => $url,
'description' => $description,
'private' => $private,
'linkdate' => $date,
'tags' => $tags,
);
$this->links[$date] = $link;
if ($private) {
$this->privateCount++;
return;
}
$this->publicCount++;
}
/**
* Writes data to the datastore
*/
public function write($filename, $prefix, $suffix)
{
file_put_contents(
$filename,
$prefix.base64_encode(gzdeflate(serialize($this->links))).$suffix
);
}
/**
* Returns the number of links in the reference data
*/
public function countLinks()
{
return $this->publicCount + $this->privateCount;
}
/**
* Returns the number of public links in the reference data
*/
public function countPublicLinks()
{
return $this->publicCount;
}
/**
* Returns the number of private links in the reference data
*/
public function countPrivateLinks()
{
return $this->privateCount;
}
}
?>