aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--application/LinkDB.php120
-rw-r--r--application/LinkFilter.php259
-rw-r--r--application/Utils.php12
-rw-r--r--index.php200
-rw-r--r--tests/LinkDBTest.php229
-rw-r--r--tests/LinkFilterTest.php242
-rw-r--r--tests/utils/ReferenceLinkDB.php5
-rw-r--r--tpl/linklist.html17
8 files changed, 673 insertions, 411 deletions
diff --git a/application/LinkDB.php b/application/LinkDB.php
index 51fa926d..be7d9016 100644
--- a/application/LinkDB.php
+++ b/application/LinkDB.php
@@ -63,6 +63,11 @@ class LinkDB implements Iterator, Countable, ArrayAccess
63 private $_redirector; 63 private $_redirector;
64 64
65 /** 65 /**
66 * @var LinkFilter instance.
67 */
68 private $linkFilter;
69
70 /**
66 * Creates a new LinkDB 71 * Creates a new LinkDB
67 * 72 *
68 * Checks if the datastore exists; else, attempts to create a dummy one. 73 * Checks if the datastore exists; else, attempts to create a dummy one.
@@ -80,6 +85,7 @@ class LinkDB implements Iterator, Countable, ArrayAccess
80 $this->_redirector = $redirector; 85 $this->_redirector = $redirector;
81 $this->_checkDB(); 86 $this->_checkDB();
82 $this->_readDB(); 87 $this->_readDB();
88 $this->linkFilter = new LinkFilter($this->_links);
83 } 89 }
84 90
85 /** 91 /**
@@ -334,114 +340,18 @@ You use the community supported version of the original Shaarli project, by Seba
334 } 340 }
335 341
336 /** 342 /**
337 * Returns the list of links corresponding to a full-text search 343 * Filter links.
338 * 344 *
339 * Searches: 345 * @param string $type Type of filter.
340 * - in the URLs, title and description; 346 * @param mixed $request Search request, string or array.
341 * - are case-insensitive. 347 * @param bool $casesensitive Optional: Perform case sensitive filter
342 * 348 * @param bool $privateonly Optional: Returns private links only if true.
343 * Example:
344 * print_r($mydb->filterFulltext('hollandais'));
345 *
346 * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
347 * - allows to perform searches on Unicode text
348 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
349 */
350 public function filterFulltext($searchterms)
351 {
352 // FIXME: explode(' ',$searchterms) and perform a AND search.
353 // FIXME: accept double-quotes to search for a string "as is"?
354 $filtered = array();
355 $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
356 $keys = array('title', 'description', 'url', 'tags');
357
358 foreach ($this->_links as $link) {
359 $found = false;
360
361 foreach ($keys as $key) {
362 if (strpos(mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8'),
363 $search) !== false) {
364 $found = true;
365 }
366 }
367
368 if ($found) {
369 $filtered[$link['linkdate']] = $link;
370 }
371 }
372 krsort($filtered);
373 return $filtered;
374 }
375
376 /**
377 * Returns the list of links associated with a given list of tags
378 * 349 *
379 * You can specify one or more tags, separated by space or a comma, e.g. 350 * @return array filtered links
380 * print_r($mydb->filterTags('linux programming'));
381 */ 351 */
382 public function filterTags($tags, $casesensitive=false) 352 public function filter($type, $request, $casesensitive = false, $privateonly = false) {
383 { 353 $requestFilter = is_array($request) ? implode(' ', $request) : $request;
384 // Same as above, we use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) 354 return $this->linkFilter->filter($type, $requestFilter, $casesensitive, $privateonly);
385 // FIXME: is $casesensitive ever true?
386 $t = str_replace(
387 ',', ' ',
388 ($casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'))
389 );
390
391 $searchtags = explode(' ', $t);
392 $filtered = array();
393
394 foreach ($this->_links as $l) {
395 $linktags = explode(
396 ' ',
397 ($casesensitive ? $l['tags']:mb_convert_case($l['tags'], MB_CASE_LOWER, 'UTF-8'))
398 );
399
400 if (count(array_intersect($linktags, $searchtags)) == count($searchtags)) {
401 $filtered[$l['linkdate']] = $l;
402 }
403 }
404 krsort($filtered);
405 return $filtered;
406 }
407
408
409 /**
410 * Returns the list of articles for a given day, chronologically sorted
411 *
412 * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
413 * print_r($mydb->filterDay('20120125'));
414 */
415 public function filterDay($day)
416 {
417 if (! checkDateFormat('Ymd', $day)) {
418 throw new Exception('Invalid date format');
419 }
420
421 $filtered = array();
422 foreach ($this->_links as $l) {
423 if (startsWith($l['linkdate'], $day)) {
424 $filtered[$l['linkdate']] = $l;
425 }
426 }
427 ksort($filtered);
428 return $filtered;
429 }
430
431 /**
432 * Returns the article corresponding to a smallHash
433 */
434 public function filterSmallHash($smallHash)
435 {
436 $filtered = array();
437 foreach ($this->_links as $l) {
438 if ($smallHash == smallHash($l['linkdate'])) {
439 // Yes, this is ugly and slow
440 $filtered[$l['linkdate']] = $l;
441 return $filtered;
442 }
443 }
444 return $filtered;
445 } 355 }
446 356
447 /** 357 /**
diff --git a/application/LinkFilter.php b/application/LinkFilter.php
new file mode 100644
index 00000000..cf647371
--- /dev/null
+++ b/application/LinkFilter.php
@@ -0,0 +1,259 @@
1<?php
2
3/**
4 * Class LinkFilter.
5 *
6 * Perform search and filter operation on link data list.
7 */
8class LinkFilter
9{
10 /**
11 * @var string permalinks.
12 */
13 public static $FILTER_HASH = 'permalink';
14
15 /**
16 * @var string text search.
17 */
18 public static $FILTER_TEXT = 'fulltext';
19
20 /**
21 * @var string tag filter.
22 */
23 public static $FILTER_TAG = 'tags';
24
25 /**
26 * @var string filter by day.
27 */
28 public static $FILTER_DAY = 'FILTER_DAY';
29
30 /**
31 * @var array all available links.
32 */
33 private $links;
34
35 /**
36 * @param array $links initialization.
37 */
38 public function __construct($links)
39 {
40 $this->links = $links;
41 }
42
43 /**
44 * Filter links according to parameters.
45 *
46 * @param string $type Type of filter (eg. tags, permalink, etc.).
47 * @param string $request Filter content.
48 * @param bool $casesensitive Optional: Perform case sensitive filter if true.
49 * @param bool $privateonly Optional: Only returns private links if true.
50 *
51 * @return array filtered link list.
52 */
53 public function filter($type, $request, $casesensitive = false, $privateonly = false)
54 {
55 switch($type) {
56 case self::$FILTER_HASH:
57 return $this->filterSmallHash($request);
58 break;
59 case self::$FILTER_TEXT:
60 return $this->filterFulltext($request, $privateonly);
61 break;
62 case self::$FILTER_TAG:
63 return $this->filterTags($request, $casesensitive, $privateonly);
64 break;
65 case self::$FILTER_DAY:
66 return $this->filterDay($request);
67 break;
68 default:
69 return $this->noFilter($privateonly);
70 }
71 }
72
73 /**
74 * Unknown filter, but handle private only.
75 *
76 * @param bool $privateonly returns private link only if true.
77 *
78 * @return array filtered links.
79 */
80 private function noFilter($privateonly = false)
81 {
82 if (! $privateonly) {
83 krsort($this->links);
84 return $this->links;
85 }
86
87 $out = array();
88 foreach ($this->links as $value) {
89 if ($value['private']) {
90 $out[$value['linkdate']] = $value;
91 }
92 }
93
94 krsort($out);
95 return $out;
96 }
97
98 /**
99 * Returns the shaare corresponding to a smallHash.
100 *
101 * @param string $smallHash permalink hash.
102 *
103 * @return array $filtered array containing permalink data.
104 */
105 private function filterSmallHash($smallHash)
106 {
107 $filtered = array();
108 foreach ($this->links as $l) {
109 if ($smallHash == smallHash($l['linkdate'])) {
110 // Yes, this is ugly and slow
111 $filtered[$l['linkdate']] = $l;
112 return $filtered;
113 }
114 }
115 return $filtered;
116 }
117
118 /**
119 * Returns the list of links corresponding to a full-text search
120 *
121 * Searches:
122 * - in the URLs, title and description;
123 * - are case-insensitive.
124 *
125 * Example:
126 * print_r($mydb->filterFulltext('hollandais'));
127 *
128 * mb_convert_case($val, MB_CASE_LOWER, 'UTF-8')
129 * - allows to perform searches on Unicode text
130 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
131 *
132 * @param string $searchterms search query.
133 * @param bool $privateonly return only private links if true.
134 *
135 * @return array search results.
136 */
137 private function filterFulltext($searchterms, $privateonly = false)
138 {
139 // FIXME: explode(' ',$searchterms) and perform a AND search.
140 // FIXME: accept double-quotes to search for a string "as is"?
141 $filtered = array();
142 $search = mb_convert_case($searchterms, MB_CASE_LOWER, 'UTF-8');
143 $explodedSearch = explode(' ', trim($search));
144 $keys = array('title', 'description', 'url', 'tags');
145
146 // Iterate over every stored link.
147 foreach ($this->links as $link) {
148 $found = false;
149
150 // ignore non private links when 'privatonly' is on.
151 if (! $link['private'] && $privateonly === true) {
152 continue;
153 }
154
155 // Iterate over searchable link fields.
156 foreach ($keys as $key) {
157 // Search full expression.
158 if (strpos(
159 mb_convert_case($link[$key], MB_CASE_LOWER, 'UTF-8'),
160 $search
161 ) !== false) {
162 $found = true;
163 }
164
165 if ($found) {
166 break;
167 }
168 }
169
170 if ($found) {
171 $filtered[$link['linkdate']] = $link;
172 }
173 }
174
175 krsort($filtered);
176 return $filtered;
177 }
178
179 /**
180 * Returns the list of links associated with a given list of tags
181 *
182 * You can specify one or more tags, separated by space or a comma, e.g.
183 * print_r($mydb->filterTags('linux programming'));
184 *
185 * @param string $tags list of tags separated by commas or blank spaces.
186 * @param bool $casesensitive ignore case if false.
187 * @param bool $privateonly returns private links only.
188 *
189 * @return array filtered links.
190 */
191 public function filterTags($tags, $casesensitive = false, $privateonly = false)
192 {
193 $searchtags = $this->tagsStrToArray($tags, $casesensitive);
194 $filtered = array();
195
196 foreach ($this->links as $l) {
197 // ignore non private links when 'privatonly' is on.
198 if (! $l['private'] && $privateonly === true) {
199 continue;
200 }
201
202 $linktags = $this->tagsStrToArray($l['tags'], $casesensitive);
203
204 if (count(array_intersect($linktags, $searchtags)) == count($searchtags)) {
205 $filtered[$l['linkdate']] = $l;
206 }
207 }
208 krsort($filtered);
209 return $filtered;
210 }
211
212 /**
213 * Returns the list of articles for a given day, chronologically sorted
214 *
215 * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
216 * print_r($mydb->filterDay('20120125'));
217 *
218 * @param string $day day to filter.
219 *
220 * @return array all link matching given day.
221 *
222 * @throws Exception if date format is invalid.
223 */
224 public function filterDay($day)
225 {
226 if (! checkDateFormat('Ymd', $day)) {
227 throw new Exception('Invalid date format');
228 }
229
230 $filtered = array();
231 foreach ($this->links as $l) {
232 if (startsWith($l['linkdate'], $day)) {
233 $filtered[$l['linkdate']] = $l;
234 }
235 }
236 ksort($filtered);
237 return $filtered;
238 }
239
240 /**
241 * Convert a list of tags (str) to an array. Also
242 * - handle case sensitivity.
243 * - accepts spaces commas as separator.
244 * - remove private tags for loggedout users.
245 *
246 * @param string $tags string containing a list of tags.
247 * @param bool $casesensitive will convert everything to lowercase if false.
248 *
249 * @return array filtered tags string.
250 */
251 public function tagsStrToArray($tags, $casesensitive)
252 {
253 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
254 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
255 $tagsOut = str_replace(',', ' ', $tagsOut);
256
257 return explode(' ', trim($tagsOut));
258 }
259}
diff --git a/application/Utils.php b/application/Utils.php
index f84f70e4..aeaef9ff 100644
--- a/application/Utils.php
+++ b/application/Utils.php
@@ -72,12 +72,14 @@ function sanitizeLink(&$link)
72 72
73/** 73/**
74 * Checks if a string represents a valid date 74 * Checks if a string represents a valid date
75
76 * @param string $format The expected DateTime format of the string
77 * @param string $string A string-formatted date
78 *
79 * @return bool whether the string is a valid date
75 * 80 *
76 * @param string a string-formatted date 81 * @see http://php.net/manual/en/class.datetime.php
77 * @param format the expected DateTime format of the string 82 * @see http://php.net/manual/en/datetime.createfromformat.php
78 * @return whether the string is a valid date
79 * @see http://php.net/manual/en/class.datetime.php
80 * @see http://php.net/manual/en/datetime.createfromformat.php
81 */ 83 */
82function checkDateFormat($format, $string) 84function checkDateFormat($format, $string)
83{ 85{
diff --git a/index.php b/index.php
index 40a6fbe5..1664c01b 100644
--- a/index.php
+++ b/index.php
@@ -151,6 +151,7 @@ require_once 'application/CachedPage.php';
151require_once 'application/FileUtils.php'; 151require_once 'application/FileUtils.php';
152require_once 'application/HttpUtils.php'; 152require_once 'application/HttpUtils.php';
153require_once 'application/LinkDB.php'; 153require_once 'application/LinkDB.php';
154require_once 'application/LinkFilter.php';
154require_once 'application/TimeZone.php'; 155require_once 'application/TimeZone.php';
155require_once 'application/Url.php'; 156require_once 'application/Url.php';
156require_once 'application/Utils.php'; 157require_once 'application/Utils.php';
@@ -730,18 +731,23 @@ function showRSS()
730 // Read links from database (and filter private links if user it not logged in). 731 // Read links from database (and filter private links if user it not logged in).
731 732
732 // Optionally filter the results: 733 // Optionally filter the results:
733 $linksToDisplay=array(); 734 if (!empty($_GET['searchterm'])) {
734 if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); 735 $linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $_GET['searchterm']);
735 else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); 736 }
736 else $linksToDisplay = $LINKSDB; 737 elseif (!empty($_GET['searchtags'])) {
738 $linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TAG, trim($_GET['searchtags']));
739 }
740 else {
741 $linksToDisplay = $LINKSDB;
742 }
737 743
738 $nblinksToDisplay = 50; // Number of links to display. 744 $nblinksToDisplay = 50; // Number of links to display.
739 if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. 745 // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links.
740 { 746 if (!empty($_GET['nb'])) {
741 $nblinksToDisplay = $_GET['nb']=='all' ? count($linksToDisplay) : max($_GET['nb']+0,1) ; 747 $nblinksToDisplay = $_GET['nb'] == 'all' ? count($linksToDisplay) : max(intval($_GET['nb']), 1);
742 } 748 }
743 749
744 $pageaddr=escape(index_url($_SERVER)); 750 $pageaddr = escape(index_url($_SERVER));
745 echo '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">'; 751 echo '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">';
746 echo '<channel><title>'.$GLOBALS['title'].'</title><link>'.$pageaddr.'</link>'; 752 echo '<channel><title>'.$GLOBALS['title'].'</title><link>'.$pageaddr.'</link>';
747 echo '<description>Shared links</description><language>en-en</language><copyright>'.$pageaddr.'</copyright>'."\n\n"; 753 echo '<description>Shared links</description><language>en-en</language><copyright>'.$pageaddr.'</copyright>'."\n\n";
@@ -821,15 +827,20 @@ function showATOM()
821 ); 827 );
822 828
823 // Optionally filter the results: 829 // Optionally filter the results:
824 $linksToDisplay=array(); 830 if (!empty($_GET['searchterm'])) {
825 if (!empty($_GET['searchterm'])) $linksToDisplay = $LINKSDB->filterFulltext($_GET['searchterm']); 831 $linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $_GET['searchterm']);
826 else if (!empty($_GET['searchtags'])) $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); 832 }
827 else $linksToDisplay = $LINKSDB; 833 else if (!empty($_GET['searchtags'])) {
834 $linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_TAG, trim($_GET['searchtags']));
835 }
836 else {
837 $linksToDisplay = $LINKSDB;
838 }
828 839
829 $nblinksToDisplay = 50; // Number of links to display. 840 $nblinksToDisplay = 50; // Number of links to display.
830 if (!empty($_GET['nb'])) // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links. 841 // In URL, you can specificy the number of links. Example: nb=200 or nb=all for all links.
831 { 842 if (!empty($_GET['nb'])) {
832 $nblinksToDisplay = $_GET['nb']=='all' ? count($linksToDisplay) : max($_GET['nb']+0,1) ; 843 $nblinksToDisplay = $_GET['nb']=='all' ? count($linksToDisplay) : max(intval($_GET['nb']), 1);
833 } 844 }
834 845
835 $pageaddr=escape(index_url($_SERVER)); 846 $pageaddr=escape(index_url($_SERVER));
@@ -1024,7 +1035,7 @@ function showDaily($pageBuilder)
1024 } 1035 }
1025 1036
1026 try { 1037 try {
1027 $linksToDisplay = $LINKSDB->filterDay($day); 1038 $linksToDisplay = $LINKSDB->filter(LinkFilter::$FILTER_DAY, $day);
1028 } catch (Exception $exc) { 1039 } catch (Exception $exc) {
1029 error_log($exc); 1040 error_log($exc);
1030 $linksToDisplay = array(); 1041 $linksToDisplay = array();
@@ -1149,13 +1160,17 @@ function renderPage()
1149 if ($targetPage == Router::$PAGE_PICWALL) 1160 if ($targetPage == Router::$PAGE_PICWALL)
1150 { 1161 {
1151 // Optionally filter the results: 1162 // Optionally filter the results:
1152 $links=array(); 1163 if (!empty($_GET['searchterm'])) {
1153 if (!empty($_GET['searchterm'])) $links = $LINKSDB->filterFulltext($_GET['searchterm']); 1164 $links = $LINKSDB->filter(LinkFilter::$FILTER_TEXT, $_GET['searchterm']);
1154 elseif (!empty($_GET['searchtags'])) $links = $LINKSDB->filterTags(trim($_GET['searchtags'])); 1165 }
1155 else $links = $LINKSDB; 1166 elseif (! empty($_GET['searchtags'])) {
1167 $links = $LINKSDB->filter(LinkFilter::$FILTER_TAG, trim($_GET['searchtags']));
1168 }
1169 else {
1170 $links = $LINKSDB;
1171 }
1156 1172
1157 $body=''; 1173 $linksToDisplay = array();
1158 $linksToDisplay=array();
1159 1174
1160 // Get only links which have a thumbnail. 1175 // Get only links which have a thumbnail.
1161 foreach($links as $link) 1176 foreach($links as $link)
@@ -1282,7 +1297,7 @@ function renderPage()
1282 } 1297 }
1283 1298
1284 if (isset($params['searchtags'])) { 1299 if (isset($params['searchtags'])) {
1285 $tags = explode(' ',$params['searchtags']); 1300 $tags = explode(' ', $params['searchtags']);
1286 $tags=array_diff($tags, array($_GET['removetag'])); // Remove value from array $tags. 1301 $tags=array_diff($tags, array($_GET['removetag'])); // Remove value from array $tags.
1287 if (count($tags)==0) { 1302 if (count($tags)==0) {
1288 unset($params['searchtags']); 1303 unset($params['searchtags']);
@@ -1467,7 +1482,8 @@ function renderPage()
1467 if (!empty($_POST['deletetag']) && !empty($_POST['fromtag'])) 1482 if (!empty($_POST['deletetag']) && !empty($_POST['fromtag']))
1468 { 1483 {
1469 $needle=trim($_POST['fromtag']); 1484 $needle=trim($_POST['fromtag']);
1470 $linksToAlter = $LINKSDB->filterTags($needle,true); // True for case-sensitive tag search. 1485 // True for case-sensitive tag search.
1486 $linksToAlter = $LINKSDB->filter(LinkFilter::$FILTER_TAG, $needle, true);
1471 foreach($linksToAlter as $key=>$value) 1487 foreach($linksToAlter as $key=>$value)
1472 { 1488 {
1473 $tags = explode(' ',trim($value['tags'])); 1489 $tags = explode(' ',trim($value['tags']));
@@ -1484,7 +1500,8 @@ function renderPage()
1484 if (!empty($_POST['renametag']) && !empty($_POST['fromtag']) && !empty($_POST['totag'])) 1500 if (!empty($_POST['renametag']) && !empty($_POST['fromtag']) && !empty($_POST['totag']))
1485 { 1501 {
1486 $needle=trim($_POST['fromtag']); 1502 $needle=trim($_POST['fromtag']);
1487 $linksToAlter = $LINKSDB->filterTags($needle,true); // true for case-sensitive tag search. 1503 // True for case-sensitive tag search.
1504 $linksToAlter = $LINKSDB->filter(LinkFilter::$FILTER_TAG, $needle, true);
1488 foreach($linksToAlter as $key=>$value) 1505 foreach($linksToAlter as $key=>$value)
1489 { 1506 {
1490 $tags = explode(' ',trim($value['tags'])); 1507 $tags = explode(' ',trim($value['tags']));
@@ -1865,81 +1882,78 @@ function importFile()
1865function buildLinkList($PAGE,$LINKSDB) 1882function buildLinkList($PAGE,$LINKSDB)
1866{ 1883{
1867 // ---- Filter link database according to parameters 1884 // ---- Filter link database according to parameters
1868 $linksToDisplay=array(); 1885 $search_type = '';
1869 $search_type=''; 1886 $search_crits = '';
1870 $search_crits=''; 1887 $privateonly = !empty($_SESSION['privateonly']) ? true : false;
1871 if (isset($_GET['searchterm'])) // Fulltext search 1888
1872 { 1889 // Fulltext search
1873 $linksToDisplay = $LINKSDB->filterFulltext(trim($_GET['searchterm'])); 1890 if (isset($_GET['searchterm'])) {
1874 $search_crits=escape(trim($_GET['searchterm'])); 1891 $search_crits = escape(trim($_GET['searchterm']));
1875 $search_type='fulltext'; 1892 $search_type = LinkFilter::$FILTER_TEXT;
1876 } 1893 $linksToDisplay = $LINKSDB->filter($search_type, $search_crits, false, $privateonly);
1877 elseif (isset($_GET['searchtags'])) // Search by tag 1894 }
1878 { 1895 // Search by tag
1879 $linksToDisplay = $LINKSDB->filterTags(trim($_GET['searchtags'])); 1896 elseif (isset($_GET['searchtags'])) {
1880 $search_crits=explode(' ',escape(trim($_GET['searchtags']))); 1897 $search_crits = explode(' ', escape(trim($_GET['searchtags'])));
1881 $search_type='tags'; 1898 $search_type = LinkFilter::$FILTER_TAG;
1882 } 1899 $linksToDisplay = $LINKSDB->filter($search_type, $search_crits, false, $privateonly);
1883 elseif (isset($_SERVER['QUERY_STRING']) && preg_match('/[a-zA-Z0-9-_@]{6}(&.+?)?/',$_SERVER['QUERY_STRING'])) // Detect smallHashes in URL 1900 }
1884 { 1901 // Detect smallHashes in URL.
1885 $linksToDisplay = $LINKSDB->filterSmallHash(substr(trim($_SERVER["QUERY_STRING"], '/'),0,6)); 1902 elseif (isset($_SERVER['QUERY_STRING'])
1886 if (count($linksToDisplay)==0) 1903 && preg_match('/[a-zA-Z0-9-_@]{6}(&.+?)?/', $_SERVER['QUERY_STRING'])) {
1887 { 1904 $search_type = LinkFilter::$FILTER_HASH;
1888 header($_SERVER["SERVER_PROTOCOL"]." 404 Not Found"); 1905 $search_crits = substr(trim($_SERVER["QUERY_STRING"], '/'), 0, 6);
1889 echo '<h1>404 Not found.</h1>Oh crap. The link you are trying to reach does not exist or has been deleted.'; 1906 $linksToDisplay = $LINKSDB->filter($search_type, $search_crits);
1907
1908 if (count($linksToDisplay) == 0) {
1909 header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
1910 echo '<h1>404 Not found.</h1>Oh crap.
1911 The link you are trying to reach does not exist or has been deleted.';
1890 echo '<br>Would you mind <a href="?">clicking here</a>?'; 1912 echo '<br>Would you mind <a href="?">clicking here</a>?';
1891 exit; 1913 exit;
1892 } 1914 }
1893 $search_type='permalink';
1894 } 1915 }
1895 else 1916 // Otherwise, display without filtering.
1896 $linksToDisplay = $LINKSDB; // Otherwise, display without filtering. 1917 else {
1897 1918 $linksToDisplay = $LINKSDB->filter('', '', false, $privateonly);
1898
1899 // Option: Show only private links
1900 if (!empty($_SESSION['privateonly']))
1901 {
1902 $tmp = array();
1903 foreach($linksToDisplay as $linkdate=>$link)
1904 {
1905 if ($link['private']!=0) $tmp[$linkdate]=$link;
1906 }
1907 $linksToDisplay=$tmp;
1908 } 1919 }
1909 1920
1910 // ---- Handle paging. 1921 // ---- Handle paging.
1911 /* Can someone explain to me why you get the following error when using array_keys() on an object which implements the interface ArrayAccess??? 1922 $keys = array();
1912 "Warning: array_keys() expects parameter 1 to be array, object given in ... " 1923 foreach ($linksToDisplay as $key => $value) {
1913 If my class implements ArrayAccess, why won't array_keys() accept it ? ( $keys=array_keys($linksToDisplay); ) 1924 $keys[] = $key;
1914 */ 1925 }
1915 $keys=array(); foreach($linksToDisplay as $key=>$value) { $keys[]=$key; } // Stupid and ugly. Thanks PHP.
1916 1926
1917 // If there is only a single link, we change on-the-fly the title of the page. 1927 // If there is only a single link, we change on-the-fly the title of the page.
1918 if (count($linksToDisplay)==1) $GLOBALS['pagetitle'] = $linksToDisplay[$keys[0]]['title'].' - '.$GLOBALS['title']; 1928 if (count($linksToDisplay) == 1) {
1929 $GLOBALS['pagetitle'] = $linksToDisplay[$keys[0]]['title'].' - '.$GLOBALS['title'];
1930 }
1919 1931
1920 // Select articles according to paging. 1932 // Select articles according to paging.
1921 $pagecount = ceil(count($keys)/$_SESSION['LINKS_PER_PAGE']); 1933 $pagecount = ceil(count($keys) / $_SESSION['LINKS_PER_PAGE']);
1922 $pagecount = ($pagecount==0 ? 1 : $pagecount); 1934 $pagecount = $pagecount == 0 ? 1 : $pagecount;
1923 $page=( empty($_GET['page']) ? 1 : intval($_GET['page'])); 1935 $page= empty($_GET['page']) ? 1 : intval($_GET['page']);
1924 $page = ( $page<1 ? 1 : $page ); 1936 $page = $page < 1 ? 1 : $page;
1925 $page = ( $page>$pagecount ? $pagecount : $page ); 1937 $page = $page > $pagecount ? $pagecount : $page;
1926 $i = ($page-1)*$_SESSION['LINKS_PER_PAGE']; // Start index. 1938 // Start index.
1927 $end = $i+$_SESSION['LINKS_PER_PAGE']; 1939 $i = ($page-1) * $_SESSION['LINKS_PER_PAGE'];
1928 $linkDisp=array(); // Links to display 1940 $end = $i + $_SESSION['LINKS_PER_PAGE'];
1941 $linkDisp = array();
1929 while ($i<$end && $i<count($keys)) 1942 while ($i<$end && $i<count($keys))
1930 { 1943 {
1931 $link = $linksToDisplay[$keys[$i]]; 1944 $link = $linksToDisplay[$keys[$i]];
1932 $link['description'] = format_description($link['description'], $GLOBALS['redirector']); 1945 $link['description'] = format_description($link['description'], $GLOBALS['redirector']);
1933 $classLi = $i%2!=0 ? '' : 'publicLinkHightLight'; 1946 $classLi = ($i % 2) != 0 ? '' : 'publicLinkHightLight';
1934 $link['class'] = ($link['private']==0 ? $classLi : 'private'); 1947 $link['class'] = $link['private'] == 0 ? $classLi : 'private';
1935 $link['timestamp']=linkdate2timestamp($link['linkdate']); 1948 $link['timestamp'] = linkdate2timestamp($link['linkdate']);
1936 $taglist = explode(' ',$link['tags']); 1949 $taglist = explode(' ', $link['tags']);
1937 uasort($taglist, 'strcasecmp'); 1950 uasort($taglist, 'strcasecmp');
1938 $link['taglist']=$taglist; 1951 $link['taglist'] = $taglist;
1939 $link['shorturl'] = smallHash($link['linkdate']); 1952 $link['shorturl'] = smallHash($link['linkdate']);
1940 if ($link["url"][0] === '?' && // Check for both signs of a note: starting with ? and 7 chars long. I doubt that you'll post any links that look like this. 1953 // Check for both signs of a note: starting with ? and 7 chars long.
1941 strlen($link["url"]) === 7) { 1954 if ($link['url'][0] === '?' &&
1942 $link["url"] = index_url($_SERVER) . $link["url"]; 1955 strlen($link['url']) === 7) {
1956 $link['url'] = index_url($_SERVER) . $link['url'];
1943 } 1957 }
1944 1958
1945 $linkDisp[$keys[$i]] = $link; 1959 $linkDisp[$keys[$i]] = $link;
@@ -1947,13 +1961,21 @@ function buildLinkList($PAGE,$LINKSDB)
1947 } 1961 }
1948 1962
1949 // Compute paging navigation 1963 // Compute paging navigation
1950 $searchterm= ( empty($_GET['searchterm']) ? '' : '&searchterm='.$_GET['searchterm'] ); 1964 $searchterm = empty($_GET['searchterm']) ? '' : '&searchterm=' . $_GET['searchterm'];
1951 $searchtags= ( empty($_GET['searchtags']) ? '' : '&searchtags='.$_GET['searchtags'] ); 1965 $searchtags = empty($_GET['searchtags']) ? '' : '&searchtags=' . $_GET['searchtags'];
1952 $paging=''; 1966 $previous_page_url = '';
1953 $previous_page_url=''; if ($i!=count($keys)) $previous_page_url='?page='.($page+1).$searchterm.$searchtags; 1967 if ($i != count($keys)) {
1954 $next_page_url='';if ($page>1) $next_page_url='?page='.($page-1).$searchterm.$searchtags; 1968 $previous_page_url = '?page=' . ($page+1) . $searchterm . $searchtags;
1969 }
1970 $next_page_url='';
1971 if ($page>1) {
1972 $next_page_url = '?page=' . ($page-1) . $searchterm . $searchtags;
1973 }
1955 1974
1956 $token = ''; if (isLoggedIn()) $token=getToken(); 1975 $token = '';
1976 if (isLoggedIn()) {
1977 $token = getToken();
1978 }
1957 1979
1958 // Fill all template fields. 1980 // Fill all template fields.
1959 $data = array( 1981 $data = array(
diff --git a/tests/LinkDBTest.php b/tests/LinkDBTest.php
index 7b22b270..3b1a2057 100644
--- a/tests/LinkDBTest.php
+++ b/tests/LinkDBTest.php
@@ -302,236 +302,49 @@ class LinkDBTest extends PHPUnit_Framework_TestCase
302 } 302 }
303 303
304 /** 304 /**
305 * Filter links using a tag 305 * Test real_url without redirector.
306 */
307 public function testFilterOneTag()
308 {
309 $this->assertEquals(
310 3,
311 sizeof(self::$publicLinkDB->filterTags('web', false))
312 );
313
314 $this->assertEquals(
315 4,
316 sizeof(self::$privateLinkDB->filterTags('web', false))
317 );
318 }
319
320 /**
321 * Filter links using a tag - case-sensitive
322 */
323 public function testFilterCaseSensitiveTag()
324 {
325 $this->assertEquals(
326 0,
327 sizeof(self::$privateLinkDB->filterTags('mercurial', true))
328 );
329
330 $this->assertEquals(
331 1,
332 sizeof(self::$privateLinkDB->filterTags('Mercurial', true))
333 );
334 }
335
336 /**
337 * Filter links using a tag combination
338 */
339 public function testFilterMultipleTags()
340 {
341 $this->assertEquals(
342 1,
343 sizeof(self::$publicLinkDB->filterTags('dev cartoon', false))
344 );
345
346 $this->assertEquals(
347 2,
348 sizeof(self::$privateLinkDB->filterTags('dev cartoon', false))
349 );
350 }
351
352 /**
353 * Filter links using a non-existent tag
354 */
355 public function testFilterUnknownTag()
356 {
357 $this->assertEquals(
358 0,
359 sizeof(self::$publicLinkDB->filterTags('null', false))
360 );
361 }
362
363 /**
364 * Return links for a given day
365 */
366 public function testFilterDay()
367 {
368 $this->assertEquals(
369 2,
370 sizeof(self::$publicLinkDB->filterDay('20121206'))
371 );
372
373 $this->assertEquals(
374 3,
375 sizeof(self::$privateLinkDB->filterDay('20121206'))
376 );
377 }
378
379 /**
380 * 404 - day not found
381 */
382 public function testFilterUnknownDay()
383 {
384 $this->assertEquals(
385 0,
386 sizeof(self::$publicLinkDB->filterDay('19700101'))
387 );
388
389 $this->assertEquals(
390 0,
391 sizeof(self::$privateLinkDB->filterDay('19700101'))
392 );
393 }
394
395 /**
396 * Use an invalid date format
397 * @expectedException Exception
398 * @expectedExceptionMessageRegExp /Invalid date format/
399 */
400 public function testFilterInvalidDayWithChars()
401 {
402 self::$privateLinkDB->filterDay('Rainy day, dream away');
403 }
404
405 /**
406 * Use an invalid date format
407 * @expectedException Exception
408 * @expectedExceptionMessageRegExp /Invalid date format/
409 */
410 public function testFilterInvalidDayDigits()
411 {
412 self::$privateLinkDB->filterDay('20');
413 }
414
415 /**
416 * Retrieve a link entry with its hash
417 */
418 public function testFilterSmallHash()
419 {
420 $links = self::$privateLinkDB->filterSmallHash('IuWvgA');
421
422 $this->assertEquals(
423 1,
424 sizeof($links)
425 );
426
427 $this->assertEquals(
428 'MediaGoblin',
429 $links['20130614_184135']['title']
430 );
431
432 }
433
434 /**
435 * No link for this hash
436 */
437 public function testFilterUnknownSmallHash()
438 {
439 $this->assertEquals(
440 0,
441 sizeof(self::$privateLinkDB->filterSmallHash('Iblaah'))
442 );
443 }
444
445 /**
446 * Full-text search - result from a link's URL
447 */
448 public function testFilterFullTextURL()
449 {
450 $this->assertEquals(
451 2,
452 sizeof(self::$publicLinkDB->filterFullText('ars.userfriendly.org'))
453 );
454 }
455
456 /**
457 * Full-text search - result from a link's title only
458 */ 306 */
459 public function testFilterFullTextTitle() 307 public function testLinkRealUrlWithoutRedirector()
460 { 308 {
461 // use miscellaneous cases 309 $db = new LinkDB(self::$testDatastore, false, false);
462 $this->assertEquals( 310 foreach($db as $link) {
463 2, 311 $this->assertEquals($link['url'], $link['real_url']);
464 sizeof(self::$publicLinkDB->filterFullText('userfriendly -')) 312 }
465 );
466 $this->assertEquals(
467 2,
468 sizeof(self::$publicLinkDB->filterFullText('UserFriendly -'))
469 );
470 $this->assertEquals(
471 2,
472 sizeof(self::$publicLinkDB->filterFullText('uSeRFrIendlY -'))
473 );
474
475 // use miscellaneous case and offset
476 $this->assertEquals(
477 2,
478 sizeof(self::$publicLinkDB->filterFullText('RFrIendL'))
479 );
480 } 313 }
481 314
482 /** 315 /**
483 * Full-text search - result from the link's description only 316 * Test real_url with redirector.
484 */ 317 */
485 public function testFilterFullTextDescription() 318 public function testLinkRealUrlWithRedirector()
486 { 319 {
487 $this->assertEquals( 320 $redirector = 'http://redirector.to?';
488 1, 321 $db = new LinkDB(self::$testDatastore, false, false, $redirector);
489 sizeof(self::$publicLinkDB->filterFullText('media publishing')) 322 foreach($db as $link) {
490 ); 323 $this->assertStringStartsWith($redirector, $link['real_url']);
324 }
491 } 325 }
492 326
493 /** 327 /**
494 * Full-text search - result from the link's tags only 328 * Test filter with string.
495 */ 329 */
496 public function testFilterFullTextTags() 330 public function testFilterString()
497 { 331 {
332 $tags = 'dev cartoon';
498 $this->assertEquals( 333 $this->assertEquals(
499 2, 334 2,
500 sizeof(self::$publicLinkDB->filterFullText('gnu')) 335 count(self::$privateLinkDB->filter(LinkFilter::$FILTER_TAG, $tags, true, false))
501 ); 336 );
502 } 337 }
503 338
504 /** 339 /**
505 * Full-text search - result set from mixed sources 340 * Test filter with string.
506 */ 341 */
507 public function testFilterFullTextMixed() 342 public function testFilterArray()
508 { 343 {
344 $tags = array('dev', 'cartoon');
509 $this->assertEquals( 345 $this->assertEquals(
510 2, 346 2,
511 sizeof(self::$publicLinkDB->filterFullText('free software')) 347 count(self::$privateLinkDB->filter(LinkFilter::$FILTER_TAG, $tags, true, false))
512 ); 348 );
513 } 349 }
514
515 /**
516 * Test real_url without redirector.
517 */
518 public function testLinkRealUrlWithoutRedirector()
519 {
520 $db = new LinkDB(self::$testDatastore, false, false);
521 foreach($db as $link) {
522 $this->assertEquals($link['url'], $link['real_url']);
523 }
524 }
525
526 /**
527 * Test real_url with redirector.
528 */
529 public function testLinkRealUrlWithRedirector()
530 {
531 $redirector = 'http://redirector.to?';
532 $db = new LinkDB(self::$testDatastore, false, false, $redirector);
533 foreach($db as $link) {
534 $this->assertStringStartsWith($redirector, $link['real_url']);
535 }
536 }
537} 350}
diff --git a/tests/LinkFilterTest.php b/tests/LinkFilterTest.php
new file mode 100644
index 00000000..5107ab72
--- /dev/null
+++ b/tests/LinkFilterTest.php
@@ -0,0 +1,242 @@
1<?php
2
3require_once 'application/LinkFilter.php';
4
5/**
6 * Class LinkFilterTest.
7 */
8class LinkFilterTest extends PHPUnit_Framework_TestCase
9{
10 /**
11 * @var LinkFilter instance.
12 */
13 protected static $linkFilter;
14
15 /**
16 * Instanciate linkFilter with ReferenceLinkDB data.
17 */
18 public static function setUpBeforeClass()
19 {
20 $refDB = new ReferenceLinkDB();
21 self::$linkFilter = new LinkFilter($refDB->getLinks());
22 }
23
24 /**
25 * Blank filter.
26 */
27 public function testFilter()
28 {
29 $this->assertEquals(
30 6,
31 count(self::$linkFilter->filter('', ''))
32 );
33
34 // Private only.
35 $this->assertEquals(
36 2,
37 count(self::$linkFilter->filter('', '', false, true))
38 );
39 }
40
41 /**
42 * Filter links using a tag
43 */
44 public function testFilterOneTag()
45 {
46 $this->assertEquals(
47 4,
48 count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false))
49 );
50
51 // Private only.
52 $this->assertEquals(
53 1,
54 count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'web', false, true))
55 );
56 }
57
58 /**
59 * Filter links using a tag - case-sensitive
60 */
61 public function testFilterCaseSensitiveTag()
62 {
63 $this->assertEquals(
64 0,
65 count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'mercurial', true))
66 );
67
68 $this->assertEquals(
69 1,
70 count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'Mercurial', true))
71 );
72 }
73
74 /**
75 * Filter links using a tag combination
76 */
77 public function testFilterMultipleTags()
78 {
79 $this->assertEquals(
80 2,
81 count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'dev cartoon', false))
82 );
83 }
84
85 /**
86 * Filter links using a non-existent tag
87 */
88 public function testFilterUnknownTag()
89 {
90 $this->assertEquals(
91 0,
92 count(self::$linkFilter->filter(LinkFilter::$FILTER_TAG, 'null', false))
93 );
94 }
95
96 /**
97 * Return links for a given day
98 */
99 public function testFilterDay()
100 {
101 $this->assertEquals(
102 3,
103 count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20121206'))
104 );
105 }
106
107 /**
108 * 404 - day not found
109 */
110 public function testFilterUnknownDay()
111 {
112 $this->assertEquals(
113 0,
114 count(self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '19700101'))
115 );
116 }
117
118 /**
119 * Use an invalid date format
120 * @expectedException Exception
121 * @expectedExceptionMessageRegExp /Invalid date format/
122 */
123 public function testFilterInvalidDayWithChars()
124 {
125 self::$linkFilter->filter(LinkFilter::$FILTER_DAY, 'Rainy day, dream away');
126 }
127
128 /**
129 * Use an invalid date format
130 * @expectedException Exception
131 * @expectedExceptionMessageRegExp /Invalid date format/
132 */
133 public function testFilterInvalidDayDigits()
134 {
135 self::$linkFilter->filter(LinkFilter::$FILTER_DAY, '20');
136 }
137
138 /**
139 * Retrieve a link entry with its hash
140 */
141 public function testFilterSmallHash()
142 {
143 $links = self::$linkFilter->filter(LinkFilter::$FILTER_HASH, 'IuWvgA');
144
145 $this->assertEquals(
146 1,
147 count($links)
148 );
149
150 $this->assertEquals(
151 'MediaGoblin',
152 $links['20130614_184135']['title']
153 );
154 }
155
156 /**
157 * No link for this hash
158 */
159 public function testFilterUnknownSmallHash()
160 {
161 $this->assertEquals(
162 0,
163 count(self::$linkFilter->filter(LinkFilter::$FILTER_HASH, 'Iblaah'))
164 );
165 }
166
167 /**
168 * Full-text search - result from a link's URL
169 */
170 public function testFilterFullTextURL()
171 {
172 $this->assertEquals(
173 2,
174 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'ars.userfriendly.org'))
175 );
176 }
177
178 /**
179 * Full-text search - result from a link's title only
180 */
181 public function testFilterFullTextTitle()
182 {
183 // use miscellaneous cases
184 $this->assertEquals(
185 2,
186 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'userfriendly -'))
187 );
188 $this->assertEquals(
189 2,
190 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'UserFriendly -'))
191 );
192 $this->assertEquals(
193 2,
194 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'uSeRFrIendlY -'))
195 );
196
197 // use miscellaneous case and offset
198 $this->assertEquals(
199 2,
200 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'RFrIendL'))
201 );
202 }
203
204 /**
205 * Full-text search - result from the link's description only
206 */
207 public function testFilterFullTextDescription()
208 {
209 $this->assertEquals(
210 1,
211 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'media publishing'))
212 );
213 }
214
215 /**
216 * Full-text search - result from the link's tags only
217 */
218 public function testFilterFullTextTags()
219 {
220 $this->assertEquals(
221 2,
222 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'gnu'))
223 );
224
225 // Private only.
226 $this->assertEquals(
227 1,
228 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'web', false, true))
229 );
230 }
231
232 /**
233 * Full-text search - result set from mixed sources
234 */
235 public function testFilterFullTextMixed()
236 {
237 $this->assertEquals(
238 2,
239 count(self::$linkFilter->filter(LinkFilter::$FILTER_TEXT, 'free software'))
240 );
241 }
242}
diff --git a/tests/utils/ReferenceLinkDB.php b/tests/utils/ReferenceLinkDB.php
index 47b51829..011317ef 100644
--- a/tests/utils/ReferenceLinkDB.php
+++ b/tests/utils/ReferenceLinkDB.php
@@ -124,4 +124,9 @@ class ReferenceLinkDB
124 { 124 {
125 return $this->_privateCount; 125 return $this->_privateCount;
126 } 126 }
127
128 public function getLinks()
129 {
130 return $this->_links;
131 }
127} 132}
diff --git a/tpl/linklist.html b/tpl/linklist.html
index 666748a7..09860baf 100644
--- a/tpl/linklist.html
+++ b/tpl/linklist.html
@@ -7,15 +7,24 @@
7<body> 7<body>
8<div id="pageheader"> 8<div id="pageheader">
9 {include="page.header"} 9 {include="page.header"}
10
10 <div id="headerform" class="search"> 11 <div id="headerform" class="search">
11 <form method="GET" class="searchform" name="searchform"> 12 <form method="GET" class="searchform" name="searchform">
12 <input type="text" tabindex="1" id="searchform_value" name="searchterm" placeholder="Search text" value=""> 13 <input type="text" tabindex="1" id="searchform_value" name="searchterm" placeholder="Search text"
14 {if="!empty($search_crits) && $search_type=='fulltext'"}
15 value="{$search_crits}"
16 {/if}
17 >
13 <input type="submit" value="Search" class="bigbutton"> 18 <input type="submit" value="Search" class="bigbutton">
14 </form> 19 </form>
15 <form method="GET" class="tagfilter" name="tagfilter"> 20 <form method="GET" class="tagfilter" name="tagfilter">
16 <input type="text" tabindex="2" name="searchtags" id="tagfilter_value" placeholder="Filter by tag" value="" 21 <input type="text" tabindex="2" name="searchtags" id="tagfilter_value" placeholder="Filter by tag"
17 autocomplete="off" class="awesomplete" data-multiple data-minChars="1" 22 {if="!empty($search_crits) && $search_type=='tags'"}
18 data-list="{loop="$tags"}{$key}, {/loop}"> 23 value="{function="implode(' ', $search_crits)"}"
24 {/if}
25 autocomplete="off" class="awesomplete" data-multiple data-minChars="1"
26 data-list="{loop="$tags"}{$key}, {/loop}">
27 >
19 <input type="submit" value="Search" class="bigbutton"> 28 <input type="submit" value="Search" class="bigbutton">
20 </form> 29 </form>
21 {loop="$plugins_header.fields_toolbar"} 30 {loop="$plugins_header.fields_toolbar"}