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