aboutsummaryrefslogtreecommitdiffhomepage
path: root/application
diff options
context:
space:
mode:
authorArthurHoaro <arthur@hoa.ro>2021-02-04 11:11:33 +0100
committerGitHub <noreply@github.com>2021-02-04 11:11:33 +0100
commit9db1ccdf2ce8381e1f3acb581d3c6a6def095d8c (patch)
tree97a618b77d327a5f963c91522988e24db5a9e158 /application
parent8997ae6c8e24286f7d47981eaf905e80d2481c10 (diff)
parentbcba6bd353161fab456b423e93571ab027d5423c (diff)
downloadShaarli-master.tar.gz
Shaarli-master.tar.zst
Shaarli-master.zip
Merge pull request #1698 from ArthurHoaro/feature/plugins-search-filterHEADmaster
New plugin hook: ability to add custom filters to Shaarli search engine
Diffstat (limited to 'application')
-rw-r--r--application/api/ApiMiddleware.php1
-rw-r--r--application/bookmark/BookmarkFileService.php8
-rw-r--r--application/bookmark/BookmarkFilter.php246
-rw-r--r--application/container/ContainerBuilder.php1
-rw-r--r--application/plugin/PluginManager.php56
5 files changed, 193 insertions, 119 deletions
diff --git a/application/api/ApiMiddleware.php b/application/api/ApiMiddleware.php
index 9fb88358..cc7af18e 100644
--- a/application/api/ApiMiddleware.php
+++ b/application/api/ApiMiddleware.php
@@ -145,6 +145,7 @@ class ApiMiddleware
145 { 145 {
146 $linkDb = new BookmarkFileService( 146 $linkDb = new BookmarkFileService(
147 $conf, 147 $conf,
148 $this->container->get('pluginManager'),
148 $this->container->get('history'), 149 $this->container->get('history'),
149 new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), 150 new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
150 true 151 true
diff --git a/application/bookmark/BookmarkFileService.php b/application/bookmark/BookmarkFileService.php
index 8ea37427..e64eeafb 100644
--- a/application/bookmark/BookmarkFileService.php
+++ b/application/bookmark/BookmarkFileService.php
@@ -15,6 +15,7 @@ use Shaarli\Formatter\BookmarkMarkdownFormatter;
15use Shaarli\History; 15use Shaarli\History;
16use Shaarli\Legacy\LegacyLinkDB; 16use Shaarli\Legacy\LegacyLinkDB;
17use Shaarli\Legacy\LegacyUpdater; 17use Shaarli\Legacy\LegacyUpdater;
18use Shaarli\Plugin\PluginManager;
18use Shaarli\Render\PageCacheManager; 19use Shaarli\Render\PageCacheManager;
19use Shaarli\Updater\UpdaterUtils; 20use Shaarli\Updater\UpdaterUtils;
20 21
@@ -40,6 +41,9 @@ class BookmarkFileService implements BookmarkServiceInterface
40 /** @var ConfigManager instance */ 41 /** @var ConfigManager instance */
41 protected $conf; 42 protected $conf;
42 43
44 /** @var PluginManager */
45 protected $pluginManager;
46
43 /** @var History instance */ 47 /** @var History instance */
44 protected $history; 48 protected $history;
45 49
@@ -57,6 +61,7 @@ class BookmarkFileService implements BookmarkServiceInterface
57 */ 61 */
58 public function __construct( 62 public function __construct(
59 ConfigManager $conf, 63 ConfigManager $conf,
64 PluginManager $pluginManager,
60 History $history, 65 History $history,
61 Mutex $mutex, 66 Mutex $mutex,
62 bool $isLoggedIn 67 bool $isLoggedIn
@@ -95,7 +100,8 @@ class BookmarkFileService implements BookmarkServiceInterface
95 } 100 }
96 } 101 }
97 102
98 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf); 103 $this->pluginManager = $pluginManager;
104 $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf, $this->pluginManager);
99 } 105 }
100 106
101 /** 107 /**
diff --git a/application/bookmark/BookmarkFilter.php b/application/bookmark/BookmarkFilter.php
index db83c51c..8b41dbb8 100644
--- a/application/bookmark/BookmarkFilter.php
+++ b/application/bookmark/BookmarkFilter.php
@@ -4,9 +4,9 @@ declare(strict_types=1);
4 4
5namespace Shaarli\Bookmark; 5namespace Shaarli\Bookmark;
6 6
7use Exception;
8use Shaarli\Bookmark\Exception\BookmarkNotFoundException; 7use Shaarli\Bookmark\Exception\BookmarkNotFoundException;
9use Shaarli\Config\ConfigManager; 8use Shaarli\Config\ConfigManager;
9use Shaarli\Plugin\PluginManager;
10 10
11/** 11/**
12 * Class LinkFilter. 12 * Class LinkFilter.
@@ -33,11 +33,6 @@ class BookmarkFilter
33 /** 33 /**
34 * @var string filter by day. 34 * @var string filter by day.
35 */ 35 */
36 public static $FILTER_DAY = 'FILTER_DAY';
37
38 /**
39 * @var string filter by day.
40 */
41 public static $DEFAULT = 'NO_FILTER'; 36 public static $DEFAULT = 'NO_FILTER';
42 37
43 /** @var string Visibility: all */ 38 /** @var string Visibility: all */
@@ -62,13 +57,17 @@ class BookmarkFilter
62 /** @var ConfigManager */ 57 /** @var ConfigManager */
63 protected $conf; 58 protected $conf;
64 59
60 /** @var PluginManager */
61 protected $pluginManager;
62
65 /** 63 /**
66 * @param Bookmark[] $bookmarks initialization. 64 * @param Bookmark[] $bookmarks initialization.
67 */ 65 */
68 public function __construct($bookmarks, ConfigManager $conf) 66 public function __construct($bookmarks, ConfigManager $conf, PluginManager $pluginManager)
69 { 67 {
70 $this->bookmarks = $bookmarks; 68 $this->bookmarks = $bookmarks;
71 $this->conf = $conf; 69 $this->conf = $conf;
70 $this->pluginManager = $pluginManager;
72 } 71 }
73 72
74 /** 73 /**
@@ -112,12 +111,12 @@ class BookmarkFilter
112 $filtered = $this->bookmarks; 111 $filtered = $this->bookmarks;
113 } 112 }
114 if (!empty($request[0])) { 113 if (!empty($request[0])) {
115 $filtered = (new BookmarkFilter($filtered, $this->conf)) 114 $filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
116 ->filterTags($request[0], $casesensitive, $visibility) 115 ->filterTags($request[0], $casesensitive, $visibility)
117 ; 116 ;
118 } 117 }
119 if (!empty($request[1])) { 118 if (!empty($request[1])) {
120 $filtered = (new BookmarkFilter($filtered, $this->conf)) 119 $filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager))
121 ->filterFulltext($request[1], $visibility) 120 ->filterFulltext($request[1], $visibility)
122 ; 121 ;
123 } 122 }
@@ -130,8 +129,6 @@ class BookmarkFilter
130 } else { 129 } else {
131 return $this->filterTags($request, $casesensitive, $visibility); 130 return $this->filterTags($request, $casesensitive, $visibility);
132 } 131 }
133 case self::$FILTER_DAY:
134 return $this->filterDay($request, $visibility);
135 default: 132 default:
136 return $this->noFilter($visibility); 133 return $this->noFilter($visibility);
137 } 134 }
@@ -146,13 +143,20 @@ class BookmarkFilter
146 */ 143 */
147 private function noFilter(string $visibility = 'all') 144 private function noFilter(string $visibility = 'all')
148 { 145 {
149 if ($visibility === 'all') {
150 return $this->bookmarks;
151 }
152
153 $out = []; 146 $out = [];
154 foreach ($this->bookmarks as $key => $value) { 147 foreach ($this->bookmarks as $key => $value) {
155 if ($value->isPrivate() && $visibility === 'private') { 148 if (
149 !$this->pluginManager->filterSearchEntry(
150 $value,
151 ['source' => 'no_filter', 'visibility' => $visibility]
152 )
153 ) {
154 continue;
155 }
156
157 if ($visibility === 'all') {
158 $out[$key] = $value;
159 } elseif ($value->isPrivate() && $visibility === 'private') {
156 $out[$key] = $value; 160 $out[$key] = $value;
157 } elseif (!$value->isPrivate() && $visibility === 'public') { 161 } elseif (!$value->isPrivate() && $visibility === 'public') {
158 $out[$key] = $value; 162 $out[$key] = $value;
@@ -233,18 +237,34 @@ class BookmarkFilter
233 } 237 }
234 238
235 // Iterate over every stored link. 239 // Iterate over every stored link.
236 foreach ($this->bookmarks as $id => $link) { 240 foreach ($this->bookmarks as $id => $bookmark) {
241 if (
242 !$this->pluginManager->filterSearchEntry(
243 $bookmark,
244 [
245 'source' => 'fulltext',
246 'searchterms' => $searchterms,
247 'andSearch' => $andSearch,
248 'exactSearch' => $exactSearch,
249 'excludeSearch' => $excludeSearch,
250 'visibility' => $visibility
251 ]
252 )
253 ) {
254 continue;
255 }
256
237 // ignore non private bookmarks when 'privatonly' is on. 257 // ignore non private bookmarks when 'privatonly' is on.
238 if ($visibility !== 'all') { 258 if ($visibility !== 'all') {
239 if (!$link->isPrivate() && $visibility === 'private') { 259 if (!$bookmark->isPrivate() && $visibility === 'private') {
240 continue; 260 continue;
241 } elseif ($link->isPrivate() && $visibility === 'public') { 261 } elseif ($bookmark->isPrivate() && $visibility === 'public') {
242 continue; 262 continue;
243 } 263 }
244 } 264 }
245 265
246 $lengths = []; 266 $lengths = [];
247 $content = $this->buildFullTextSearchableLink($link, $lengths); 267 $content = $this->buildFullTextSearchableLink($bookmark, $lengths);
248 268
249 // Be optimistic 269 // Be optimistic
250 $found = true; 270 $found = true;
@@ -270,12 +290,12 @@ class BookmarkFilter
270 } 290 }
271 291
272 if ($found !== false) { 292 if ($found !== false) {
273 $link->addAdditionalContentEntry( 293 $bookmark->addAdditionalContentEntry(
274 'search_highlight', 294 'search_highlight',
275 $this->postProcessFoundPositions($lengths, $foundPositions) 295 $this->postProcessFoundPositions($lengths, $foundPositions)
276 ); 296 );
277 297
278 $filtered[$id] = $link; 298 $filtered[$id] = $bookmark;
279 } 299 }
280 } 300 }
281 301
@@ -283,56 +303,6 @@ class BookmarkFilter
283 } 303 }
284 304
285 /** 305 /**
286 * generate a regex fragment out of a tag
287 *
288 * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard
289 *
290 * @return string generated regex fragment
291 */
292 protected function tag2regex(string $tag): string
293 {
294 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
295 $len = strlen($tag);
296 if (!$len || $tag === "-" || $tag === "*") {
297 // nothing to search, return empty regex
298 return '';
299 }
300 if ($tag[0] === "-") {
301 // query is negated
302 $i = 1; // use offset to start after '-' character
303 $regex = '(?!'; // create negative lookahead
304 } else {
305 $i = 0; // start at first character
306 $regex = '(?='; // use positive lookahead
307 }
308 // before tag may only be the separator or the beginning
309 $regex .= '.*(?:^|' . $tagsSeparator . ')';
310 // iterate over string, separating it into placeholder and content
311 for (; $i < $len; $i++) {
312 if ($tag[$i] === '*') {
313 // placeholder found
314 $regex .= '[^' . $tagsSeparator . ']*?';
315 } else {
316 // regular characters
317 $offset = strpos($tag, '*', $i);
318 if ($offset === false) {
319 // no placeholder found, set offset to end of string
320 $offset = $len;
321 }
322 // subtract one, as we want to get before the placeholder or end of string
323 $offset -= 1;
324 // we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
325 $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
326 // move $i on
327 $i = $offset;
328 }
329 }
330 // after the tag may only be the separator or the end
331 $regex .= '(?:$|' . $tagsSeparator . '))';
332 return $regex;
333 }
334
335 /**
336 * Returns the list of bookmarks associated with a given list of tags 306 * Returns the list of bookmarks associated with a given list of tags
337 * 307 *
338 * You can specify one or more tags, separated by space or a comma, e.g. 308 * You can specify one or more tags, separated by space or a comma, e.g.
@@ -381,25 +351,39 @@ class BookmarkFilter
381 $filtered = []; 351 $filtered = [];
382 352
383 // iterate over each link 353 // iterate over each link
384 foreach ($this->bookmarks as $key => $link) { 354 foreach ($this->bookmarks as $key => $bookmark) {
355 if (
356 !$this->pluginManager->filterSearchEntry(
357 $bookmark,
358 [
359 'source' => 'tags',
360 'tags' => $tags,
361 'casesensitive' => $casesensitive,
362 'visibility' => $visibility
363 ]
364 )
365 ) {
366 continue;
367 }
368
385 // check level of visibility 369 // check level of visibility
386 // ignore non private bookmarks when 'privateonly' is on. 370 // ignore non private bookmarks when 'privateonly' is on.
387 if ($visibility !== 'all') { 371 if ($visibility !== 'all') {
388 if (!$link->isPrivate() && $visibility === 'private') { 372 if (!$bookmark->isPrivate() && $visibility === 'private') {
389 continue; 373 continue;
390 } elseif ($link->isPrivate() && $visibility === 'public') { 374 } elseif ($bookmark->isPrivate() && $visibility === 'public') {
391 continue; 375 continue;
392 } 376 }
393 } 377 }
394 // build search string, start with tags of current link 378 // build search string, start with tags of current link
395 $search = $link->getTagsString($tagsSeparator); 379 $search = $bookmark->getTagsString($tagsSeparator);
396 if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { 380 if (strlen(trim($bookmark->getDescription())) && strpos($bookmark->getDescription(), '#') !== false) {
397 // description given and at least one possible tag found 381 // description given and at least one possible tag found
398 $descTags = []; 382 $descTags = [];
399 // find all tags in the form of #tag in the description 383 // find all tags in the form of #tag in the description
400 preg_match_all( 384 preg_match_all(
401 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm', 385 '/(?<![' . self::$HASHTAG_CHARS . '])#([' . self::$HASHTAG_CHARS . ']+?)\b/sm',
402 $link->getDescription(), 386 $bookmark->getDescription(),
403 $descTags 387 $descTags
404 ); 388 );
405 if (count($descTags[1])) { 389 if (count($descTags[1])) {
@@ -412,8 +396,9 @@ class BookmarkFilter
412 // this entry does _not_ match our regex 396 // this entry does _not_ match our regex
413 continue; 397 continue;
414 } 398 }
415 $filtered[$key] = $link; 399 $filtered[$key] = $bookmark;
416 } 400 }
401
417 return $filtered; 402 return $filtered;
418 } 403 }
419 404
@@ -427,55 +412,30 @@ class BookmarkFilter
427 public function filterUntagged(string $visibility) 412 public function filterUntagged(string $visibility)
428 { 413 {
429 $filtered = []; 414 $filtered = [];
430 foreach ($this->bookmarks as $key => $link) { 415 foreach ($this->bookmarks as $key => $bookmark) {
416 if (
417 !$this->pluginManager->filterSearchEntry(
418 $bookmark,
419 ['source' => 'untagged', 'visibility' => $visibility]
420 )
421 ) {
422 continue;
423 }
424
431 if ($visibility !== 'all') { 425 if ($visibility !== 'all') {
432 if (!$link->isPrivate() && $visibility === 'private') { 426 if (!$bookmark->isPrivate() && $visibility === 'private') {
433 continue; 427 continue;
434 } elseif ($link->isPrivate() && $visibility === 'public') { 428 } elseif ($bookmark->isPrivate() && $visibility === 'public') {
435 continue; 429 continue;
436 } 430 }
437 } 431 }
438 432
439 if (empty($link->getTags())) { 433 if (empty($bookmark->getTags())) {
440 $filtered[$key] = $link;
441 }
442 }
443
444 return $filtered;
445 }
446
447 /**
448 * Returns the list of articles for a given day, chronologically sorted
449 *
450 * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g.
451 * print_r($mydb->filterDay('20120125'));
452 *
453 * @param string $day day to filter.
454 * @param string $visibility return only all/private/public bookmarks.
455
456 * @return Bookmark[] all link matching given day.
457 *
458 * @throws Exception if date format is invalid.
459 */
460 public function filterDay(string $day, string $visibility)
461 {
462 if (!checkDateFormat('Ymd', $day)) {
463 throw new Exception('Invalid date format');
464 }
465
466 $filtered = [];
467 foreach ($this->bookmarks as $key => $bookmark) {
468 if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) {
469 continue;
470 }
471
472 if ($bookmark->getCreated()->format('Ymd') == $day) {
473 $filtered[$key] = $bookmark; 434 $filtered[$key] = $bookmark;
474 } 435 }
475 } 436 }
476 437
477 // sort by date ASC 438 return $filtered;
478 return array_reverse($filtered, true);
479 } 439 }
480 440
481 /** 441 /**
@@ -498,6 +458,56 @@ class BookmarkFilter
498 } 458 }
499 459
500 /** 460 /**
461 * generate a regex fragment out of a tag
462 *
463 * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard
464 *
465 * @return string generated regex fragment
466 */
467 protected function tag2regex(string $tag): string
468 {
469 $tagsSeparator = $this->conf->get('general.tags_separator', ' ');
470 $len = strlen($tag);
471 if (!$len || $tag === "-" || $tag === "*") {
472 // nothing to search, return empty regex
473 return '';
474 }
475 if ($tag[0] === "-") {
476 // query is negated
477 $i = 1; // use offset to start after '-' character
478 $regex = '(?!'; // create negative lookahead
479 } else {
480 $i = 0; // start at first character
481 $regex = '(?='; // use positive lookahead
482 }
483 // before tag may only be the separator or the beginning
484 $regex .= '.*(?:^|' . $tagsSeparator . ')';
485 // iterate over string, separating it into placeholder and content
486 for (; $i < $len; $i++) {
487 if ($tag[$i] === '*') {
488 // placeholder found
489 $regex .= '[^' . $tagsSeparator . ']*?';
490 } else {
491 // regular characters
492 $offset = strpos($tag, '*', $i);
493 if ($offset === false) {
494 // no placeholder found, set offset to end of string
495 $offset = $len;
496 }
497 // subtract one, as we want to get before the placeholder or end of string
498 $offset -= 1;
499 // we got a tag name that we want to search for. escape any regex characters to prevent conflicts.
500 $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/');
501 // move $i on
502 $i = $offset;
503 }
504 }
505 // after the tag may only be the separator or the end
506 $regex .= '(?:$|' . $tagsSeparator . '))';
507 return $regex;
508 }
509
510 /**
501 * This method finalize the content of the foundPositions array, 511 * This method finalize the content of the foundPositions array,
502 * by associated all search results to their associated bookmark field, 512 * by associated all search results to their associated bookmark field,
503 * making sure that there is no overlapping results, etc. 513 * making sure that there is no overlapping results, etc.
diff --git a/application/container/ContainerBuilder.php b/application/container/ContainerBuilder.php
index 6d69a880..f66d75bd 100644
--- a/application/container/ContainerBuilder.php
+++ b/application/container/ContainerBuilder.php
@@ -95,6 +95,7 @@ class ContainerBuilder
95 $container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface { 95 $container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface {
96 return new BookmarkFileService( 96 return new BookmarkFileService(
97 $container->conf, 97 $container->conf,
98 $container->pluginManager,
98 $container->history, 99 $container->history,
99 new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), 100 new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2),
100 $container->loginManager->isLoggedIn() 101 $container->loginManager->isLoggedIn()
diff --git a/application/plugin/PluginManager.php b/application/plugin/PluginManager.php
index 7fc0cb04..939db1ea 100644
--- a/application/plugin/PluginManager.php
+++ b/application/plugin/PluginManager.php
@@ -2,6 +2,7 @@
2 2
3namespace Shaarli\Plugin; 3namespace Shaarli\Plugin;
4 4
5use Shaarli\Bookmark\Bookmark;
5use Shaarli\Config\ConfigManager; 6use Shaarli\Config\ConfigManager;
6use Shaarli\Plugin\Exception\PluginFileNotFoundException; 7use Shaarli\Plugin\Exception\PluginFileNotFoundException;
7use Shaarli\Plugin\Exception\PluginInvalidRouteException; 8use Shaarli\Plugin\Exception\PluginInvalidRouteException;
@@ -45,6 +46,9 @@ class PluginManager
45 */ 46 */
46 protected $errors; 47 protected $errors;
47 48
49 /** @var callable[]|null Preloaded list of hook function for filterSearchEntry() */
50 protected $filterSearchEntryHooks = null;
51
48 /** 52 /**
49 * Plugins subdirectory. 53 * Plugins subdirectory.
50 * 54 *
@@ -274,6 +278,14 @@ class PluginManager
274 } 278 }
275 279
276 /** 280 /**
281 * @return array List of registered filter_search_entry hooks
282 */
283 public function getFilterSearchEntryHooks(): ?array
284 {
285 return $this->filterSearchEntryHooks;
286 }
287
288 /**
277 * Return the list of encountered errors. 289 * Return the list of encountered errors.
278 * 290 *
279 * @return array List of errors (empty array if none exists). 291 * @return array List of errors (empty array if none exists).
@@ -284,6 +296,50 @@ class PluginManager
284 } 296 }
285 297
286 /** 298 /**
299 * Apply additional filter on every search result of BookmarkFilter calling plugins hooks.
300 *
301 * @param Bookmark $bookmark To check.
302 * @param array $context Additional info about search context, depends on the search source.
303 *
304 * @return bool True if the result must be kept in search results, false otherwise.
305 */
306 public function filterSearchEntry(Bookmark $bookmark, array $context): bool
307 {
308 if ($this->filterSearchEntryHooks === null) {
309 $this->loadFilterSearchEntryHooks();
310 }
311
312 if ($this->filterSearchEntryHooks === []) {
313 return true;
314 }
315
316 foreach ($this->filterSearchEntryHooks as $filterSearchEntryHook) {
317 if ($filterSearchEntryHook($bookmark, $context) === false) {
318 return false;
319 }
320 }
321
322 return true;
323 }
324
325 /**
326 * filterSearchEntry() method will be called for every search result,
327 * so for performances we preload existing functions to invoke them directly.
328 */
329 protected function loadFilterSearchEntryHooks(): void
330 {
331 $this->filterSearchEntryHooks = [];
332
333 foreach ($this->loadedPlugins as $plugin) {
334 $hookFunction = $this->buildHookName('filter_search_entry', $plugin);
335
336 if (function_exists($hookFunction)) {
337 $this->filterSearchEntryHooks[] = $hookFunction;
338 }
339 }
340 }
341
342 /**
287 * Checks whether provided input is valid to register a new route. 343 * Checks whether provided input is valid to register a new route.
288 * It must contain keys `method`, `route`, `callable` (all strings). 344 * It must contain keys `method`, `route`, `callable` (all strings).
289 * 345 *