From bb8ef86758882cd38a7bbafff62a3bc807ffe056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Gro=C3=9Fe?= <grosse@cosmocode.de> Date: Tue, 20 Mar 2018 16:14:00 +0100 Subject: [PATCH] feat(search): add search assistance for simple queries This add some search assistance to simple, single-word search queries which may be restricted to a single namespace. Further improvements: * better styling * trigger events for other plugins * set namespaces directly from fulltext search results * some more config options --- inc/Ui/Search.php | 174 ++++++++++++++++++++++++++++++- lib/exe/js.php | 1 + lib/scripts/search.js | 67 ++++++++++++ lib/tpl/dokuwiki/css/_search.css | 16 +++ 4 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 lib/scripts/search.js diff --git a/inc/Ui/Search.php b/inc/Ui/Search.php index bff33cae5..cbc090b52 100644 --- a/inc/Ui/Search.php +++ b/inc/Ui/Search.php @@ -62,17 +62,183 @@ class Search extends Ui { global $lang; - $searchForm = (new Form())->attrs(['method' => 'get']); - $searchForm->setHiddenField('do', 'search'); + $Indexer = idx_get_indexer(); + $parsedQuery = ft_queryParser($Indexer, $query); - $searchForm->addFieldsetOpen(); - $searchForm->addTextInput('id', '')->val($query); + $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form'); + $searchForm->setHiddenField('do', 'search'); + $searchForm->addFieldsetOpen()->addClass('search-results-form__fieldset'); + $searchForm->addTextInput('id')->val($query); $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit'); + + if ($this->isSearchAssistanceAvailable($parsedQuery)) { + $this->addSearchAssistanceElements($searchForm, $parsedQuery); + } else { + $searchForm->addClass('search-results-form--no-assistance'); + $searchForm->addTagOpen('span')->addClass('search-results-form__no-assistance-message'); + $searchForm->addHTML('FIXME Your query is too complex. Search assistance is unavailable. See <a href="https://doku.wiki/search">doku.wiki/search</a> for more help.'); + $searchForm->addTagClose('span'); + } + $searchForm->addFieldsetClose(); return $searchForm->toHTML(); } + /** + * Decide if the given query is simple enough to provide search assistance + * + * @param array $parsedQuery + * + * @return bool + */ + protected function isSearchAssistanceAvailable(array $parsedQuery) + { + if (count($parsedQuery['words']) > 1) { + return false; + } + if (!empty($parsedQuery['not'])) { + return false; + } + + if (!empty($parsedQuery['phrases'])) { + return false; + } + + if (!empty($parsedQuery['notns'])) { + return false; + } + if (count($parsedQuery['ns']) > 1) { + return false; + } + + return true; + } + + /** + * Add the elements to be used for search assistance + * + * @param Form $searchForm + * @param array $parsedQuery + */ + protected function addSearchAssistanceElements(Form $searchForm, array $parsedQuery) + { + $matchType = ''; + $searchTerm = null; + if (count($parsedQuery['words']) === 1) { + $searchTerm = $parsedQuery['words'][0]; + $firstChar = $searchTerm[0]; + $lastChar = substr($searchTerm, -1); + $matchType = 'exact'; + + if ($firstChar === '*') { + $matchType = 'starts'; + } + if ($lastChar === '*') { + $matchType = 'ends'; + } + if ($firstChar === '*' && $lastChar === '*') { + $matchType = 'contains'; + } + $searchTerm = trim($searchTerm, '*'); + } + + $searchForm->addTextInput( + 'searchTerm', + '', + $searchForm->findPositionByAttribute('type', 'submit') + ) + ->val($searchTerm) + ->attr('style', 'display: none;'); + $searchForm->addButton('toggleAssistant', 'toggle search assistant') + ->attr('type', 'button') + ->id('search-results-form__show-assistance-button') + ->addClass('search-results-form__show-assistance-button'); + + $searchForm->addTagOpen('div') + ->addClass('js-advancedSearchOptions') + ->attr('style', 'display: none;'); + + $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper'); + $searchForm->addRadioButton('matchType', 'exact Match FM')->val('exact')->attr('checked', + $matchType === 'exact' ?: null); + $searchForm->addRadioButton('matchType', 'starts with FM')->val('starts')->attr('checked', + $matchType === 'starts' ?: null); + $searchForm->addRadioButton('matchType', 'ends with FM')->val('ends')->attr('checked', + $matchType === 'ends' ?: null); + $searchForm->addRadioButton('matchType', 'contains FM')->val('contains')->attr('checked', + $matchType === 'contains' ?: null); + $searchForm->addTagClose('div'); + + $this->addNamespaceSelector($searchForm, $parsedQuery); + + $searchForm->addTagClose('div'); + } + + /** + * Add the elements for the namespace selector + * + * @param Form $searchForm + * @param array $parsedQuery + */ + protected function addNamespaceSelector(Form $searchForm, array $parsedQuery) + { + $baseNS = empty($parsedQuery['ns']) ? '' : $parsedQuery['ns'][0]; + $namespaces = []; + $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper'); + if ($baseNS) { + $searchForm->addRadioButton('namespace', '(no namespace FIXME)')->val(''); + $parts = [$baseNS => count($this->fullTextResults)]; + $upperNameSpace = $baseNS; + while ($upperNameSpace = getNS($upperNameSpace)) { + $parts[$upperNameSpace] = 0; + } + $namespaces = array_reverse($parts); + }; + + $namespaces = array_merge($namespaces, $this->getAdditionalNamespacesFromResults($baseNS)); + + foreach ($namespaces as $extraNS => $count) { + $label = $extraNS . ($count ? " ($count)" : ''); + $namespaceCB = $searchForm->addRadioButton('namespace', $label)->val($extraNS); + if ($extraNS === $baseNS) { + $namespaceCB->attr('checked', true); + } + } + + $searchForm->addTagClose('div'); + } + + /** + * Parse the full text results for their top namespaces below the given base namespace + * + * @param string $baseNS the namespace within which was searched, empty string for root namespace + * + * @return array an associative array with namespace => #number of found pages, sorted descending + */ + protected function getAdditionalNamespacesFromResults($baseNS) + { + $namespaces = []; + $baseNSLength = strlen($baseNS); + foreach ($this->fullTextResults as $page => $numberOfHits) { + $namespace = getNS($page); + if (!$namespace) { + continue; + } + if ($namespace === $baseNS) { + continue; + } + $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace); + $subtopNS = substr($namespace, 0, $firstColon); + if (empty($namespaces[$subtopNS])) { + $namespaces[$subtopNS] = 0; + } + $namespaces[$subtopNS] += 1; + } + arsort($namespaces); + return $namespaces; + } + /** * Build the intro text for the search page * diff --git a/lib/exe/js.php b/lib/exe/js.php index ee017a41e..4c614f080 100644 --- a/lib/exe/js.php +++ b/lib/exe/js.php @@ -47,6 +47,7 @@ function js_out(){ DOKU_INC.'lib/scripts/cookie.js', DOKU_INC.'lib/scripts/script.js', DOKU_INC.'lib/scripts/qsearch.js', + DOKU_INC.'lib/scripts/search.js', DOKU_INC.'lib/scripts/tree.js', DOKU_INC.'lib/scripts/index.js', DOKU_INC.'lib/scripts/textselection.js', diff --git a/lib/scripts/search.js b/lib/scripts/search.js new file mode 100644 index 000000000..0c9dca76a --- /dev/null +++ b/lib/scripts/search.js @@ -0,0 +1,67 @@ +jQuery(function () { + 'use strict'; + + const $searchForm = jQuery('.search-results-form'); + if (!$searchForm.length) { + return; + } + if (!$searchForm.find('#search-results-form__show-assistance-button').length){ + return; + } + const $toggleAssistanceButton = $searchForm.find('#search-results-form__show-assistance-button'); + const $queryInput = $searchForm.find('[name="id"]'); + const $termInput = $searchForm.find('[name="searchTerm"]'); + + $toggleAssistanceButton.on('click', function () { + jQuery('.js-advancedSearchOptions').toggle(); + $queryInput.toggle(); + $termInput.toggle(); + }); + + + const $matchTypeSwitcher = $searchForm.find('[name="matchType"]'); + const $namespaceSwitcher = $searchForm.find('[name="namespace"]'); + const $refiningElements = $termInput.add($matchTypeSwitcher).add($namespaceSwitcher); + $refiningElements.on('input change', function () { + $queryInput.val( + rebuildQuery( + $termInput.val(), + $matchTypeSwitcher.filter(':checked').val(), + $namespaceSwitcher.filter(':checked').val() + ) + ); + }); + + /** + * Rebuild the search query from the parts + * + * @param {string} searchTerm the word which is to be searched + * @param {enum} matchType the type of matching that is to be done + * @param {string} namespace list of namespaces to which to limit the search + * + * @return {string} the query string for the actual search + */ + function rebuildQuery(searchTerm, matchType, namespace) { + let query = ''; + + switch (matchType) { + case 'contains': + query = '*' + searchTerm + '*'; + break; + case 'starts': + query = '*' + searchTerm; + break; + case 'ends': + query = searchTerm + '*'; + break; + default: + query = searchTerm; + } + + if (namespace && namespace.length) { + query += ' @' + namespace; + } + + return query; + } +}); diff --git a/lib/tpl/dokuwiki/css/_search.css b/lib/tpl/dokuwiki/css/_search.css index a8972ae72..8da2652a2 100644 --- a/lib/tpl/dokuwiki/css/_search.css +++ b/lib/tpl/dokuwiki/css/_search.css @@ -12,6 +12,22 @@ margin-bottom: 1.4em; } +.search-results-form .search-results-form__fieldset { + width: 80vw; +} + +.search-results-form__show-assistance-button { + float: right; +} + +.search-results-form__no-assistance-message { + color: grey; + float: right; + font-size: 80%; + margin-top: -0.3em; +} + + /*____________ matching pagenames ____________*/ .dokuwiki div.search_quickresult { -- GitLab