diff options
-rw-r--r-- | .gitignore | 4 | ||||
-rw-r--r-- | .travis.yml | 10 | ||||
-rw-r--r-- | Makefile | 33 | ||||
-rw-r--r-- | application/.htaccess | 2 | ||||
-rw-r--r-- | application/LinkDB.php | 419 | ||||
-rw-r--r-- | application/Utils.php | 45 | ||||
-rw-r--r-- | composer.json | 1 | ||||
-rw-r--r-- | inc/shaarli.css | 11 | ||||
-rw-r--r-- | index.php | 281 | ||||
-rw-r--r-- | phpunit.xml | 15 | ||||
-rw-r--r-- | tests/.htaccess | 2 | ||||
-rw-r--r-- | tests/LinkDBTest.php | 509 | ||||
-rw-r--r-- | tests/UtilsTest.php | 78 | ||||
-rw-r--r-- | tests/utils/ReferenceLinkDB.php | 128 | ||||
-rw-r--r-- | tpl/linklist.html | 14 |
15 files changed, 1272 insertions, 280 deletions
@@ -16,5 +16,7 @@ pagecache | |||
16 | composer.lock | 16 | composer.lock |
17 | /vendor/ | 17 | /vendor/ |
18 | 18 | ||
19 | # Ignore test output | 19 | # Ignore test data & output |
20 | coverage | ||
21 | tests/datastore.php | ||
20 | phpmd.html | 22 | phpmd.html |
diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..bcaf682c --- /dev/null +++ b/.travis.yml | |||
@@ -0,0 +1,10 @@ | |||
1 | language: php | ||
2 | php: | ||
3 | - 5.6 | ||
4 | - 5.5 | ||
5 | - 5.4 | ||
6 | install: | ||
7 | - composer self-update | ||
8 | - composer install | ||
9 | script: | ||
10 | - make test | ||
@@ -8,12 +8,15 @@ | |||
8 | # - install/update test dependencies: | 8 | # - install/update test dependencies: |
9 | # $ composer install # 1st setup | 9 | # $ composer install # 1st setup |
10 | # $ composer update | 10 | # $ composer update |
11 | # - install Xdebug for PHPUnit code coverage reports: | ||
12 | # - see http://xdebug.org/docs/install | ||
13 | # - enable in php.ini | ||
11 | 14 | ||
12 | BIN = vendor/bin | 15 | BIN = vendor/bin |
13 | PHP_SOURCE = index.php | 16 | PHP_SOURCE = index.php application tests |
14 | MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode | 17 | PHP_COMMA_SOURCE = index.php,application,tests |
15 | 18 | ||
16 | all: static_analysis_summary | 19 | all: static_analysis_summary test |
17 | 20 | ||
18 | ## | 21 | ## |
19 | # Concise status of the project | 22 | # Concise status of the project |
@@ -21,6 +24,7 @@ all: static_analysis_summary | |||
21 | ## | 24 | ## |
22 | 25 | ||
23 | static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary | 26 | static_analysis_summary: code_sniffer_source copy_paste mess_detector_summary |
27 | @echo | ||
24 | 28 | ||
25 | ## | 29 | ## |
26 | # PHP_CodeSniffer | 30 | # PHP_CodeSniffer |
@@ -62,6 +66,7 @@ copy_paste: | |||
62 | # Detects PHP syntax errors, sorted by category | 66 | # Detects PHP syntax errors, sorted by category |
63 | # Rules documentation: http://phpmd.org/rules/index.html | 67 | # Rules documentation: http://phpmd.org/rules/index.html |
64 | ## | 68 | ## |
69 | MESS_DETECTOR_RULES = cleancode,codesize,controversial,design,naming,unusedcode | ||
65 | 70 | ||
66 | mess_title: | 71 | mess_title: |
67 | @echo "-----------------" | 72 | @echo "-----------------" |
@@ -70,11 +75,11 @@ mess_title: | |||
70 | 75 | ||
71 | ### - all warnings | 76 | ### - all warnings |
72 | mess_detector: mess_title | 77 | mess_detector: mess_title |
73 | @$(BIN)/phpmd $(PHP_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__' | 78 | @$(BIN)/phpmd $(PHP_COMMA_SOURCE) text $(MESS_DETECTOR_RULES) | sed 's_.*\/__' |
74 | 79 | ||
75 | ### - all warnings + HTML output contains links to PHPMD's documentation | 80 | ### - all warnings + HTML output contains links to PHPMD's documentation |
76 | mess_detector_html: | 81 | mess_detector_html: |
77 | @$(BIN)/phpmd $(PHP_SOURCE) html $(MESS_DETECTOR_RULES) \ | 82 | @$(BIN)/phpmd $(PHP_COMMA_SOURCE) html $(MESS_DETECTOR_RULES) \ |
78 | --reportfile phpmd.html || exit 0 | 83 | --reportfile phpmd.html || exit 0 |
79 | 84 | ||
80 | ### - warnings grouped by message, sorted by descending frequency order | 85 | ### - warnings grouped by message, sorted by descending frequency order |
@@ -85,11 +90,25 @@ mess_detector_grouped: mess_title | |||
85 | ### - summary: number of warnings by rule set | 90 | ### - summary: number of warnings by rule set |
86 | mess_detector_summary: mess_title | 91 | mess_detector_summary: mess_title |
87 | @for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \ | 92 | @for rule in $$(echo $(MESS_DETECTOR_RULES) | tr ',' ' '); do \ |
88 | warnings=$$($(BIN)/phpmd $(PHP_SOURCE) text $$rule | wc -l); \ | 93 | warnings=$$($(BIN)/phpmd $(PHP_COMMA_SOURCE) text $$rule | wc -l); \ |
89 | printf "$$warnings\t$$rule\n"; \ | 94 | printf "$$warnings\t$$rule\n"; \ |
90 | done; | 95 | done; |
91 | 96 | ||
92 | ## | 97 | ## |
98 | # PHPUnit | ||
99 | # Runs unitary and functional tests | ||
100 | # Generates an HTML coverage report if Xdebug is enabled | ||
101 | # | ||
102 | # See phpunit.xml for configuration | ||
103 | # https://phpunit.de/manual/current/en/appendixes.configuration.html | ||
104 | ## | ||
105 | test: clean | ||
106 | @echo "-------" | ||
107 | @echo "PHPUNIT" | ||
108 | @echo "-------" | ||
109 | @$(BIN)/phpunit tests | ||
110 | |||
111 | ## | ||
93 | # Targets for repository and documentation maintenance | 112 | # Targets for repository and documentation maintenance |
94 | ## | 113 | ## |
95 | 114 | ||
@@ -107,4 +126,4 @@ doc: clean | |||
107 | htmldoc: | 126 | htmldoc: |
108 | for file in `find doc/ -maxdepth 1 -name "*.md"`; do \ | 127 | for file in `find doc/ -maxdepth 1 -name "*.md"`; do \ |
109 | pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \ | 128 | pandoc -f markdown_github -t html5 -s -c "github-markdown.css" -o doc/`basename $$file .md`.html "$$file"; \ |
110 | done; \ No newline at end of file | 129 | done; |
diff --git a/application/.htaccess b/application/.htaccess new file mode 100644 index 00000000..b584d98c --- /dev/null +++ b/application/.htaccess | |||
@@ -0,0 +1,2 @@ | |||
1 | Allow from none | ||
2 | Deny from all | ||
diff --git a/application/LinkDB.php b/application/LinkDB.php new file mode 100644 index 00000000..137f42e5 --- /dev/null +++ b/application/LinkDB.php | |||
@@ -0,0 +1,419 @@ | |||
1 | <?php | ||
2 | /** | ||
3 | * Data storage for links. | ||
4 | * | ||
5 | * This object behaves like an associative array. | ||
6 | * | ||
7 | * Example: | ||
8 | * $myLinks = new LinkDB(); | ||
9 | * echo $myLinks['20110826_161819']['title']; | ||
10 | * foreach ($myLinks as $link) | ||
11 | * echo $link['title'].' at url '.$link['url'].'; description:'.$link['description']; | ||
12 | * | ||
13 | * Available keys: | ||
14 | * - description: description of the entry | ||
15 | * - linkdate: date of the creation of this entry, in the form YYYYMMDD_HHMMSS | ||
16 | * (e.g.'20110914_192317') | ||
17 | * - private: Is this link private? 0=no, other value=yes | ||
18 | * - tags: tags attached to this entry (separated by spaces) | ||
19 | * - title Title of the link | ||
20 | * - url URL of the link. Can be absolute or relative. | ||
21 | * Relative URLs are permalinks (e.g.'?m-ukcw') | ||
22 | * | ||
23 | * Implements 3 interfaces: | ||
24 | * - ArrayAccess: behaves like an associative array; | ||
25 | * - Countable: there is a count() method; | ||
26 | * - Iterator: usable in foreach () loops. | ||
27 | */ | ||
28 | class LinkDB implements Iterator, Countable, ArrayAccess | ||
29 | { | ||
30 | // List of links (associative array) | ||
31 | // - key: link date (e.g. "20110823_124546"), | ||
32 | // - value: associative array (keys: title, description...) | ||
33 | private $links; | ||
34 | |||
35 | // List of all recorded URLs (key=url, value=linkdate) | ||
36 | // for fast reserve search (url-->linkdate) | ||
37 | private $urls; | ||
38 | |||
39 | // List of linkdate keys (for the Iterator interface implementation) | ||
40 | private $keys; | ||
41 | |||
42 | // Position in the $this->keys array (for the Iterator interface) | ||
43 | private $position; | ||
44 | |||
45 | // Is the user logged in? (used to filter private links) | ||
46 | private $loggedIn; | ||
47 | |||
48 | /** | ||
49 | * Creates a new LinkDB | ||
50 | * | ||
51 | * Checks if the datastore exists; else, attempts to create a dummy one. | ||
52 | * | ||
53 | * @param $isLoggedIn is the user logged in? | ||
54 | */ | ||
55 | function __construct($isLoggedIn) | ||
56 | { | ||
57 | // FIXME: do not access $GLOBALS, pass the datastore instead | ||
58 | $this->loggedIn = $isLoggedIn; | ||
59 | $this->checkDB(); | ||
60 | $this->readdb(); | ||
61 | } | ||
62 | |||
63 | /** | ||
64 | * Countable - Counts elements of an object | ||
65 | */ | ||
66 | public function count() | ||
67 | { | ||
68 | return count($this->links); | ||
69 | } | ||
70 | |||
71 | /** | ||
72 | * ArrayAccess - Assigns a value to the specified offset | ||
73 | */ | ||
74 | public function offsetSet($offset, $value) | ||
75 | { | ||
76 | // TODO: use exceptions instead of "die" | ||
77 | if (!$this->loggedIn) { | ||
78 | die('You are not authorized to add a link.'); | ||
79 | } | ||
80 | if (empty($value['linkdate']) || empty($value['url'])) { | ||
81 | die('Internal Error: A link should always have a linkdate and URL.'); | ||
82 | } | ||
83 | if (empty($offset)) { | ||
84 | die('You must specify a key.'); | ||
85 | } | ||
86 | $this->links[$offset] = $value; | ||
87 | $this->urls[$value['url']]=$offset; | ||
88 | } | ||
89 | |||
90 | /** | ||
91 | * ArrayAccess - Whether or not an offset exists | ||
92 | */ | ||
93 | public function offsetExists($offset) | ||
94 | { | ||
95 | return array_key_exists($offset, $this->links); | ||
96 | } | ||
97 | |||
98 | /** | ||
99 | * ArrayAccess - Unsets an offset | ||
100 | */ | ||
101 | public function offsetUnset($offset) | ||
102 | { | ||
103 | if (!$this->loggedIn) { | ||
104 | // TODO: raise an exception | ||
105 | die('You are not authorized to delete a link.'); | ||
106 | } | ||
107 | $url = $this->links[$offset]['url']; | ||
108 | unset($this->urls[$url]); | ||
109 | unset($this->links[$offset]); | ||
110 | } | ||
111 | |||
112 | /** | ||
113 | * ArrayAccess - Returns the value at specified offset | ||
114 | */ | ||
115 | public function offsetGet($offset) | ||
116 | { | ||
117 | return isset($this->links[$offset]) ? $this->links[$offset] : null; | ||
118 | } | ||
119 | |||
120 | /** | ||
121 | * Iterator - Returns the current element | ||
122 | */ | ||
123 | function current() | ||
124 | { | ||
125 | return $this->links[$this->keys[$this->position]]; | ||
126 | } | ||
127 | |||
128 | /** | ||
129 | * Iterator - Returns the key of the current element | ||
130 | */ | ||
131 | function key() | ||
132 | { | ||
133 | return $this->keys[$this->position]; | ||
134 | } | ||
135 | |||
136 | /** | ||
137 | * Iterator - Moves forward to next element | ||
138 | */ | ||
139 | function next() | ||
140 | { | ||
141 | ++$this->position; | ||
142 | } | ||
143 | |||
144 | /** | ||
145 | * Iterator - Rewinds the Iterator to the first element | ||
146 | * | ||
147 | * Entries are sorted by date (latest first) | ||
148 | */ | ||
149 | function rewind() | ||
150 | { | ||
151 | $this->keys = array_keys($this->links); | ||
152 | rsort($this->keys); | ||
153 | $this->position = 0; | ||
154 | } | ||
155 | |||
156 | /** | ||
157 | * Iterator - Checks if current position is valid | ||
158 | */ | ||
159 | function valid() | ||
160 | { | ||
161 | return isset($this->keys[$this->position]); | ||
162 | } | ||
163 | |||
164 | /** | ||
165 | * Checks if the DB directory and file exist | ||
166 | * | ||
167 | * If no DB file is found, creates a dummy DB. | ||
168 | */ | ||
169 | private function checkDB() | ||
170 | { | ||
171 | if (file_exists($GLOBALS['config']['DATASTORE'])) { | ||
172 | return; | ||
173 | } | ||
174 | |||
175 | // Create a dummy database for example | ||
176 | $this->links = array(); | ||
177 | $link = array( | ||
178 | 'title'=>'Shaarli - sebsauvage.net', | ||
179 | 'url'=>'http://sebsauvage.net/wiki/doku.php?id=php:shaarli', | ||
180 | 'description'=>'Welcome to Shaarli! This is a bookmark. To edit or delete me, you must first login.', | ||
181 | 'private'=>0, | ||
182 | 'linkdate'=>'20110914_190000', | ||
183 | 'tags'=>'opensource software' | ||
184 | ); | ||
185 | $this->links[$link['linkdate']] = $link; | ||
186 | |||
187 | $link = array( | ||
188 | 'title'=>'My secret stuff... - Pastebin.com', | ||
189 | 'url'=>'http://sebsauvage.net/paste/?8434b27936c09649#bR7XsXhoTiLcqCpQbmOpBi3rq2zzQUC5hBI7ZT1O3x8=', | ||
190 | 'description'=>'SShhhh!! I\'m a private link only YOU can see. You can delete me too.', | ||
191 | 'private'=>1, | ||
192 | 'linkdate'=>'20110914_074522', | ||
193 | 'tags'=>'secretstuff' | ||
194 | ); | ||
195 | $this->links[$link['linkdate']] = $link; | ||
196 | |||
197 | // Write database to disk | ||
198 | // TODO: raise an exception if the file is not write-able | ||
199 | file_put_contents( | ||
200 | // FIXME: do not use $GLOBALS | ||
201 | $GLOBALS['config']['DATASTORE'], | ||
202 | PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX | ||
203 | ); | ||
204 | } | ||
205 | |||
206 | /** | ||
207 | * Reads database from disk to memory | ||
208 | */ | ||
209 | private function readdb() | ||
210 | { | ||
211 | |||
212 | // Public links are hidden and user not logged in => nothing to show | ||
213 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) { | ||
214 | $this->links = array(); | ||
215 | return; | ||
216 | } | ||
217 | |||
218 | // Read data | ||
219 | // Note that gzinflate is faster than gzuncompress. | ||
220 | // See: http://www.php.net/manual/en/function.gzdeflate.php#96439 | ||
221 | // FIXME: do not use $GLOBALS | ||
222 | $this->links = array(); | ||
223 | |||
224 | if (file_exists($GLOBALS['config']['DATASTORE'])) { | ||
225 | $this->links = unserialize(gzinflate(base64_decode( | ||
226 | substr(file_get_contents($GLOBALS['config']['DATASTORE']), | ||
227 | strlen(PHPPREFIX), -strlen(PHPSUFFIX))))); | ||
228 | } | ||
229 | |||
230 | // If user is not logged in, filter private links. | ||
231 | if (!$this->loggedIn) { | ||
232 | $toremove = array(); | ||
233 | foreach ($this->links as $link) { | ||
234 | if ($link['private'] != 0) { | ||
235 | $toremove[] = $link['linkdate']; | ||
236 | } | ||
237 | } | ||
238 | foreach ($toremove as $linkdate) { | ||
239 | unset($this->links[$linkdate]); | ||
240 | } | ||
241 | } | ||
242 | |||
243 | // Keep the list of the mapping URLs-->linkdate up-to-date. | ||
244 | $this->urls = array(); | ||
245 | foreach ($this->links as $link) { | ||
246 | $this->urls[$link['url']] = $link['linkdate']; | ||
247 | } | ||
248 | } | ||
249 | |||
250 | /** | ||
251 | * Saves the database from memory to disk | ||
252 | */ | ||
253 | public function savedb() | ||
254 | { | ||
255 | if (!$this->loggedIn) { | ||
256 | // TODO: raise an Exception instead | ||
257 | die('You are not authorized to change the database.'); | ||
258 | } | ||
259 | file_put_contents( | ||
260 | $GLOBALS['config']['DATASTORE'], | ||
261 | PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX | ||
262 | ); | ||
263 | invalidateCaches(); | ||
264 | } | ||
265 | |||
266 | /** | ||
267 | * Returns the link for a given URL, or False if it does not exist. | ||
268 | */ | ||
269 | public function getLinkFromUrl($url) | ||
270 | { | ||
271 | if (isset($this->urls[$url])) { | ||
272 | return $this->links[$this->urls[$url]]; | ||
273 | } | ||
274 | return false; | ||
275 | } | ||
276 | |||
277 | /** | ||
278 | * Returns the list of links corresponding to a full-text search | ||
279 | * | ||
280 | * Searches: | ||
281 | * - in the URLs, title and description; | ||
282 | * - are case-insensitive. | ||
283 | * | ||
284 | * Example: | ||
285 | * print_r($mydb->filterFulltext('hollandais')); | ||
286 | * | ||
287 | * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') | ||
288 | * - allows to perform searches on Unicode text | ||
289 | * - see https://github.com/shaarli/Shaarli/issues/75 for examples | ||
290 | */ | ||
291 | public function filterFulltext($searchterms) | ||
292 | { | ||
293 | // FIXME: explode(' ',$searchterms) and perform a AND search. | ||
294 | // FIXME: accept double-quotes to search for a string "as is"? | ||
295 | $filtered = array(); | ||
296 | $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8'); | ||
297 | $keys = ['title', 'description', 'url', 'tags']; | ||
298 | |||
299 | foreach ($this->links as $link) { | ||
300 | $found = false; | ||
301 | |||
302 | foreach ($keys as $key) { | ||
303 | if (strpos(mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8'), | ||
304 | $search) !== false) { | ||
305 | $found = true; | ||
306 | } | ||
307 | } | ||
308 | |||
309 | if ($found) { | ||
310 | $filtered[$link['linkdate']] = $link; | ||
311 | } | ||
312 | } | ||
313 | krsort($filtered); | ||
314 | return $filtered; | ||
315 | } | ||
316 | |||
317 | /** | ||
318 | * Returns the list of links associated with a given list of tags | ||
319 | * | ||
320 | * You can specify one or more tags, separated by space or a comma, e.g. | ||
321 | * print_r($mydb->filterTags('linux programming')); | ||
322 | */ | ||
323 | public function filterTags($tags, $casesensitive=false) | ||
324 | { | ||
325 | // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) | ||
326 | // FIXME: is $casesensitive ever true? | ||
327 | $t = str_replace( | ||
328 | ',', ' ', | ||
329 | ($casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8')) | ||
330 | ); | ||
331 | |||
332 | $searchtags = explode(' ', $t); | ||
333 | $filtered = array(); | ||
334 | |||
335 | foreach ($this->links as $l) { | ||
336 | $linktags = explode( | ||
337 | ' ', | ||
338 | ($casesensitive ? $l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8')) | ||
339 | ); | ||
340 | |||
341 | if (count(array_intersect($linktags, $searchtags)) == count($searchtags)) { | ||
342 | $filtered[$l['linkdate']] = $l; | ||
343 | } | ||
344 | } | ||
345 | krsort($filtered); | ||
346 | return $filtered; | ||
347 | } | ||
348 | |||
349 | |||
350 | /** | ||
351 | * Returns the list of articles for a given day, chronologically sorted | ||
352 | * | ||
353 | * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. | ||
354 | * print_r($mydb->filterDay('20120125')); | ||
355 | */ | ||
356 | public function filterDay($day) | ||
357 | { | ||
358 | // TODO: check input format | ||
359 | $filtered = array(); | ||
360 | foreach ($this->links as $l) { | ||
361 | if (startsWith($l['linkdate'], $day)) { | ||
362 | $filtered[$l['linkdate']] = $l; | ||
363 | } | ||
364 | } | ||
365 | ksort($filtered); | ||
366 | return $filtered; | ||
367 | } | ||
368 | |||
369 | /** | ||
370 | * Returns the article corresponding to a smallHash | ||
371 | */ | ||
372 | public function filterSmallHash($smallHash) | ||
373 | { | ||
374 | $filtered = array(); | ||
375 | foreach ($this->links as $l) { | ||
376 | if ($smallHash == smallHash($l['linkdate'])) { | ||
377 | // Yes, this is ugly and slow | ||
378 | $filtered[$l['linkdate']] = $l; | ||
379 | return $filtered; | ||
380 | } | ||
381 | } | ||
382 | return $filtered; | ||
383 | } | ||
384 | |||
385 | /** | ||
386 | * Returns the list of all tags | ||
387 | * Output: associative array key=tags, value=0 | ||
388 | */ | ||
389 | public function allTags() | ||
390 | { | ||
391 | $tags = array(); | ||
392 | foreach ($this->links as $link) { | ||
393 | foreach (explode(' ', $link['tags']) as $tag) { | ||
394 | if (!empty($tag)) { | ||
395 | $tags[$tag] = (empty($tags[$tag]) ? 1 : $tags[$tag] + 1); | ||
396 | } | ||
397 | } | ||
398 | } | ||
399 | // Sort tags by usage (most used tag first) | ||
400 | arsort($tags); | ||
401 | return $tags; | ||
402 | } | ||
403 | |||
404 | /** | ||
405 | * Returns the list of days containing articles (oldest first) | ||
406 | * Output: An array containing days (in format YYYYMMDD). | ||
407 | */ | ||
408 | public function days() | ||
409 | { | ||
410 | $linkDays = array(); | ||
411 | foreach (array_keys($this->links) as $day) { | ||
412 | $linkDays[substr($day, 0, 8)] = 0; | ||
413 | } | ||
414 | $linkDays = array_keys($linkDays); | ||
415 | sort($linkDays); | ||
416 | return $linkDays; | ||
417 | } | ||
418 | } | ||
419 | ?> | ||
diff --git a/application/Utils.php b/application/Utils.php new file mode 100644 index 00000000..737f1502 --- /dev/null +++ b/application/Utils.php | |||
@@ -0,0 +1,45 @@ | |||
1 | <?php | ||
2 | /** | ||
3 | * Shaarli utilities | ||
4 | */ | ||
5 | |||
6 | /** | ||
7 | * Returns the small hash of a string, using RFC 4648 base64url format | ||
8 | * | ||
9 | * Small hashes: | ||
10 | * - are unique (well, as unique as crc32, at last) | ||
11 | * - are always 6 characters long. | ||
12 | * - only use the following characters: a-z A-Z 0-9 - _ @ | ||
13 | * - are NOT cryptographically secure (they CAN be forged) | ||
14 | * | ||
15 | * In Shaarli, they are used as a tinyurl-like link to individual entries, | ||
16 | * e.g. smallHash('20111006_131924') --> yZH23w | ||
17 | */ | ||
18 | function smallHash($text) | ||
19 | { | ||
20 | $t = rtrim(base64_encode(hash('crc32', $text, true)), '='); | ||
21 | return strtr($t, '+/', '-_'); | ||
22 | } | ||
23 | |||
24 | /** | ||
25 | * Tells if a string start with a substring | ||
26 | */ | ||
27 | function startsWith($haystack, $needle, $case=true) | ||
28 | { | ||
29 | if ($case) { | ||
30 | return (strcmp(substr($haystack, 0, strlen($needle)), $needle) === 0); | ||
31 | } | ||
32 | return (strcasecmp(substr($haystack, 0, strlen($needle)), $needle) === 0); | ||
33 | } | ||
34 | |||
35 | /** | ||
36 | * Tells if a string ends with a substring | ||
37 | */ | ||
38 | function endsWith($haystack, $needle, $case=true) | ||
39 | { | ||
40 | if ($case) { | ||
41 | return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0); | ||
42 | } | ||
43 | return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)), $needle) === 0); | ||
44 | } | ||
45 | ?> | ||
diff --git a/composer.json b/composer.json index d1f613c1..f6d92c92 100644 --- a/composer.json +++ b/composer.json | |||
@@ -8,6 +8,7 @@ | |||
8 | "require": {}, | 8 | "require": {}, |
9 | "require-dev": { | 9 | "require-dev": { |
10 | "phpmd/phpmd" : "@stable", | 10 | "phpmd/phpmd" : "@stable", |
11 | "phpunit/phpunit": "4.6.*", | ||
11 | "sebastian/phpcpd": "*", | 12 | "sebastian/phpcpd": "*", |
12 | "squizlabs/php_codesniffer": "2.*" | 13 | "squizlabs/php_codesniffer": "2.*" |
13 | } | 14 | } |
diff --git a/inc/shaarli.css b/inc/shaarli.css index da0c3599..fcd5c6a2 100644 --- a/inc/shaarli.css +++ b/inc/shaarli.css | |||
@@ -221,8 +221,17 @@ h1 { | |||
221 | margin-left:24px; | 221 | margin-left:24px; |
222 | } | 222 | } |
223 | 223 | ||
224 | .tagfilter div.awesomplete { | ||
225 | width: inherit; | ||
226 | } | ||
227 | |||
224 | .tagfilter #tagfilter_value { | 228 | .tagfilter #tagfilter_value { |
225 | width: 10%; | 229 | width: 100%; |
230 | display: inline; | ||
231 | } | ||
232 | |||
233 | .tagfilter li { | ||
234 | color: black; | ||
226 | } | 235 | } |
227 | 236 | ||
228 | .tagfilter input.bigbutton, .searchform input.bigbutton, .addform input.bigbutton { | 237 | .tagfilter input.bigbutton, .searchform input.bigbutton, .addform input.bigbutton { |
@@ -68,6 +68,10 @@ checkphpversion(); | |||
68 | error_reporting(E_ALL^E_WARNING); // See all error except warnings. | 68 | error_reporting(E_ALL^E_WARNING); // See all error except warnings. |
69 | //error_reporting(-1); // See all errors (for debugging only) | 69 | //error_reporting(-1); // See all errors (for debugging only) |
70 | 70 | ||
71 | // Shaarli library | ||
72 | require_once 'application/LinkDB.php'; | ||
73 | require_once 'application/Utils.php'; | ||
74 | |||
71 | include "inc/rain.tpl.class.php"; //include Rain TPL | 75 | include "inc/rain.tpl.class.php"; //include Rain TPL |
72 | raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory | 76 | raintpl::$tpl_dir = $GLOBALS['config']['RAINTPL_TPL']; // template directory |
73 | raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory | 77 | raintpl::$cache_dir = $GLOBALS['config']['RAINTPL_TMP']; // cache directory |
@@ -268,21 +272,6 @@ function nl2br_escaped($html) | |||
268 | return str_replace('>','>',str_replace('<','<',nl2br($html))); | 272 | return str_replace('>','>',str_replace('<','<',nl2br($html))); |
269 | } | 273 | } |
270 | 274 | ||
271 | /* Returns the small hash of a string, using RFC 4648 base64url format | ||
272 | e.g. smallHash('20111006_131924') --> yZH23w | ||
273 | Small hashes: | ||
274 | - are unique (well, as unique as crc32, at last) | ||
275 | - are always 6 characters long. | ||
276 | - only use the following characters: a-z A-Z 0-9 - _ @ | ||
277 | - are NOT cryptographically secure (they CAN be forged) | ||
278 | In Shaarli, they are used as a tinyurl-like link to individual entries. | ||
279 | */ | ||
280 | function smallHash($text) | ||
281 | { | ||
282 | $t = rtrim(base64_encode(hash('crc32',$text,true)),'='); | ||
283 | return strtr($t, '+/', '-_'); | ||
284 | } | ||
285 | |||
286 | // In a string, converts URLs to clickable links. | 275 | // In a string, converts URLs to clickable links. |
287 | // Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 | 276 | // Function inspired from http://www.php.net/manual/en/function.preg-replace.php#85722 |
288 | function text2clickable($url) | 277 | function text2clickable($url) |
@@ -536,20 +525,6 @@ function getMaxFileSize() | |||
536 | return $maxsize; | 525 | return $maxsize; |
537 | } | 526 | } |
538 | 527 | ||
539 | // Tells if a string start with a substring or not. | ||
540 | function startsWith($haystack,$needle,$case=true) | ||
541 | { | ||
542 | if($case){return (strcmp(substr($haystack, 0, strlen($needle)),$needle)===0);} | ||
543 | return (strcasecmp(substr($haystack, 0, strlen($needle)),$needle)===0); | ||
544 | } | ||
545 | |||
546 | // Tells if a string ends with a substring or not. | ||
547 | function endsWith($haystack,$needle,$case=true) | ||
548 | { | ||
549 | if($case){return (strcmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0);} | ||
550 | return (strcasecmp(substr($haystack, strlen($haystack) - strlen($needle)),$needle)===0); | ||
551 | } | ||
552 | |||
553 | /* Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch) | 528 | /* Converts a linkdate time (YYYYMMDD_HHMMSS) of an article to a timestamp (Unix epoch) |
554 | (used to build the ADD_DATE attribute in Netscape-bookmarks file) | 529 | (used to build the ADD_DATE attribute in Netscape-bookmarks file) |
555 | PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */ | 530 | PS: I could have used strptime(), but it does not exist on Windows. I'm too kind. */ |
@@ -711,220 +686,6 @@ class pageBuilder | |||
711 | } | 686 | } |
712 | 687 | ||
713 | // ------------------------------------------------------------------------------------------ | 688 | // ------------------------------------------------------------------------------------------ |
714 | /* Data storage for links. | ||
715 | This object behaves like an associative array. | ||
716 | Example: | ||
717 | $mylinks = new linkdb(); | ||
718 | echo $mylinks['20110826_161819']['title']; | ||
719 | foreach($mylinks as $link) | ||
720 | echo $link['title'].' at url '.$link['url'].' ; description:'.$link['description']; | ||
721 | |||
722 | Available keys: | ||
723 | title : Title of the link | ||
724 | url : URL of the link. Can be absolute or relative. Relative URLs are permalinks (e.g.'?m-ukcw') | ||
725 | description : description of the entry | ||
726 | private : Is this link private? 0=no, other value=yes | ||
727 | linkdate : date of the creation of this entry, in the form YYYYMMDD_HHMMSS (e.g.'20110914_192317') | ||
728 | tags : tags attached to this entry (separated by spaces) | ||
729 | |||
730 | We implement 3 interfaces: | ||
731 | - ArrayAccess so that this object behaves like an associative array. | ||
732 | - Iterator so that this object can be used in foreach() loops. | ||
733 | - Countable interface so that we can do a count() on this object. | ||
734 | */ | ||
735 | class linkdb implements Iterator, Countable, ArrayAccess | ||
736 | { | ||
737 | private $links; // List of links (associative array. Key=linkdate (e.g. "20110823_124546"), value= associative array (keys:title,description...) | ||
738 | private $urls; // List of all recorded URLs (key=url, value=linkdate) for fast reserve search (url-->linkdate) | ||
739 | private $keys; // List of linkdate keys (for the Iterator interface implementation) | ||
740 | private $position; // Position in the $this->keys array. (for the Iterator interface implementation.) | ||
741 | private $loggedin; // Is the user logged in? (used to filter private links) | ||
742 | |||
743 | // Constructor: | ||
744 | function __construct($isLoggedIn) | ||
745 | // Input : $isLoggedIn : is the user logged in? | ||
746 | { | ||
747 | $this->loggedin = $isLoggedIn; | ||
748 | $this->checkdb(); // Make sure data file exists. | ||
749 | $this->readdb(); // Then read it. | ||
750 | } | ||
751 | |||
752 | // ---- Countable interface implementation | ||
753 | public function count() { return count($this->links); } | ||
754 | |||
755 | // ---- ArrayAccess interface implementation | ||
756 | public function offsetSet($offset, $value) | ||
757 | { | ||
758 | if (!$this->loggedin) die('You are not authorized to add a link.'); | ||
759 | if (empty($value['linkdate']) || empty($value['url'])) die('Internal Error: A link should always have a linkdate and URL.'); | ||
760 | if (empty($offset)) die('You must specify a key.'); | ||
761 | $this->links[$offset] = $value; | ||
762 | $this->urls[$value['url']]=$offset; | ||
763 | } | ||
764 | public function offsetExists($offset) { return array_key_exists($offset,$this->links); } | ||
765 | public function offsetUnset($offset) | ||
766 | { | ||
767 | if (!$this->loggedin) die('You are not authorized to delete a link.'); | ||
768 | $url = $this->links[$offset]['url']; unset($this->urls[$url]); | ||
769 | unset($this->links[$offset]); | ||
770 | } | ||
771 | public function offsetGet($offset) { return isset($this->links[$offset]) ? $this->links[$offset] : null; } | ||
772 | |||
773 | // ---- Iterator interface implementation | ||
774 | function rewind() { $this->keys=array_keys($this->links); rsort($this->keys); $this->position=0; } // Start over for iteration, ordered by date (latest first). | ||
775 | function key() { return $this->keys[$this->position]; } // current key | ||
776 | function current() { return $this->links[$this->keys[$this->position]]; } // current value | ||
777 | function next() { ++$this->position; } // go to next item | ||
778 | function valid() { return isset($this->keys[$this->position]); } // Check if current position is valid. | ||
779 | |||
780 | // ---- Misc methods | ||
781 | private function checkdb() // Check if db directory and file exists. | ||
782 | { | ||
783 | if (!file_exists($GLOBALS['config']['DATASTORE'])) // Create a dummy database for example. | ||
784 | { | ||
785 | $this->links = array(); | ||
786 | $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'); | ||
787 | $this->links[$link['linkdate']] = $link; | ||
788 | $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'); | ||
789 | $this->links[$link['linkdate']] = $link; | ||
790 | file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX); // Write database to disk | ||
791 | } | ||
792 | } | ||
793 | |||
794 | // Read database from disk to memory | ||
795 | private function readdb() | ||
796 | { | ||
797 | // Read data | ||
798 | $this->links=(file_exists($GLOBALS['config']['DATASTORE']) ? unserialize(gzinflate(base64_decode(substr(file_get_contents($GLOBALS['config']['DATASTORE']),strlen(PHPPREFIX),-strlen(PHPSUFFIX))))) : array() ); | ||
799 | // Note that gzinflate is faster than gzuncompress. See: http://www.php.net/manual/en/function.gzdeflate.php#96439 | ||
800 | |||
801 | // If user is not logged in, filter private links. | ||
802 | if (!$this->loggedin) | ||
803 | { | ||
804 | $toremove=array(); | ||
805 | foreach($this->links as $link) { if ($link['private']!=0) $toremove[]=$link['linkdate']; } | ||
806 | foreach($toremove as $linkdate) { unset($this->links[$linkdate]); } | ||
807 | } | ||
808 | |||
809 | // Keep the list of the mapping URLs-->linkdate up-to-date. | ||
810 | $this->urls=array(); | ||
811 | foreach($this->links as $link) { $this->urls[$link['url']]=$link['linkdate']; } | ||
812 | } | ||
813 | |||
814 | // Save database from memory to disk. | ||
815 | public function savedb() | ||
816 | { | ||
817 | if (!$this->loggedin) die('You are not authorized to change the database.'); | ||
818 | file_put_contents($GLOBALS['config']['DATASTORE'], PHPPREFIX.base64_encode(gzdeflate(serialize($this->links))).PHPSUFFIX); | ||
819 | invalidateCaches(); | ||
820 | } | ||
821 | |||
822 | // Returns the link for a given URL (if it exists). False if it does not exist. | ||
823 | public function getLinkFromUrl($url) | ||
824 | { | ||
825 | if (isset($this->urls[$url])) return $this->links[$this->urls[$url]]; | ||
826 | return false; | ||
827 | } | ||
828 | |||
829 | // Case insensitive search among links (in the URLs, title and description). Returns filtered list of links. | ||
830 | // e.g. print_r($mydb->filterFulltext('hollandais')); | ||
831 | public function filterFulltext($searchterms) | ||
832 | { | ||
833 | // FIXME: explode(' ',$searchterms) and perform a AND search. | ||
834 | // FIXME: accept double-quotes to search for a string "as is"? | ||
835 | // Using mb_convert_case($val, MB_CASE_LOWER, 'UTF-8') allows us to perform searches on | ||
836 | // Unicode text. See https://github.com/shaarli/Shaarli/issues/75 for examples. | ||
837 | $filtered=array(); | ||
838 | $s = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8'); | ||
839 | foreach($this->links as $l) | ||
840 | { | ||
841 | $found= (strpos(mb_convert_case($l['title'], MB_CASE_LOWER, 'UTF-8'),$s) !== false) | ||
842 | || (strpos(mb_convert_case($l['description'], MB_CASE_LOWER, 'UTF-8'),$s) !== false) | ||
843 | || (strpos(mb_convert_case($l['url'], MB_CASE_LOWER, 'UTF-8'),$s) !== false) | ||
844 | || (strpos(mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'),$s) !== false); | ||
845 | if ($found) $filtered[$l['linkdate']] = $l; | ||
846 | } | ||
847 | krsort($filtered); | ||
848 | return $filtered; | ||
849 | } | ||
850 | |||
851 | // Filter by tag. | ||
852 | // You can specify one or more tags (tags can be separated by space or comma). | ||
853 | // e.g. print_r($mydb->filterTags('linux programming')); | ||
854 | public function filterTags($tags,$casesensitive=false) | ||
855 | { | ||
856 | // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) | ||
857 | // TODO: is $casesensitive ever true ? | ||
858 | $t = str_replace(',',' ',($casesensitive?$tags:mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'))); | ||
859 | $searchtags=explode(' ',$t); | ||
860 | $filtered=array(); | ||
861 | foreach($this->links as $l) | ||
862 | { | ||
863 | $linktags = explode(' ',($casesensitive?$l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'))); | ||
864 | if (count(array_intersect($linktags,$searchtags)) == count($searchtags)) | ||
865 | $filtered[$l['linkdate']] = $l; | ||
866 | } | ||
867 | krsort($filtered); | ||
868 | return $filtered; | ||
869 | } | ||
870 | |||
871 | // Filter by day. Day must be in the form 'YYYYMMDD' (e.g. '20120125') | ||
872 | // Sort order is: older articles first. | ||
873 | // e.g. print_r($mydb->filterDay('20120125')); | ||
874 | public function filterDay($day) | ||
875 | { | ||
876 | $filtered=array(); | ||
877 | foreach($this->links as $l) | ||
878 | { | ||
879 | if (startsWith($l['linkdate'],$day)) $filtered[$l['linkdate']] = $l; | ||
880 | } | ||
881 | ksort($filtered); | ||
882 | return $filtered; | ||
883 | } | ||
884 | // Filter by smallHash. | ||
885 | // Only 1 article is returned. | ||
886 | public function filterSmallHash($smallHash) | ||
887 | { | ||
888 | $filtered=array(); | ||
889 | foreach($this->links as $l) | ||
890 | { | ||
891 | if ($smallHash==smallHash($l['linkdate'])) // Yes, this is ugly and slow | ||
892 | { | ||
893 | $filtered[$l['linkdate']] = $l; | ||
894 | return $filtered; | ||
895 | } | ||
896 | } | ||
897 | return $filtered; | ||
898 | } | ||
899 | |||
900 | // Returns the list of all tags | ||
901 | // Output: associative array key=tags, value=0 | ||
902 | public function allTags() | ||
903 | { | ||
904 | $tags=array(); | ||
905 | foreach($this->links as $link) | ||
906 | foreach(explode(' ',$link['tags']) as $tag) | ||
907 | if (!empty($tag)) $tags[$tag]=(empty($tags[$tag]) ? 1 : $tags[$tag]+1); | ||
908 | arsort($tags); // Sort tags by usage (most used tag first) | ||
909 | return $tags; | ||
910 | } | ||
911 | |||
912 | // Returns the list of days containing articles (oldest first) | ||
913 | // Output: An array containing days (in format YYYYMMDD). | ||
914 | public function days() | ||
915 | { | ||
916 | $linkdays=array(); | ||
917 | foreach(array_keys($this->links) as $day) | ||
918 | { | ||
919 | $linkdays[substr($day,0,8)]=0; | ||
920 | } | ||
921 | $linkdays=array_keys($linkdays); | ||
922 | sort($linkdays); | ||
923 | return $linkdays; | ||
924 | } | ||
925 | } | ||
926 | |||
927 | // ------------------------------------------------------------------------------------------ | ||
928 | // Output the last N links in RSS 2.0 format. | 689 | // Output the last N links in RSS 2.0 format. |
929 | function showRSS() | 690 | function showRSS() |
930 | { | 691 | { |
@@ -941,16 +702,13 @@ function showRSS() | |||
941 | $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } | 702 | $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } |
942 | 703 | ||
943 | // If cached was not found (or not usable), then read the database and build the response: | 704 | // If cached was not found (or not usable), then read the database and build the response: |
944 | $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if user it not logged in). | 705 | $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if user it not logged in). |
945 | 706 | ||
946 | // Optionally filter the results: | 707 | // Optionally filter the results: |
947 | $linksToDisplay=array(); | 708 | $linksToDisplay=array(); |
948 | if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); | 709 | if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); |
949 | else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); | 710 | else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); |
950 | else $linksToDisplay = $LINKSDB; | 711 | else $linksToDisplay = $LINKSDB; |
951 | |||
952 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | ||
953 | $linksToDisplay = array(); | ||
954 | 712 | ||
955 | $nblinksToDisplay = 50; // Number of links to display. | 713 | $nblinksToDisplay = 50; // Number of links to display. |
956 | if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. | 714 | if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. |
@@ -1019,7 +777,7 @@ function showATOM() | |||
1019 | $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } | 777 | $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } |
1020 | // If cached was not found (or not usable), then read the database and build the response: | 778 | // If cached was not found (or not usable), then read the database and build the response: |
1021 | 779 | ||
1022 | $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). | 780 | $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). |
1023 | 781 | ||
1024 | 782 | ||
1025 | // Optionally filter the results: | 783 | // Optionally filter the results: |
@@ -1027,9 +785,6 @@ function showATOM() | |||
1027 | if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); | 785 | if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); |
1028 | else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); | 786 | else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); |
1029 | else $linksToDisplay = $LINKSDB; | 787 | else $linksToDisplay = $LINKSDB; |
1030 | |||
1031 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | ||
1032 | $linksToDisplay = array(); | ||
1033 | 788 | ||
1034 | $nblinksToDisplay = 50; // Number of links to display. | 789 | $nblinksToDisplay = 50; // Number of links to display. |
1035 | if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. | 790 | if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. |
@@ -1104,7 +859,7 @@ function showDailyRSS() | |||
1104 | $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn()); | 859 | $cache = new pageCache(pageUrl(),startsWith($query,'do=dailyrss') && !isLoggedIn()); |
1105 | $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } | 860 | $cached = $cache->cachedVersion(); if (!empty($cached)) { echo $cached; exit; } |
1106 | // If cached was not found (or not usable), then read the database and build the response: | 861 | // If cached was not found (or not usable), then read the database and build the response: |
1107 | $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). | 862 | $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). |
1108 | 863 | ||
1109 | /* Some Shaarlies may have very few links, so we need to look | 864 | /* Some Shaarlies may have very few links, so we need to look |
1110 | back in time (rsort()) until we have enough days ($nb_of_days). | 865 | back in time (rsort()) until we have enough days ($nb_of_days). |
@@ -1172,7 +927,7 @@ function showDailyRSS() | |||
1172 | // "Daily" page. | 927 | // "Daily" page. |
1173 | function showDaily() | 928 | function showDaily() |
1174 | { | 929 | { |
1175 | $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). | 930 | $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). |
1176 | 931 | ||
1177 | 932 | ||
1178 | $day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD. | 933 | $day=Date('Ymd',strtotime('-1 day')); // Yesterday, in format YYYYMMDD. |
@@ -1190,8 +945,6 @@ function showDaily() | |||
1190 | } | 945 | } |
1191 | 946 | ||
1192 | $linksToDisplay=$LINKSDB->filterDay($day); | 947 | $linksToDisplay=$LINKSDB->filterDay($day); |
1193 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | ||
1194 | $linksToDisplay = array(); | ||
1195 | // We pre-format some fields for proper output. | 948 | // We pre-format some fields for proper output. |
1196 | foreach($linksToDisplay as $key=>$link) | 949 | foreach($linksToDisplay as $key=>$link) |
1197 | { | 950 | { |
@@ -1240,7 +993,7 @@ function showDaily() | |||
1240 | // Render HTML page (according to URL parameters and user rights) | 993 | // Render HTML page (according to URL parameters and user rights) |
1241 | function renderPage() | 994 | function renderPage() |
1242 | { | 995 | { |
1243 | $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). | 996 | $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). |
1244 | 997 | ||
1245 | // -------- Display login form. | 998 | // -------- Display login form. |
1246 | if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login')) | 999 | if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=login')) |
@@ -1270,9 +1023,6 @@ function renderPage() | |||
1270 | if (!empty($_GET['searchterm'])) $links = $LINKSDB->filterFulltext($_GET['searchterm']); | 1023 | if (!empty($_GET['searchterm'])) $links = $LINKSDB->filterFulltext($_GET['searchterm']); |
1271 | elseif (!empty($_GET['searchtags'])) $links = $LINKSDB->filterTags(trim($_GET['searchtags'])); | 1024 | elseif (!empty($_GET['searchtags'])) $links = $LINKSDB->filterTags(trim($_GET['searchtags'])); |
1272 | else $links = $LINKSDB; | 1025 | else $links = $LINKSDB; |
1273 | |||
1274 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | ||
1275 | $links = array(); | ||
1276 | 1026 | ||
1277 | $body=''; | 1027 | $body=''; |
1278 | $linksToDisplay=array(); | 1028 | $linksToDisplay=array(); |
@@ -1300,8 +1050,7 @@ function renderPage() | |||
1300 | if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=tagcloud')) | 1050 | if (isset($_SERVER["QUERY_STRING"]) && startswith($_SERVER["QUERY_STRING"],'do=tagcloud')) |
1301 | { | 1051 | { |
1302 | $tags= $LINKSDB->allTags(); | 1052 | $tags= $LINKSDB->allTags(); |
1303 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | 1053 | |
1304 | $tags = array(); | ||
1305 | // We sort tags alphabetically, then choose a font size according to count. | 1054 | // We sort tags alphabetically, then choose a font size according to count. |
1306 | // First, find max value. | 1055 | // First, find max value. |
1307 | $maxcount=0; foreach($tags as $key=>$value) $maxcount=max($maxcount,$value); | 1056 | $maxcount=0; foreach($tags as $key=>$value) $maxcount=max($maxcount,$value); |
@@ -1822,7 +1571,7 @@ HTML; | |||
1822 | function importFile() | 1571 | function importFile() |
1823 | { | 1572 | { |
1824 | if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); } | 1573 | if (!(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI'])) { die('Not allowed.'); } |
1825 | $LINKSDB=new linkdb(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). | 1574 | $LINKSDB = new LinkDB(isLoggedIn() || $GLOBALS['config']['OPEN_SHAARLI']); // Read links from database (and filter private links if used it not logged in). |
1826 | $filename=$_FILES['filetoupload']['name']; | 1575 | $filename=$_FILES['filetoupload']['name']; |
1827 | $filesize=$_FILES['filetoupload']['size']; | 1576 | $filesize=$_FILES['filetoupload']['size']; |
1828 | $data=file_get_contents($_FILES['filetoupload']['tmp_name']); | 1577 | $data=file_get_contents($_FILES['filetoupload']['tmp_name']); |
@@ -1914,16 +1663,12 @@ function buildLinkList($PAGE,$LINKSDB) | |||
1914 | if (isset($_GET['searchterm'])) // Fulltext search | 1663 | if (isset($_GET['searchterm'])) // Fulltext search |
1915 | { | 1664 | { |
1916 | $linksToDisplay = $LINKSDB->filterFulltext(trim($_GET['searchterm'])); | 1665 | $linksToDisplay = $LINKSDB->filterFulltext(trim($_GET['searchterm'])); |
1917 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | ||
1918 | $linksToDisplay = array(); | ||
1919 | $search_crits=htmlspecialchars(trim($_GET['searchterm'])); | 1666 | $search_crits=htmlspecialchars(trim($_GET['searchterm'])); |
1920 | $search_type='fulltext'; | 1667 | $search_type='fulltext'; |
1921 | } | 1668 | } |
1922 | elseif (isset($_GET['searchtags'])) // Search by tag | 1669 | elseif (isset($_GET['searchtags'])) // Search by tag |
1923 | { | 1670 | { |
1924 | $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); | 1671 | $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); |
1925 | if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | ||
1926 | $linksToDisplay = array(); | ||
1927 | $search_crits=explode(' ',trim($_GET['searchtags'])); | 1672 | $search_crits=explode(' ',trim($_GET['searchtags'])); |
1928 | $search_type='tags'; | 1673 | $search_type='tags'; |
1929 | } | 1674 | } |
@@ -1939,9 +1684,6 @@ function buildLinkList($PAGE,$LINKSDB) | |||
1939 | } | 1684 | } |
1940 | $search_type='permalink'; | 1685 | $search_type='permalink'; |
1941 | } | 1686 | } |
1942 | // We chose to disable all private links and the user isn't logged in, do not return any link. | ||
1943 | else if ($GLOBALS['config']['HIDE_PUBLIC_LINKS'] && !isLoggedIn()) | ||
1944 | $linksToDisplay = array(); | ||
1945 | else | 1687 | else |
1946 | $linksToDisplay = $LINKSDB; // Otherwise, display without filtering. | 1688 | $linksToDisplay = $LINKSDB; // Otherwise, display without filtering. |
1947 | 1689 | ||
@@ -2018,6 +1760,7 @@ function buildLinkList($PAGE,$LINKSDB) | |||
2018 | $PAGE->assign('redirector',empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector']); // Optional redirector URL. | 1760 | $PAGE->assign('redirector',empty($GLOBALS['redirector']) ? '' : $GLOBALS['redirector']); // Optional redirector URL. |
2019 | $PAGE->assign('token',$token); | 1761 | $PAGE->assign('token',$token); |
2020 | $PAGE->assign('links',$linkDisp); | 1762 | $PAGE->assign('links',$linkDisp); |
1763 | $PAGE->assign('tags', $LINKSDB->allTags()); | ||
2021 | return; | 1764 | return; |
2022 | } | 1765 | } |
2023 | 1766 | ||
diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..d6e01c35 --- /dev/null +++ b/phpunit.xml | |||
@@ -0,0 +1,15 @@ | |||
1 | <?xml version="1.0" encoding="UTF-8"?> | ||
2 | <phpunit | ||
3 | xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | ||
4 | xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.5/phpunit.xsd" | ||
5 | colors="true"> | ||
6 | <filter> | ||
7 | <whitelist addUncoveredFilesFromWhitelist="true"> | ||
8 | <directory suffix=".php">application</directory> | ||
9 | </whitelist> | ||
10 | </filter> | ||
11 | <logging> | ||
12 | <log type="coverage-html" target="coverage" lowUpperBound="30" highLowerBound="80"/> | ||
13 | <log type="coverage-text" target="php://stdout" showUncoveredFiles="true"/> | ||
14 | </logging> | ||
15 | </phpunit> | ||
diff --git a/tests/.htaccess b/tests/.htaccess new file mode 100644 index 00000000..b584d98c --- /dev/null +++ b/tests/.htaccess | |||
@@ -0,0 +1,2 @@ | |||
1 | Allow from none | ||
2 | Deny from all | ||
diff --git a/tests/LinkDBTest.php b/tests/LinkDBTest.php new file mode 100644 index 00000000..bbe4e026 --- /dev/null +++ b/tests/LinkDBTest.php | |||
@@ -0,0 +1,509 @@ | |||
1 | <?php | ||
2 | /** | ||
3 | * Link datastore tests | ||
4 | */ | ||
5 | |||
6 | require_once 'application/LinkDB.php'; | ||
7 | require_once 'application/Utils.php'; | ||
8 | require_once 'tests/utils/ReferenceLinkDB.php'; | ||
9 | |||
10 | define('PHPPREFIX', '<?php /* '); | ||
11 | define('PHPSUFFIX', ' */ ?>'); | ||
12 | |||
13 | |||
14 | /** | ||
15 | * Unitary tests for LinkDB | ||
16 | */ | ||
17 | class LinkDBTest extends PHPUnit_Framework_TestCase | ||
18 | { | ||
19 | // datastore to test write operations | ||
20 | protected static $testDatastore = 'tests/datastore.php'; | ||
21 | protected static $dummyDatastoreSHA1 = 'e3edea8ea7bb50be4bcb404df53fbb4546a7156e'; | ||
22 | protected static $refDB = null; | ||
23 | protected static $publicLinkDB = null; | ||
24 | protected static $privateLinkDB = null; | ||
25 | |||
26 | /** | ||
27 | * Instantiates public and private LinkDBs with test data | ||
28 | * | ||
29 | * The reference datastore contains public and private links that | ||
30 | * will be used to test LinkDB's methods: | ||
31 | * - access filtering (public/private), | ||
32 | * - link searches: | ||
33 | * - by day, | ||
34 | * - by tag, | ||
35 | * - by text, | ||
36 | * - etc. | ||
37 | */ | ||
38 | public static function setUpBeforeClass() | ||
39 | { | ||
40 | self::$refDB = new ReferenceLinkDB(); | ||
41 | self::$refDB->write(self::$testDatastore, PHPPREFIX, PHPSUFFIX); | ||
42 | |||
43 | $GLOBALS['config']['DATASTORE'] = self::$testDatastore; | ||
44 | self::$publicLinkDB = new LinkDB(false); | ||
45 | self::$privateLinkDB = new LinkDB(true); | ||
46 | } | ||
47 | |||
48 | /** | ||
49 | * Resets test data for each test | ||
50 | */ | ||
51 | protected function setUp() | ||
52 | { | ||
53 | $GLOBALS['config']['DATASTORE'] = self::$testDatastore; | ||
54 | if (file_exists(self::$testDatastore)) { | ||
55 | unlink(self::$testDatastore); | ||
56 | } | ||
57 | } | ||
58 | |||
59 | /** | ||
60 | * Allows to test LinkDB's private methods | ||
61 | * | ||
62 | * @see | ||
63 | * https://sebastian-bergmann.de/archives/881-Testing-Your-Privates.html | ||
64 | * http://stackoverflow.com/a/2798203 | ||
65 | */ | ||
66 | protected static function getMethod($name) | ||
67 | { | ||
68 | $class = new ReflectionClass('LinkDB'); | ||
69 | $method = $class->getMethod($name); | ||
70 | $method->setAccessible(true); | ||
71 | return $method; | ||
72 | } | ||
73 | |||
74 | /** | ||
75 | * Instantiate LinkDB objects - logged in user | ||
76 | */ | ||
77 | public function testConstructLoggedIn() | ||
78 | { | ||
79 | new LinkDB(true); | ||
80 | $this->assertFileExists(self::$testDatastore); | ||
81 | } | ||
82 | |||
83 | /** | ||
84 | * Instantiate LinkDB objects - logged out or public instance | ||
85 | */ | ||
86 | public function testConstructLoggedOut() | ||
87 | { | ||
88 | new LinkDB(false); | ||
89 | $this->assertFileExists(self::$testDatastore); | ||
90 | } | ||
91 | |||
92 | /** | ||
93 | * Attempt to instantiate a LinkDB whereas the datastore is not writable | ||
94 | * | ||
95 | * @expectedException PHPUnit_Framework_Error_Warning | ||
96 | * @expectedExceptionMessageRegExp /failed to open stream: No such file or directory/ | ||
97 | */ | ||
98 | public function testConstructDatastoreNotWriteable() | ||
99 | { | ||
100 | $GLOBALS['config']['DATASTORE'] = 'null/store.db'; | ||
101 | new LinkDB(false); | ||
102 | } | ||
103 | |||
104 | /** | ||
105 | * The DB doesn't exist, ensure it is created with dummy content | ||
106 | */ | ||
107 | public function testCheckDBNew() | ||
108 | { | ||
109 | $linkDB = new LinkDB(false); | ||
110 | unlink(self::$testDatastore); | ||
111 | $this->assertFileNotExists(self::$testDatastore); | ||
112 | |||
113 | $checkDB = self::getMethod('checkDB'); | ||
114 | $checkDB->invokeArgs($linkDB, array()); | ||
115 | $this->assertFileExists(self::$testDatastore); | ||
116 | |||
117 | // ensure the correct data has been written | ||
118 | $this->assertEquals( | ||
119 | self::$dummyDatastoreSHA1, | ||
120 | sha1_file(self::$testDatastore) | ||
121 | ); | ||
122 | } | ||
123 | |||
124 | /** | ||
125 | * The DB exists, don't do anything | ||
126 | */ | ||
127 | public function testCheckDBLoad() | ||
128 | { | ||
129 | $linkDB = new LinkDB(false); | ||
130 | $this->assertEquals( | ||
131 | self::$dummyDatastoreSHA1, | ||
132 | sha1_file(self::$testDatastore) | ||
133 | ); | ||
134 | |||
135 | $checkDB = self::getMethod('checkDB'); | ||
136 | $checkDB->invokeArgs($linkDB, array()); | ||
137 | |||
138 | // ensure the datastore is left unmodified | ||
139 | $this->assertEquals( | ||
140 | self::$dummyDatastoreSHA1, | ||
141 | sha1_file(self::$testDatastore) | ||
142 | ); | ||
143 | } | ||
144 | |||
145 | /** | ||
146 | * Load an empty DB | ||
147 | */ | ||
148 | public function testReadEmptyDB() | ||
149 | { | ||
150 | file_put_contents(self::$testDatastore, PHPPREFIX.'S7QysKquBQA='.PHPSUFFIX); | ||
151 | $emptyDB = new LinkDB(false); | ||
152 | $this->assertEquals(0, sizeof($emptyDB)); | ||
153 | $this->assertEquals(0, count($emptyDB)); | ||
154 | } | ||
155 | |||
156 | /** | ||
157 | * Load public links from the DB | ||
158 | */ | ||
159 | public function testReadPublicDB() | ||
160 | { | ||
161 | $this->assertEquals( | ||
162 | self::$refDB->countPublicLinks(), | ||
163 | sizeof(self::$publicLinkDB) | ||
164 | ); | ||
165 | } | ||
166 | |||
167 | /** | ||
168 | * Load public and private links from the DB | ||
169 | */ | ||
170 | public function testReadPrivateDB() | ||
171 | { | ||
172 | $this->assertEquals( | ||
173 | self::$refDB->countLinks(), | ||
174 | sizeof(self::$privateLinkDB) | ||
175 | ); | ||
176 | } | ||
177 | |||
178 | /** | ||
179 | * Save the links to the DB | ||
180 | */ | ||
181 | public function testSaveDB() | ||
182 | { | ||
183 | $testDB = new LinkDB(true); | ||
184 | $dbSize = sizeof($testDB); | ||
185 | |||
186 | $link = array( | ||
187 | 'title'=>'an additional link', | ||
188 | 'url'=>'http://dum.my', | ||
189 | 'description'=>'One more', | ||
190 | 'private'=>0, | ||
191 | 'linkdate'=>'20150518_190000', | ||
192 | 'tags'=>'unit test' | ||
193 | ); | ||
194 | $testDB[$link['linkdate']] = $link; | ||
195 | |||
196 | // TODO: move PageCache to a proper class/file | ||
197 | function invalidateCaches() {} | ||
198 | |||
199 | $testDB->savedb(); | ||
200 | |||
201 | $testDB = new LinkDB(true); | ||
202 | $this->assertEquals($dbSize + 1, sizeof($testDB)); | ||
203 | } | ||
204 | |||
205 | /** | ||
206 | * Count existing links | ||
207 | */ | ||
208 | public function testCount() | ||
209 | { | ||
210 | $this->assertEquals( | ||
211 | self::$refDB->countPublicLinks(), | ||
212 | self::$publicLinkDB->count() | ||
213 | ); | ||
214 | $this->assertEquals( | ||
215 | self::$refDB->countLinks(), | ||
216 | self::$privateLinkDB->count() | ||
217 | ); | ||
218 | } | ||
219 | |||
220 | /** | ||
221 | * List the days for which links have been posted | ||
222 | */ | ||
223 | public function testDays() | ||
224 | { | ||
225 | $this->assertEquals( | ||
226 | ['20121206', '20130614', '20150310'], | ||
227 | self::$publicLinkDB->days() | ||
228 | ); | ||
229 | |||
230 | $this->assertEquals( | ||
231 | ['20121206', '20130614', '20141125', '20150310'], | ||
232 | self::$privateLinkDB->days() | ||
233 | ); | ||
234 | } | ||
235 | |||
236 | /** | ||
237 | * The URL corresponds to an existing entry in the DB | ||
238 | */ | ||
239 | public function testGetKnownLinkFromURL() | ||
240 | { | ||
241 | $link = self::$publicLinkDB->getLinkFromUrl('http://mediagoblin.org/'); | ||
242 | |||
243 | $this->assertNotEquals(false, $link); | ||
244 | $this->assertEquals( | ||
245 | 'A free software media publishing platform', | ||
246 | $link['description'] | ||
247 | ); | ||
248 | } | ||
249 | |||
250 | /** | ||
251 | * The URL is not in the DB | ||
252 | */ | ||
253 | public function testGetUnknownLinkFromURL() | ||
254 | { | ||
255 | $this->assertEquals( | ||
256 | false, | ||
257 | self::$publicLinkDB->getLinkFromUrl('http://dev.null') | ||
258 | ); | ||
259 | } | ||
260 | |||
261 | /** | ||
262 | * Lists all tags | ||
263 | */ | ||
264 | public function testAllTags() | ||
265 | { | ||
266 | $this->assertEquals( | ||
267 | [ | ||
268 | 'web' => 3, | ||
269 | 'cartoon' => 2, | ||
270 | 'gnu' => 2, | ||
271 | 'dev' => 1, | ||
272 | 'samba' => 1, | ||
273 | 'media' => 1, | ||
274 | 'software' => 1, | ||
275 | 'stallman' => 1, | ||
276 | 'free' => 1 | ||
277 | ], | ||
278 | self::$publicLinkDB->allTags() | ||
279 | ); | ||
280 | |||
281 | $this->assertEquals( | ||
282 | [ | ||
283 | 'web' => 4, | ||
284 | 'cartoon' => 3, | ||
285 | 'gnu' => 2, | ||
286 | 'dev' => 2, | ||
287 | 'samba' => 1, | ||
288 | 'media' => 1, | ||
289 | 'software' => 1, | ||
290 | 'stallman' => 1, | ||
291 | 'free' => 1, | ||
292 | 'html' => 1, | ||
293 | 'w3c' => 1, | ||
294 | 'css' => 1, | ||
295 | 'Mercurial' => 1 | ||
296 | ], | ||
297 | self::$privateLinkDB->allTags() | ||
298 | ); | ||
299 | } | ||
300 | |||
301 | /** | ||
302 | * Filter links using a tag | ||
303 | */ | ||
304 | public function testFilterOneTag() | ||
305 | { | ||
306 | $this->assertEquals( | ||
307 | 3, | ||
308 | sizeof(self::$publicLinkDB->filterTags('web', false)) | ||
309 | ); | ||
310 | |||
311 | $this->assertEquals( | ||
312 | 4, | ||
313 | sizeof(self::$privateLinkDB->filterTags('web', false)) | ||
314 | ); | ||
315 | } | ||
316 | |||
317 | /** | ||
318 | * Filter links using a tag - case-sensitive | ||
319 | */ | ||
320 | public function testFilterCaseSensitiveTag() | ||
321 | { | ||
322 | $this->assertEquals( | ||
323 | 0, | ||
324 | sizeof(self::$privateLinkDB->filterTags('mercurial', true)) | ||
325 | ); | ||
326 | |||
327 | $this->assertEquals( | ||
328 | 1, | ||
329 | sizeof(self::$privateLinkDB->filterTags('Mercurial', true)) | ||
330 | ); | ||
331 | } | ||
332 | |||
333 | /** | ||
334 | * Filter links using a tag combination | ||
335 | */ | ||
336 | public function testFilterMultipleTags() | ||
337 | { | ||
338 | $this->assertEquals( | ||
339 | 1, | ||
340 | sizeof(self::$publicLinkDB->filterTags('dev cartoon', false)) | ||
341 | ); | ||
342 | |||
343 | $this->assertEquals( | ||
344 | 2, | ||
345 | sizeof(self::$privateLinkDB->filterTags('dev cartoon', false)) | ||
346 | ); | ||
347 | } | ||
348 | |||
349 | /** | ||
350 | * Filter links using a non-existent tag | ||
351 | */ | ||
352 | public function testFilterUnknownTag() | ||
353 | { | ||
354 | $this->assertEquals( | ||
355 | 0, | ||
356 | sizeof(self::$publicLinkDB->filterTags('null', false)) | ||
357 | ); | ||
358 | } | ||
359 | |||
360 | /** | ||
361 | * Return links for a given day | ||
362 | */ | ||
363 | public function testFilterDay() | ||
364 | { | ||
365 | $this->assertEquals( | ||
366 | 2, | ||
367 | sizeof(self::$publicLinkDB->filterDay('20121206')) | ||
368 | ); | ||
369 | |||
370 | $this->assertEquals( | ||
371 | 3, | ||
372 | sizeof(self::$privateLinkDB->filterDay('20121206')) | ||
373 | ); | ||
374 | } | ||
375 | |||
376 | /** | ||
377 | * 404 - day not found | ||
378 | */ | ||
379 | public function testFilterUnknownDay() | ||
380 | { | ||
381 | $this->assertEquals( | ||
382 | 0, | ||
383 | sizeof(self::$publicLinkDB->filterDay('19700101')) | ||
384 | ); | ||
385 | |||
386 | $this->assertEquals( | ||
387 | 0, | ||
388 | sizeof(self::$privateLinkDB->filterDay('19700101')) | ||
389 | ); | ||
390 | } | ||
391 | |||
392 | /** | ||
393 | * Use an invalid date format | ||
394 | */ | ||
395 | public function testFilterInvalidDay() | ||
396 | { | ||
397 | $this->assertEquals( | ||
398 | 0, | ||
399 | sizeof(self::$privateLinkDB->filterDay('Rainy day, dream away')) | ||
400 | ); | ||
401 | |||
402 | // TODO: check input format | ||
403 | $this->assertEquals( | ||
404 | 6, | ||
405 | sizeof(self::$privateLinkDB->filterDay('20')) | ||
406 | ); | ||
407 | } | ||
408 | |||
409 | /** | ||
410 | * Retrieve a link entry with its hash | ||
411 | */ | ||
412 | public function testFilterSmallHash() | ||
413 | { | ||
414 | $links = self::$privateLinkDB->filterSmallHash('IuWvgA'); | ||
415 | |||
416 | $this->assertEquals( | ||
417 | 1, | ||
418 | sizeof($links) | ||
419 | ); | ||
420 | |||
421 | $this->assertEquals( | ||
422 | 'MediaGoblin', | ||
423 | $links['20130614_184135']['title'] | ||
424 | ); | ||
425 | |||
426 | } | ||
427 | |||
428 | /** | ||
429 | * No link for this hash | ||
430 | */ | ||
431 | public function testFilterUnknownSmallHash() | ||
432 | { | ||
433 | $this->assertEquals( | ||
434 | 0, | ||
435 | sizeof(self::$privateLinkDB->filterSmallHash('Iblaah')) | ||
436 | ); | ||
437 | } | ||
438 | |||
439 | /** | ||
440 | * Full-text search - result from a link's URL | ||
441 | */ | ||
442 | public function testFilterFullTextURL() | ||
443 | { | ||
444 | $this->assertEquals( | ||
445 | 2, | ||
446 | sizeof(self::$publicLinkDB->filterFullText('ars.userfriendly.org')) | ||
447 | ); | ||
448 | } | ||
449 | |||
450 | /** | ||
451 | * Full-text search - result from a link's title only | ||
452 | */ | ||
453 | public function testFilterFullTextTitle() | ||
454 | { | ||
455 | // use miscellaneous cases | ||
456 | $this->assertEquals( | ||
457 | 2, | ||
458 | sizeof(self::$publicLinkDB->filterFullText('userfriendly -')) | ||
459 | ); | ||
460 | $this->assertEquals( | ||
461 | 2, | ||
462 | sizeof(self::$publicLinkDB->filterFullText('UserFriendly -')) | ||
463 | ); | ||
464 | $this->assertEquals( | ||
465 | 2, | ||
466 | sizeof(self::$publicLinkDB->filterFullText('uSeRFrIendlY -')) | ||
467 | ); | ||
468 | |||
469 | // use miscellaneous case and offset | ||
470 | $this->assertEquals( | ||
471 | 2, | ||
472 | sizeof(self::$publicLinkDB->filterFullText('RFrIendL')) | ||
473 | ); | ||
474 | } | ||
475 | |||
476 | /** | ||
477 | * Full-text search - result from the link's description only | ||
478 | */ | ||
479 | public function testFilterFullTextDescription() | ||
480 | { | ||
481 | $this->assertEquals( | ||
482 | 1, | ||
483 | sizeof(self::$publicLinkDB->filterFullText('media publishing')) | ||
484 | ); | ||
485 | } | ||
486 | |||
487 | /** | ||
488 | * Full-text search - result from the link's tags only | ||
489 | */ | ||
490 | public function testFilterFullTextTags() | ||
491 | { | ||
492 | $this->assertEquals( | ||
493 | 2, | ||
494 | sizeof(self::$publicLinkDB->filterFullText('gnu')) | ||
495 | ); | ||
496 | } | ||
497 | |||
498 | /** | ||
499 | * Full-text search - result set from mixed sources | ||
500 | */ | ||
501 | public function testFilterFullTextMixed() | ||
502 | { | ||
503 | $this->assertEquals( | ||
504 | 2, | ||
505 | sizeof(self::$publicLinkDB->filterFullText('free software')) | ||
506 | ); | ||
507 | } | ||
508 | } | ||
509 | ?> | ||
diff --git a/tests/UtilsTest.php b/tests/UtilsTest.php new file mode 100644 index 00000000..bbba99f2 --- /dev/null +++ b/tests/UtilsTest.php | |||
@@ -0,0 +1,78 @@ | |||
1 | <?php | ||
2 | /** | ||
3 | * Utilities' tests | ||
4 | */ | ||
5 | |||
6 | require_once 'application/Utils.php'; | ||
7 | |||
8 | /** | ||
9 | * Unitary tests for Shaarli utilities | ||
10 | */ | ||
11 | class UtilsTest extends PHPUnit_Framework_TestCase | ||
12 | { | ||
13 | /** | ||
14 | * Represent a link by its hash | ||
15 | */ | ||
16 | public function testSmallHash() | ||
17 | { | ||
18 | $this->assertEquals('CyAAJw', smallHash('http://test.io')); | ||
19 | $this->assertEquals(6, strlen(smallHash('https://github.com'))); | ||
20 | } | ||
21 | |||
22 | /** | ||
23 | * Look for a substring at the beginning of a string | ||
24 | */ | ||
25 | public function testStartsWithCaseInsensitive() | ||
26 | { | ||
27 | $this->assertTrue(startsWith('Lorem ipsum', 'lorem', false)); | ||
28 | $this->assertTrue(startsWith('Lorem ipsum', 'LoReM i', false)); | ||
29 | } | ||
30 | |||
31 | /** | ||
32 | * Look for a substring at the beginning of a string (case-sensitive) | ||
33 | */ | ||
34 | public function testStartsWithCaseSensitive() | ||
35 | { | ||
36 | $this->assertTrue(startsWith('Lorem ipsum', 'Lorem', true)); | ||
37 | $this->assertFalse(startsWith('Lorem ipsum', 'lorem', true)); | ||
38 | $this->assertFalse(startsWith('Lorem ipsum', 'LoReM i', true)); | ||
39 | } | ||
40 | |||
41 | /** | ||
42 | * Look for a substring at the beginning of a string (Unicode) | ||
43 | */ | ||
44 | public function testStartsWithSpecialChars() | ||
45 | { | ||
46 | $this->assertTrue(startsWith('å!ùµ', 'å!', false)); | ||
47 | $this->assertTrue(startsWith('µ$åù', 'µ$', true)); | ||
48 | } | ||
49 | |||
50 | /** | ||
51 | * Look for a substring at the end of a string | ||
52 | */ | ||
53 | public function testEndsWithCaseInsensitive() | ||
54 | { | ||
55 | $this->assertTrue(endsWith('Lorem ipsum', 'ipsum', false)); | ||
56 | $this->assertTrue(endsWith('Lorem ipsum', 'm IpsUM', false)); | ||
57 | } | ||
58 | |||
59 | /** | ||
60 | * Look for a substring at the end of a string (case-sensitive) | ||
61 | */ | ||
62 | public function testEndsWithCaseSensitive() | ||
63 | { | ||
64 | $this->assertTrue(endsWith('lorem Ipsum', 'Ipsum', true)); | ||
65 | $this->assertFalse(endsWith('lorem Ipsum', 'ipsum', true)); | ||
66 | $this->assertFalse(endsWith('lorem Ipsum', 'M IPsuM', true)); | ||
67 | } | ||
68 | |||
69 | /** | ||
70 | * Look for a substring at the end of a string (Unicode) | ||
71 | */ | ||
72 | public function testEndsWithSpecialChars() | ||
73 | { | ||
74 | $this->assertTrue(endsWith('å!ùµ', 'ùµ', false)); | ||
75 | $this->assertTrue(endsWith('µ$åù', 'åù', true)); | ||
76 | } | ||
77 | } | ||
78 | ?> | ||
diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php new file mode 100644 index 00000000..2cb05bae --- /dev/null +++ b/tests/utils/ReferenceLinkDB.php | |||
@@ -0,0 +1,128 @@ | |||
1 | <?php | ||
2 | /** | ||
3 | * Populates a reference datastore to test LinkDB | ||
4 | */ | ||
5 | class ReferenceLinkDB | ||
6 | { | ||
7 | private $links = array(); | ||
8 | private $publicCount = 0; | ||
9 | private $privateCount = 0; | ||
10 | |||
11 | /** | ||
12 | * Populates the test DB with reference data | ||
13 | */ | ||
14 | function __construct() | ||
15 | { | ||
16 | $this->addLink( | ||
17 | 'Free as in Freedom 2.0', | ||
18 | 'https://static.fsf.org/nosvn/faif-2.0.pdf', | ||
19 | 'Richard Stallman and the Free Software Revolution', | ||
20 | 0, | ||
21 | '20150310_114633', | ||
22 | 'free gnu software stallman' | ||
23 | ); | ||
24 | |||
25 | $this->addLink( | ||
26 | 'MediaGoblin', | ||
27 | 'http://mediagoblin.org/', | ||
28 | 'A free software media publishing platform', | ||
29 | 0, | ||
30 | '20130614_184135', | ||
31 | 'gnu media web' | ||
32 | ); | ||
33 | |||
34 | $this->addLink( | ||
35 | 'w3c-markup-validator', | ||
36 | 'https://dvcs.w3.org/hg/markup-validator/summary', | ||
37 | 'Mercurial repository for the W3C Validator', | ||
38 | 1, | ||
39 | '20141125_084734', | ||
40 | 'css html w3c web Mercurial' | ||
41 | ); | ||
42 | |||
43 | $this->addLink( | ||
44 | 'UserFriendly - Web Designer', | ||
45 | 'http://ars.userfriendly.org/cartoons/?id=20121206', | ||
46 | 'Naming conventions...', | ||
47 | 0, | ||
48 | '20121206_142300', | ||
49 | 'dev cartoon web' | ||
50 | ); | ||
51 | |||
52 | $this->addLink( | ||
53 | 'UserFriendly - Samba', | ||
54 | 'http://ars.userfriendly.org/cartoons/?id=20010306', | ||
55 | 'Tropical printing', | ||
56 | 0, | ||
57 | '20121206_172539', | ||
58 | 'samba cartoon web' | ||
59 | ); | ||
60 | |||
61 | $this->addLink( | ||
62 | 'Geek and Poke', | ||
63 | 'http://geek-and-poke.com/', | ||
64 | '', | ||
65 | 1, | ||
66 | '20121206_182539', | ||
67 | 'dev cartoon' | ||
68 | ); | ||
69 | } | ||
70 | |||
71 | /** | ||
72 | * Adds a new link | ||
73 | */ | ||
74 | protected function addLink($title, $url, $description, $private, $date, $tags) | ||
75 | { | ||
76 | $link = array( | ||
77 | 'title' => $title, | ||
78 | 'url' => $url, | ||
79 | 'description' => $description, | ||
80 | 'private' => $private, | ||
81 | 'linkdate' => $date, | ||
82 | 'tags' => $tags, | ||
83 | ); | ||
84 | $this->links[$date] = $link; | ||
85 | |||
86 | if ($private) { | ||
87 | $this->privateCount++; | ||
88 | return; | ||
89 | } | ||
90 | $this->publicCount++; | ||
91 | } | ||
92 | |||
93 | /** | ||
94 | * Writes data to the datastore | ||
95 | */ | ||
96 | public function write($filename, $prefix, $suffix) | ||
97 | { | ||
98 | file_put_contents( | ||
99 | $filename, | ||
100 | $prefix.base64_encode(gzdeflate(serialize($this->links))).$suffix | ||
101 | ); | ||
102 | } | ||
103 | |||
104 | /** | ||
105 | * Returns the number of links in the reference data | ||
106 | */ | ||
107 | public function countLinks() | ||
108 | { | ||
109 | return $this->publicCount + $this->privateCount; | ||
110 | } | ||
111 | |||
112 | /** | ||
113 | * Returns the number of public links in the reference data | ||
114 | */ | ||
115 | public function countPublicLinks() | ||
116 | { | ||
117 | return $this->publicCount; | ||
118 | } | ||
119 | |||
120 | /** | ||
121 | * Returns the number of private links in the reference data | ||
122 | */ | ||
123 | public function countPrivateLinks() | ||
124 | { | ||
125 | return $this->privateCount; | ||
126 | } | ||
127 | } | ||
128 | ?> | ||
diff --git a/tpl/linklist.html b/tpl/linklist.html index 766a80ce..47e67e71 100644 --- a/tpl/linklist.html +++ b/tpl/linklist.html | |||
@@ -1,12 +1,21 @@ | |||
1 | <!DOCTYPE html> | 1 | <!DOCTYPE html> |
2 | <html> | 2 | <html> |
3 | <head>{include="includes"}</head> | 3 | <head> |
4 | <link type="text/css" rel="stylesheet" href="../inc/awesomplete.css" /> | ||
5 | {include="includes"} | ||
6 | </head> | ||
4 | <body> | 7 | <body> |
5 | <div id="pageheader"> | 8 | <div id="pageheader"> |
6 | {include="page.header"} | 9 | {include="page.header"} |
7 | <div id="headerform" class="search"> | 10 | <div id="headerform" class="search"> |
8 | <form method="GET" class="searchform" name="searchform"><input type="text" id="searchform_value" name="searchterm" placeholder="Search text" value=""> <input type="submit" value="Search" class="bigbutton"></form> | 11 | <form method="GET" class="searchform" name="searchform"><input type="text" id="searchform_value" name="searchterm" placeholder="Search text" value=""> <input type="submit" value="Search" class="bigbutton"></form> |
9 | <form method="GET" class="tagfilter" name="tagfilter"><input type="text" name="searchtags" id="tagfilter_value" placeholder="Filter by tag" value=""> <input type="submit" value="Search" class="bigbutton"></form> | 12 | <form method="GET" class="tagfilter" name="tagfilter"> |
13 | <input type="text" name="searchtags" id="tagfilter_value" placeholder="Filter by tag" value="" list="tagsList" autocomplete="off" class="awesomplete" data-minChars="1"> | ||
14 | <datalist id="tagsList"> | ||
15 | {loop="$tags"}<option>{$key}</option>{/loop} | ||
16 | </datalist> | ||
17 | <input type="submit" value="Search" class="bigbutton"> | ||
18 | </form> | ||
10 | </div> | 19 | </div> |
11 | </div> | 20 | </div> |
12 | 21 | ||
@@ -129,5 +138,6 @@ function showQrCode(caller,loading) | |||
129 | return false; | 138 | return false; |
130 | } | 139 | } |
131 | </script> | 140 | </script> |
141 | <script src="inc/awesomplete.min.js#"></script> | ||
132 | </body> | 142 | </body> |
133 | </html> | 143 | </html> |