From bcba6bd353161fab456b423e93571ab027d5423c Mon Sep 17 00:00:00 2001 From: ArthurHoaro Date: Wed, 20 Jan 2021 15:59:00 +0100 Subject: New plugin hook: ability to add custom filters to Shaarli search engine A new plugin hook has been added: hook_test_filter_search_entry This hook allows to filter out bookmark with custom plugin code when a search is performed. Related to #143 --- application/api/ApiMiddleware.php | 1 + application/bookmark/BookmarkFileService.php | 8 +- application/bookmark/BookmarkFilter.php | 246 ++++++++++++++------------- application/container/ContainerBuilder.php | 1 + application/plugin/PluginManager.php | 56 ++++++ 5 files changed, 193 insertions(+), 119 deletions(-) (limited to 'application') 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 { $linkDb = new BookmarkFileService( $conf, + $this->container->get('pluginManager'), $this->container->get('history'), new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), 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; use Shaarli\History; use Shaarli\Legacy\LegacyLinkDB; use Shaarli\Legacy\LegacyUpdater; +use Shaarli\Plugin\PluginManager; use Shaarli\Render\PageCacheManager; use Shaarli\Updater\UpdaterUtils; @@ -40,6 +41,9 @@ class BookmarkFileService implements BookmarkServiceInterface /** @var ConfigManager instance */ protected $conf; + /** @var PluginManager */ + protected $pluginManager; + /** @var History instance */ protected $history; @@ -57,6 +61,7 @@ class BookmarkFileService implements BookmarkServiceInterface */ public function __construct( ConfigManager $conf, + PluginManager $pluginManager, History $history, Mutex $mutex, bool $isLoggedIn @@ -95,7 +100,8 @@ class BookmarkFileService implements BookmarkServiceInterface } } - $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf); + $this->pluginManager = $pluginManager; + $this->bookmarkFilter = new BookmarkFilter($this->bookmarks, $this->conf, $this->pluginManager); } /** 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); namespace Shaarli\Bookmark; -use Exception; use Shaarli\Bookmark\Exception\BookmarkNotFoundException; use Shaarli\Config\ConfigManager; +use Shaarli\Plugin\PluginManager; /** * Class LinkFilter. @@ -30,11 +30,6 @@ class BookmarkFilter */ public static $FILTER_TAG = 'tags'; - /** - * @var string filter by day. - */ - public static $FILTER_DAY = 'FILTER_DAY'; - /** * @var string filter by day. */ @@ -62,13 +57,17 @@ class BookmarkFilter /** @var ConfigManager */ protected $conf; + /** @var PluginManager */ + protected $pluginManager; + /** * @param Bookmark[] $bookmarks initialization. */ - public function __construct($bookmarks, ConfigManager $conf) + public function __construct($bookmarks, ConfigManager $conf, PluginManager $pluginManager) { $this->bookmarks = $bookmarks; $this->conf = $conf; + $this->pluginManager = $pluginManager; } /** @@ -112,12 +111,12 @@ class BookmarkFilter $filtered = $this->bookmarks; } if (!empty($request[0])) { - $filtered = (new BookmarkFilter($filtered, $this->conf)) + $filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager)) ->filterTags($request[0], $casesensitive, $visibility) ; } if (!empty($request[1])) { - $filtered = (new BookmarkFilter($filtered, $this->conf)) + $filtered = (new BookmarkFilter($filtered, $this->conf, $this->pluginManager)) ->filterFulltext($request[1], $visibility) ; } @@ -130,8 +129,6 @@ class BookmarkFilter } else { return $this->filterTags($request, $casesensitive, $visibility); } - case self::$FILTER_DAY: - return $this->filterDay($request, $visibility); default: return $this->noFilter($visibility); } @@ -146,13 +143,20 @@ class BookmarkFilter */ private function noFilter(string $visibility = 'all') { - if ($visibility === 'all') { - return $this->bookmarks; - } - $out = []; foreach ($this->bookmarks as $key => $value) { - if ($value->isPrivate() && $visibility === 'private') { + if ( + !$this->pluginManager->filterSearchEntry( + $value, + ['source' => 'no_filter', 'visibility' => $visibility] + ) + ) { + continue; + } + + if ($visibility === 'all') { + $out[$key] = $value; + } elseif ($value->isPrivate() && $visibility === 'private') { $out[$key] = $value; } elseif (!$value->isPrivate() && $visibility === 'public') { $out[$key] = $value; @@ -233,18 +237,34 @@ class BookmarkFilter } // Iterate over every stored link. - foreach ($this->bookmarks as $id => $link) { + foreach ($this->bookmarks as $id => $bookmark) { + if ( + !$this->pluginManager->filterSearchEntry( + $bookmark, + [ + 'source' => 'fulltext', + 'searchterms' => $searchterms, + 'andSearch' => $andSearch, + 'exactSearch' => $exactSearch, + 'excludeSearch' => $excludeSearch, + 'visibility' => $visibility + ] + ) + ) { + continue; + } + // ignore non private bookmarks when 'privatonly' is on. if ($visibility !== 'all') { - if (!$link->isPrivate() && $visibility === 'private') { + if (!$bookmark->isPrivate() && $visibility === 'private') { continue; - } elseif ($link->isPrivate() && $visibility === 'public') { + } elseif ($bookmark->isPrivate() && $visibility === 'public') { continue; } } $lengths = []; - $content = $this->buildFullTextSearchableLink($link, $lengths); + $content = $this->buildFullTextSearchableLink($bookmark, $lengths); // Be optimistic $found = true; @@ -270,68 +290,18 @@ class BookmarkFilter } if ($found !== false) { - $link->addAdditionalContentEntry( + $bookmark->addAdditionalContentEntry( 'search_highlight', $this->postProcessFoundPositions($lengths, $foundPositions) ); - $filtered[$id] = $link; + $filtered[$id] = $bookmark; } } return $filtered; } - /** - * generate a regex fragment out of a tag - * - * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard - * - * @return string generated regex fragment - */ - protected function tag2regex(string $tag): string - { - $tagsSeparator = $this->conf->get('general.tags_separator', ' '); - $len = strlen($tag); - if (!$len || $tag === "-" || $tag === "*") { - // nothing to search, return empty regex - return ''; - } - if ($tag[0] === "-") { - // query is negated - $i = 1; // use offset to start after '-' character - $regex = '(?!'; // create negative lookahead - } else { - $i = 0; // start at first character - $regex = '(?='; // use positive lookahead - } - // before tag may only be the separator or the beginning - $regex .= '.*(?:^|' . $tagsSeparator . ')'; - // iterate over string, separating it into placeholder and content - for (; $i < $len; $i++) { - if ($tag[$i] === '*') { - // placeholder found - $regex .= '[^' . $tagsSeparator . ']*?'; - } else { - // regular characters - $offset = strpos($tag, '*', $i); - if ($offset === false) { - // no placeholder found, set offset to end of string - $offset = $len; - } - // subtract one, as we want to get before the placeholder or end of string - $offset -= 1; - // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. - $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); - // move $i on - $i = $offset; - } - } - // after the tag may only be the separator or the end - $regex .= '(?:$|' . $tagsSeparator . '))'; - return $regex; - } - /** * Returns the list of bookmarks associated with a given list of tags * @@ -381,25 +351,39 @@ class BookmarkFilter $filtered = []; // iterate over each link - foreach ($this->bookmarks as $key => $link) { + foreach ($this->bookmarks as $key => $bookmark) { + if ( + !$this->pluginManager->filterSearchEntry( + $bookmark, + [ + 'source' => 'tags', + 'tags' => $tags, + 'casesensitive' => $casesensitive, + 'visibility' => $visibility + ] + ) + ) { + continue; + } + // check level of visibility // ignore non private bookmarks when 'privateonly' is on. if ($visibility !== 'all') { - if (!$link->isPrivate() && $visibility === 'private') { + if (!$bookmark->isPrivate() && $visibility === 'private') { continue; - } elseif ($link->isPrivate() && $visibility === 'public') { + } elseif ($bookmark->isPrivate() && $visibility === 'public') { continue; } } // build search string, start with tags of current link - $search = $link->getTagsString($tagsSeparator); - if (strlen(trim($link->getDescription())) && strpos($link->getDescription(), '#') !== false) { + $search = $bookmark->getTagsString($tagsSeparator); + if (strlen(trim($bookmark->getDescription())) && strpos($bookmark->getDescription(), '#') !== false) { // description given and at least one possible tag found $descTags = []; // find all tags in the form of #tag in the description preg_match_all( '/(?getDescription(), + $bookmark->getDescription(), $descTags ); if (count($descTags[1])) { @@ -412,8 +396,9 @@ class BookmarkFilter // this entry does _not_ match our regex continue; } - $filtered[$key] = $link; + $filtered[$key] = $bookmark; } + return $filtered; } @@ -427,55 +412,30 @@ class BookmarkFilter public function filterUntagged(string $visibility) { $filtered = []; - foreach ($this->bookmarks as $key => $link) { + foreach ($this->bookmarks as $key => $bookmark) { + if ( + !$this->pluginManager->filterSearchEntry( + $bookmark, + ['source' => 'untagged', 'visibility' => $visibility] + ) + ) { + continue; + } + if ($visibility !== 'all') { - if (!$link->isPrivate() && $visibility === 'private') { + if (!$bookmark->isPrivate() && $visibility === 'private') { continue; - } elseif ($link->isPrivate() && $visibility === 'public') { + } elseif ($bookmark->isPrivate() && $visibility === 'public') { continue; } } - if (empty($link->getTags())) { - $filtered[$key] = $link; - } - } - - return $filtered; - } - - /** - * Returns the list of articles for a given day, chronologically sorted - * - * Day must be in the form 'YYYYMMDD' (e.g. '20120125'), e.g. - * print_r($mydb->filterDay('20120125')); - * - * @param string $day day to filter. - * @param string $visibility return only all/private/public bookmarks. - - * @return Bookmark[] all link matching given day. - * - * @throws Exception if date format is invalid. - */ - public function filterDay(string $day, string $visibility) - { - if (!checkDateFormat('Ymd', $day)) { - throw new Exception('Invalid date format'); - } - - $filtered = []; - foreach ($this->bookmarks as $key => $bookmark) { - if ($visibility === static::$PUBLIC && $bookmark->isPrivate()) { - continue; - } - - if ($bookmark->getCreated()->format('Ymd') == $day) { + if (empty($bookmark->getTags())) { $filtered[$key] = $bookmark; } } - // sort by date ASC - return array_reverse($filtered, true); + return $filtered; } /** @@ -497,6 +457,56 @@ class BookmarkFilter return preg_split('/\s+/', $tagsOut, -1, PREG_SPLIT_NO_EMPTY); } + /** + * generate a regex fragment out of a tag + * + * @param string $tag to to generate regexs from. may start with '-' to negate, contain '*' as wildcard + * + * @return string generated regex fragment + */ + protected function tag2regex(string $tag): string + { + $tagsSeparator = $this->conf->get('general.tags_separator', ' '); + $len = strlen($tag); + if (!$len || $tag === "-" || $tag === "*") { + // nothing to search, return empty regex + return ''; + } + if ($tag[0] === "-") { + // query is negated + $i = 1; // use offset to start after '-' character + $regex = '(?!'; // create negative lookahead + } else { + $i = 0; // start at first character + $regex = '(?='; // use positive lookahead + } + // before tag may only be the separator or the beginning + $regex .= '.*(?:^|' . $tagsSeparator . ')'; + // iterate over string, separating it into placeholder and content + for (; $i < $len; $i++) { + if ($tag[$i] === '*') { + // placeholder found + $regex .= '[^' . $tagsSeparator . ']*?'; + } else { + // regular characters + $offset = strpos($tag, '*', $i); + if ($offset === false) { + // no placeholder found, set offset to end of string + $offset = $len; + } + // subtract one, as we want to get before the placeholder or end of string + $offset -= 1; + // we got a tag name that we want to search for. escape any regex characters to prevent conflicts. + $regex .= preg_quote(substr($tag, $i, $offset - $i + 1), '/'); + // move $i on + $i = $offset; + } + } + // after the tag may only be the separator or the end + $regex .= '(?:$|' . $tagsSeparator . '))'; + return $regex; + } + /** * This method finalize the content of the foundPositions array, * by associated all search results to their associated bookmark field, 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 $container['bookmarkService'] = function (ShaarliContainer $container): BookmarkServiceInterface { return new BookmarkFileService( $container->conf, + $container->pluginManager, $container->history, new FlockMutex(fopen(SHAARLI_MUTEX_FILE, 'r'), 2), $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 @@ namespace Shaarli\Plugin; +use Shaarli\Bookmark\Bookmark; use Shaarli\Config\ConfigManager; use Shaarli\Plugin\Exception\PluginFileNotFoundException; use Shaarli\Plugin\Exception\PluginInvalidRouteException; @@ -45,6 +46,9 @@ class PluginManager */ protected $errors; + /** @var callable[]|null Preloaded list of hook function for filterSearchEntry() */ + protected $filterSearchEntryHooks = null; + /** * Plugins subdirectory. * @@ -273,6 +277,14 @@ class PluginManager return $this->registeredRoutes; } + /** + * @return array List of registered filter_search_entry hooks + */ + public function getFilterSearchEntryHooks(): ?array + { + return $this->filterSearchEntryHooks; + } + /** * Return the list of encountered errors. * @@ -283,6 +295,50 @@ class PluginManager return $this->errors; } + /** + * Apply additional filter on every search result of BookmarkFilter calling plugins hooks. + * + * @param Bookmark $bookmark To check. + * @param array $context Additional info about search context, depends on the search source. + * + * @return bool True if the result must be kept in search results, false otherwise. + */ + public function filterSearchEntry(Bookmark $bookmark, array $context): bool + { + if ($this->filterSearchEntryHooks === null) { + $this->loadFilterSearchEntryHooks(); + } + + if ($this->filterSearchEntryHooks === []) { + return true; + } + + foreach ($this->filterSearchEntryHooks as $filterSearchEntryHook) { + if ($filterSearchEntryHook($bookmark, $context) === false) { + return false; + } + } + + return true; + } + + /** + * filterSearchEntry() method will be called for every search result, + * so for performances we preload existing functions to invoke them directly. + */ + protected function loadFilterSearchEntryHooks(): void + { + $this->filterSearchEntryHooks = []; + + foreach ($this->loadedPlugins as $plugin) { + $hookFunction = $this->buildHookName('filter_search_entry', $plugin); + + if (function_exists($hookFunction)) { + $this->filterSearchEntryHooks[] = $hookFunction; + } + } + } + /** * Checks whether provided input is valid to register a new route. * It must contain keys `method`, `route`, `callable` (all strings). -- cgit v1.2.3