aboutsummaryrefslogtreecommitdiffhomepage
path: root/application/LinkFilter.php
diff options
context:
space:
mode:
Diffstat (limited to 'application/LinkFilter.php')
-rw-r--r--application/LinkFilter.php246
1 files changed, 171 insertions, 75 deletions
diff --git a/application/LinkFilter.php b/application/LinkFilter.php
index daa6d9cc..12376e27 100644
--- a/application/LinkFilter.php
+++ b/application/LinkFilter.php
@@ -51,55 +51,73 @@ class LinkFilter
51 * @param string $type Type of filter (eg. tags, permalink, etc.). 51 * @param string $type Type of filter (eg. tags, permalink, etc.).
52 * @param mixed $request Filter content. 52 * @param mixed $request Filter content.
53 * @param bool $casesensitive Optional: Perform case sensitive filter if true. 53 * @param bool $casesensitive Optional: Perform case sensitive filter if true.
54 * @param bool $privateonly Optional: Only returns private links if true. 54 * @param string $visibility Optional: return only all/private/public links
55 * @param string $untaggedonly Optional: return only untagged links. Applies only if $type includes FILTER_TAG
55 * 56 *
56 * @return array filtered link list. 57 * @return array filtered link list.
57 */ 58 */
58 public function filter($type, $request, $casesensitive = false, $privateonly = false) 59 public function filter($type, $request, $casesensitive = false, $visibility = 'all', $untaggedonly = false)
59 { 60 {
61 if (! in_array($visibility, ['all', 'public', 'private'])) {
62 $visibility = 'all';
63 }
64
60 switch($type) { 65 switch($type) {
61 case self::$FILTER_HASH: 66 case self::$FILTER_HASH:
62 return $this->filterSmallHash($request); 67 return $this->filterSmallHash($request);
63 case self::$FILTER_TAG | self::$FILTER_TEXT: 68 case self::$FILTER_TAG | self::$FILTER_TEXT: // == "vuotext"
64 if (!empty($request)) { 69 $noRequest = empty($request) || (empty($request[0]) && empty($request[1]));
65 $filtered = $this->links; 70 if ($noRequest) {
66 if (isset($request[0])) { 71 if ($untaggedonly) {
67 $filtered = $this->filterTags($request[0], $casesensitive, $privateonly); 72 return $this->filterUntagged($visibility);
68 } 73 }
69 if (isset($request[1])) { 74 return $this->noFilter($visibility);
70 $lf = new LinkFilter($filtered);
71 $filtered = $lf->filterFulltext($request[1], $privateonly);
72 }
73 return $filtered;
74 } 75 }
75 return $this->noFilter($privateonly); 76 if ($untaggedonly) {
77 $filtered = $this->filterUntagged($visibility);
78 } else {
79 $filtered = $this->links;
80 }
81 if (!empty($request[0])) {
82 $filtered = (new LinkFilter($filtered))->filterTags($request[0], $casesensitive, $visibility);
83 }
84 if (!empty($request[1])) {
85 $filtered = (new LinkFilter($filtered))->filterFulltext($request[1], $visibility);
86 }
87 return $filtered;
76 case self::$FILTER_TEXT: 88 case self::$FILTER_TEXT:
77 return $this->filterFulltext($request, $privateonly); 89 return $this->filterFulltext($request, $visibility);
78 case self::$FILTER_TAG: 90 case self::$FILTER_TAG:
79 return $this->filterTags($request, $casesensitive, $privateonly); 91 if ($untaggedonly) {
92 return $this->filterUntagged($visibility);
93 } else {
94 return $this->filterTags($request, $casesensitive, $visibility);
95 }
80 case self::$FILTER_DAY: 96 case self::$FILTER_DAY:
81 return $this->filterDay($request); 97 return $this->filterDay($request);
82 default: 98 default:
83 return $this->noFilter($privateonly); 99 return $this->noFilter($visibility);
84 } 100 }
85 } 101 }
86 102
87 /** 103 /**
88 * Unknown filter, but handle private only. 104 * Unknown filter, but handle private only.
89 * 105 *
90 * @param bool $privateonly returns private link only if true. 106 * @param string $visibility Optional: return only all/private/public links
91 * 107 *
92 * @return array filtered links. 108 * @return array filtered links.
93 */ 109 */
94 private function noFilter($privateonly = false) 110 private function noFilter($visibility = 'all')
95 { 111 {
96 if (! $privateonly) { 112 if ($visibility === 'all') {
97 return $this->links; 113 return $this->links;
98 } 114 }
99 115
100 $out = array(); 116 $out = array();
101 foreach ($this->links as $key => $value) { 117 foreach ($this->links as $key => $value) {
102 if ($value['private']) { 118 if ($value['private'] && $visibility === 'private') {
119 $out[$key] = $value;
120 } else if (! $value['private'] && $visibility === 'public') {
103 $out[$key] = $value; 121 $out[$key] = $value;
104 } 122 }
105 } 123 }
@@ -151,14 +169,14 @@ class LinkFilter
151 * - see https://github.com/shaarli/Shaarli/issues/75 for examples 169 * - see https://github.com/shaarli/Shaarli/issues/75 for examples
152 * 170 *
153 * @param string $searchterms search query. 171 * @param string $searchterms search query.
154 * @param bool $privateonly return only private links if true. 172 * @param string $visibility Optional: return only all/private/public links.
155 * 173 *
156 * @return array search results. 174 * @return array search results.
157 */ 175 */
158 private function filterFulltext($searchterms, $privateonly = false) 176 private function filterFulltext($searchterms, $visibility = 'all')
159 { 177 {
160 if (empty($searchterms)) { 178 if (empty($searchterms)) {
161 return $this->links; 179 return $this->noFilter($visibility);
162 } 180 }
163 181
164 $filtered = array(); 182 $filtered = array();
@@ -189,8 +207,12 @@ class LinkFilter
189 foreach ($this->links as $id => $link) { 207 foreach ($this->links as $id => $link) {
190 208
191 // ignore non private links when 'privatonly' is on. 209 // ignore non private links when 'privatonly' is on.
192 if (! $link['private'] && $privateonly === true) { 210 if ($visibility !== 'all') {
193 continue; 211 if (! $link['private'] && $visibility === 'private') {
212 continue;
213 } else if ($link['private'] && $visibility === 'public') {
214 continue;
215 }
194 } 216 }
195 217
196 // Concatenate link fields to search across fields. 218 // Concatenate link fields to search across fields.
@@ -228,6 +250,51 @@ class LinkFilter
228 } 250 }
229 251
230 /** 252 /**
253 * generate a regex fragment out of a tag
254 * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard
255 * @return string generated regex fragment
256 */
257 private static function tag2regex($tag)
258 {
259 $len = strlen($tag);
260 if(!$len || $tag === "-" || $tag === "*"){
261 // nothing to search, return empty regex
262 return '';
263 }
264 if($tag[0] === "-") {
265 // query is negated
266 $i = 1; // use offset to start after '-' character
267 $regex = '(?!'; // create negative lookahead
268 } else {
269 $i = 0; // start at first character
270 $regex = '(?='; // use positive lookahead
271 }
272 $regex .= '.*(?:^| )'; // before tag may only be a space or the beginning
273 // iterate over string, separating it into placeholder and content
274 for(; $i < $len; $i++){
275 if($tag[$i] === '*'){
276 // placeholder found
277 $regex .= '[^ ]*?';
278 } else {
279 // regular characters
280 $offset = strpos($tag, '*', $i);
281 if($offset === false){
282 // no placeholder found, set offset to end of string
283 $offset = $len;
284 }
285 // subtract one, as we want to get before the placeholder or end of string
286 $offset -= 1;
287 // we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
288 $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
289 // move $i on
290 $i = $offset;
291 }
292 }
293 $regex .= '(?:$| ))'; // after the tag may only be a space or the end
294 return $regex;
295 }
296
297 /**
231 * Returns the list of links associated with a given list of tags 298 * Returns the list of links associated with a given list of tags
232 * 299 *
233 * You can specify one or more tags, separated by space or a comma, e.g. 300 * You can specify one or more tags, separated by space or a comma, e.g.
@@ -235,49 +302,94 @@ class LinkFilter
235 * 302 *
236 * @param string $tags list of tags separated by commas or blank spaces. 303 * @param string $tags list of tags separated by commas or blank spaces.
237 * @param bool $casesensitive ignore case if false. 304 * @param bool $casesensitive ignore case if false.
238 * @param bool $privateonly returns private links only. 305 * @param string $visibility Optional: return only all/private/public links.
239 * 306 *
240 * @return array filtered links. 307 * @return array filtered links.
241 */ 308 */
242 public function filterTags($tags, $casesensitive = false, $privateonly = false) 309 public function filterTags($tags, $casesensitive = false, $visibility = 'all')
243 { 310 {
244 // Implode if array for clean up. 311 // get single tags (we may get passed an array, even though the docs say different)
245 $tags = is_array($tags) ? trim(implode(' ', $tags)) : $tags; 312 $inputTags = $tags;
246 if (empty($tags)) { 313 if(!is_array($tags)) {
247 return $this->links; 314 // we got an input string, split tags
315 $inputTags = preg_split('/(?:\s+)|,/', $inputTags, -1, PREG_SPLIT_NO_EMPTY);
248 } 316 }
249 317
250 $searchtags = self::tagsStrToArray($tags, $casesensitive); 318 if(!count($inputTags)){
251 $filtered = array(); 319 // no input tags
252 if (empty($searchtags)) { 320 return $this->noFilter($visibility);
253 return $filtered; 321 }
322
323 // build regex from all tags
324 $re = '/^' . implode(array_map("self::tag2regex", $inputTags)) . '.*$/';
325 if(!$casesensitive) {
326 // make regex case insensitive
327 $re .= 'i';
254 } 328 }
255 329
330 // create resulting array
331 $filtered = array();
332
333 // iterate over each link
256 foreach ($this->links as $key => $link) { 334 foreach ($this->links as $key => $link) {
257 // ignore non private links when 'privatonly' is on. 335 // check level of visibility
258 if (! $link['private'] && $privateonly === true) { 336 // ignore non private links when 'privateonly' is on.
337 if ($visibility !== 'all') {
338 if (! $link['private'] && $visibility === 'private') {
339 continue;
340 } else if ($link['private'] && $visibility === 'public') {
341 continue;
342 }
343 }
344 $search = $link['tags']; // build search string, start with tags of current link
345 if(strlen(trim($link['description'])) && strpos($link['description'], '#') !== false){
346 // description given and at least one possible tag found
347 $descTags = array();
348 // find all tags in the form of #tag in the description
349 preg_match_all(
350 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
351 $link['description'],
352 $descTags
353 );
354 if(count($descTags[1])){
355 // there were some tags in the description, add them to the search string
356 $search .= ' ' . implode(' ', $descTags[1]);
357 }
358 };
359 // match regular expression with search string
360 if(!preg_match($re, $search)){
361 // this entry does _not_ match our regex
259 continue; 362 continue;
260 } 363 }
364 $filtered[$key] = $link;
365 }
366 return $filtered;
367 }
261 368
262 $linktags = self::tagsStrToArray($link['tags'], $casesensitive); 369 /**
263 370 * Return only links without any tag.
264 $found = true; 371 *
265 for ($i = 0 ; $i < count($searchtags) && $found; $i++) { 372 * @param string $visibility return only all/private/public links.
266 // Exclusive search, quit if tag found. 373 *
267 // Or, tag not found in the link, quit. 374 * @return array filtered links.
268 if (($searchtags[$i][0] == '-' 375 */
269 && $this->searchTagAndHashTag(substr($searchtags[$i], 1), $linktags, $link['description'])) 376 public function filterUntagged($visibility)
270 || ($searchtags[$i][0] != '-') 377 {
271 && ! $this->searchTagAndHashTag($searchtags[$i], $linktags, $link['description']) 378 $filtered = [];
272 ) { 379 foreach ($this->links as $key => $link) {
273 $found = false; 380 if ($visibility !== 'all') {
381 if (! $link['private'] && $visibility === 'private') {
382 continue;
383 } else if ($link['private'] && $visibility === 'public') {
384 continue;
274 } 385 }
275 } 386 }
276 387
277 if ($found) { 388 if (empty(trim($link['tags']))) {
278 $filtered[$key] = $link; 389 $filtered[$key] = $link;
279 } 390 }
280 } 391 }
392
281 return $filtered; 393 return $filtered;
282 } 394 }
283 395
@@ -311,28 +423,6 @@ class LinkFilter
311 } 423 }
312 424
313 /** 425 /**
314 * Check if a tag is found in the taglist, or as an hashtag in the link description.
315 *
316 * @param string $tag Tag to search.
317 * @param array $taglist List of tags for the current link.
318 * @param string $description Link description.
319 *
320 * @return bool True if found, false otherwise.
321 */
322 protected function searchTagAndHashTag($tag, $taglist, $description)
323 {
324 if (in_array($tag, $taglist)) {
325 return true;
326 }
327
328 if (preg_match('/(^| )#'. $tag .'([^'. self::$HASHTAG_CHARS .']|$)/mui', $description) > 0) {
329 return true;
330 }
331
332 return false;
333 }
334
335 /**
336 * Convert a list of tags (str) to an array. Also 426 * Convert a list of tags (str) to an array. Also
337 * - handle case sensitivity. 427 * - handle case sensitivity.
338 * - accepts spaces commas as separator. 428 * - accepts spaces commas as separator.
@@ -341,18 +431,24 @@ class LinkFilter
341 * @param bool $casesensitive will convert everything to lowercase if false. 431 * @param bool $casesensitive will convert everything to lowercase if false.
342 * 432 *
343 * @return array filtered tags string. 433 * @return array filtered tags string.
344 */ 434 */
345 public static function tagsStrToArray($tags, $casesensitive) 435 public static function tagsStrToArray($tags, $casesensitive)
346 { 436 {
347 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek) 437 // We use UTF-8 conversion to handle various graphemes (i.e. cyrillic, or greek)
348 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8'); 438 $tagsOut = $casesensitive ? $tags : mb_convert_case($tags, MB_CASE_LOWER, 'UTF-8');
349 $tagsOut = str_replace(',', ' ', $tagsOut); 439 $tagsOut = str_replace(',', ' ', $tagsOut);
350 440
351 return array_values(array_filter(explode(' ', trim($tagsOut)), 'strlen')); 441 return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY);
352 } 442 }
353} 443}
354 444
355class LinkNotFoundException extends Exception 445class LinkNotFoundException extends Exception
356{ 446{
357 protected $message = 'The link you are trying to reach does not exist or has been deleted.'; 447 /**
448 * LinkNotFoundException constructor.
449 */
450 public function __construct()
451 {
452 $this->message = t('The link you are trying to reach does not exist or has been deleted.');
453 }
358} 454}