diff options
Diffstat (limited to 'application/LinkFilter.php')
-rw-r--r-- | application/LinkFilter.php | 246 |
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 | ||
355 | class LinkNotFoundException extends Exception | 445 | class 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 | } |