1<?php 2 3namespace dokuwiki\Ui; 4 5use \dokuwiki\Form\Form; 6 7class Search extends Ui 8{ 9 protected $query; 10 protected $pageLookupResults = array(); 11 protected $fullTextResults = array(); 12 protected $highlight = array(); 13 14 /** 15 * Search constructor. 16 * 17 * @param string $query the search query 18 */ 19 public function __construct($query) 20 { 21 $this->query = $query; 22 } 23 24 /** 25 * run the search 26 */ 27 public function execute() 28 { 29 $this->pageLookupResults = ft_pageLookup($this->query, true, useHeading('navigation')); 30 $this->fullTextResults = ft_pageSearch($this->query, $highlight); 31 $this->highlight = $highlight; 32 } 33 34 /** 35 * display the search result 36 * 37 * @return void 38 */ 39 public function show() 40 { 41 $searchHTML = ''; 42 43 $searchHTML .= $this->getSearchFormHTML($this->query); 44 45 $searchHTML .= $this->getSearchIntroHTML($this->query); 46 47 $searchHTML .= $this->getPageLookupHTML($this->pageLookupResults); 48 49 $searchHTML .= $this->getFulltextResultsHTML($this->fullTextResults, $this->highlight); 50 51 echo $searchHTML; 52 } 53 54 /** 55 * Get a form which can be used to adjust/refine the search 56 * 57 * @param string $query 58 * 59 * @return string 60 */ 61 protected function getSearchFormHTML($query) 62 { 63 global $lang; 64 65 $Indexer = idx_get_indexer(); 66 $parsedQuery = ft_queryParser($Indexer, $query); 67 68 $searchForm = (new Form())->attrs(['method' => 'get'])->addClass('search-results-form'); 69 $searchForm->setHiddenField('do', 'search'); 70 $searchForm->addFieldsetOpen()->addClass('search-results-form__fieldset'); 71 $searchForm->addTextInput('id')->val($query); 72 $searchForm->addButton('', $lang['btn_search'])->attr('type', 'submit'); 73 74 if ($this->isSearchAssistanceAvailable($parsedQuery)) { 75 $this->addSearchAssistanceElements($searchForm, $parsedQuery); 76 } else { 77 $searchForm->addClass('search-results-form--no-assistance'); 78 $searchForm->addTagOpen('span')->addClass('search-results-form__no-assistance-message'); 79 $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.'); 80 $searchForm->addTagClose('span'); 81 } 82 83 $searchForm->addFieldsetClose(); 84 85 return $searchForm->toHTML(); 86 } 87 88 /** 89 * Decide if the given query is simple enough to provide search assistance 90 * 91 * @param array $parsedQuery 92 * 93 * @return bool 94 */ 95 protected function isSearchAssistanceAvailable(array $parsedQuery) 96 { 97 if (count($parsedQuery['words']) > 1) { 98 return false; 99 } 100 if (!empty($parsedQuery['not'])) { 101 return false; 102 } 103 104 if (!empty($parsedQuery['phrases'])) { 105 return false; 106 } 107 108 if (!empty($parsedQuery['notns'])) { 109 return false; 110 } 111 if (count($parsedQuery['ns']) > 1) { 112 return false; 113 } 114 115 return true; 116 } 117 118 /** 119 * Add the elements to be used for search assistance 120 * 121 * @param Form $searchForm 122 * @param array $parsedQuery 123 */ 124 protected function addSearchAssistanceElements(Form $searchForm, array $parsedQuery) 125 { 126 $matchType = ''; 127 $searchTerm = null; 128 if (count($parsedQuery['words']) === 1) { 129 $searchTerm = $parsedQuery['words'][0]; 130 $firstChar = $searchTerm[0]; 131 $lastChar = substr($searchTerm, -1); 132 $matchType = 'exact'; 133 134 if ($firstChar === '*') { 135 $matchType = 'starts'; 136 } 137 if ($lastChar === '*') { 138 $matchType = 'ends'; 139 } 140 if ($firstChar === '*' && $lastChar === '*') { 141 $matchType = 'contains'; 142 } 143 $searchTerm = trim($searchTerm, '*'); 144 } 145 146 $searchForm->addTextInput( 147 'searchTerm', 148 '', 149 $searchForm->findPositionByAttribute('type', 'submit') 150 ) 151 ->val($searchTerm) 152 ->attr('style', 'display: none;'); 153 $searchForm->addButton('toggleAssistant', 'toggle search assistant') 154 ->attr('type', 'button') 155 ->id('search-results-form__show-assistance-button') 156 ->addClass('search-results-form__show-assistance-button'); 157 158 $searchForm->addTagOpen('div') 159 ->addClass('js-advancedSearchOptions') 160 ->attr('style', 'display: none;'); 161 162 $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper'); 163 $searchForm->addRadioButton('matchType', 'exact Match FM')->val('exact')->attr('checked', 164 $matchType === 'exact' ?: null); 165 $searchForm->addRadioButton('matchType', 'starts with FM')->val('starts')->attr('checked', 166 $matchType === 'starts' ?: null); 167 $searchForm->addRadioButton('matchType', 'ends with FM')->val('ends')->attr('checked', 168 $matchType === 'ends' ?: null); 169 $searchForm->addRadioButton('matchType', 'contains FM')->val('contains')->attr('checked', 170 $matchType === 'contains' ?: null); 171 $searchForm->addTagClose('div'); 172 173 $this->addNamespaceSelector($searchForm, $parsedQuery); 174 175 $searchForm->addTagClose('div'); 176 } 177 178 /** 179 * Add the elements for the namespace selector 180 * 181 * @param Form $searchForm 182 * @param array $parsedQuery 183 */ 184 protected function addNamespaceSelector(Form $searchForm, array $parsedQuery) 185 { 186 $baseNS = empty($parsedQuery['ns']) ? '' : $parsedQuery['ns'][0]; 187 $namespaces = []; 188 $searchForm->addTagOpen('div')->addClass('search-results-form__subwrapper'); 189 if ($baseNS) { 190 $searchForm->addRadioButton('namespace', '(no namespace FIXME)')->val(''); 191 $parts = [$baseNS => count($this->fullTextResults)]; 192 $upperNameSpace = $baseNS; 193 while ($upperNameSpace = getNS($upperNameSpace)) { 194 $parts[$upperNameSpace] = 0; 195 } 196 $namespaces = array_reverse($parts); 197 }; 198 199 $namespaces = array_merge($namespaces, $this->getAdditionalNamespacesFromResults($baseNS)); 200 201 foreach ($namespaces as $extraNS => $count) { 202 $label = $extraNS . ($count ? " ($count)" : ''); 203 $namespaceCB = $searchForm->addRadioButton('namespace', $label)->val($extraNS); 204 if ($extraNS === $baseNS) { 205 $namespaceCB->attr('checked', true); 206 } 207 } 208 209 $searchForm->addTagClose('div'); 210 } 211 212 /** 213 * Parse the full text results for their top namespaces below the given base namespace 214 * 215 * @param string $baseNS the namespace within which was searched, empty string for root namespace 216 * 217 * @return array an associative array with namespace => #number of found pages, sorted descending 218 */ 219 protected function getAdditionalNamespacesFromResults($baseNS) 220 { 221 $namespaces = []; 222 $baseNSLength = strlen($baseNS); 223 foreach ($this->fullTextResults as $page => $numberOfHits) { 224 $namespace = getNS($page); 225 if (!$namespace) { 226 continue; 227 } 228 if ($namespace === $baseNS) { 229 continue; 230 } 231 $firstColon = strpos((string)$namespace, ':', $baseNSLength + 1) ?: strlen($namespace); 232 $subtopNS = substr($namespace, 0, $firstColon); 233 if (empty($namespaces[$subtopNS])) { 234 $namespaces[$subtopNS] = 0; 235 } 236 $namespaces[$subtopNS] += 1; 237 } 238 arsort($namespaces); 239 return $namespaces; 240 } 241 242 /** 243 * Build the intro text for the search page 244 * 245 * @param string $query the search query 246 * 247 * @return string 248 */ 249 protected function getSearchIntroHTML($query) 250 { 251 global $ID, $lang; 252 253 $intro = p_locale_xhtml('searchpage'); 254 // allow use of placeholder in search intro 255 $pagecreateinfo = (auth_quickaclcheck($ID) >= AUTH_CREATE) ? $lang['searchcreatepage'] : ''; 256 $intro = str_replace( 257 array('@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'), 258 array(hsc(rawurlencode($query)), hsc($query), $pagecreateinfo), 259 $intro 260 ); 261 return $intro; 262 } 263 264 /** 265 * Build HTML for a list of pages with matching pagenames 266 * 267 * @param array $data search results 268 * 269 * @return string 270 */ 271 protected function getPageLookupHTML($data) 272 { 273 if (empty($data)) { 274 return ''; 275 } 276 277 global $lang; 278 279 $html = '<div class="search_quickresult">'; 280 $html .= '<h3>' . $lang['quickhits'] . ':</h3>'; 281 $html .= '<ul class="search_quickhits">'; 282 foreach ($data as $id => $title) { 283 $html .= '<li> '; 284 if (useHeading('navigation')) { 285 $name = $title; 286 } else { 287 $ns = getNS($id); 288 if ($ns) { 289 $name = shorten(noNS($id), ' (' . $ns . ')', 30); 290 } else { 291 $name = $id; 292 } 293 } 294 $html .= html_wikilink(':' . $id, $name); 295 $html .= '</li> '; 296 } 297 $html .= '</ul> '; 298 //clear float (see http://www.complexspiral.com/publications/containing-floats/) 299 $html .= '<div class="clearer"></div>'; 300 $html .= '</div>'; 301 302 return $html; 303 } 304 305 /** 306 * Build HTML for fulltext search results or "no results" message 307 * 308 * @param array $data the results of the fulltext search 309 * @param array $highlight the terms to be highlighted in the results 310 * 311 * @return string 312 */ 313 protected function getFulltextResultsHTML($data, $highlight) 314 { 315 global $lang; 316 317 if (empty($data)) { 318 return '<div class="nothing">' . $lang['nothingfound'] . '</div>'; 319 } 320 321 $html = ''; 322 $html .= '<dl class="search_results">'; 323 $num = 1; 324 foreach ($data as $id => $cnt) { 325 $html .= '<dt>'; 326 $html .= html_wikilink(':' . $id, useHeading('navigation') ? null : $id, $highlight); 327 if ($cnt !== 0) { 328 $html .= ': ' . $cnt . ' ' . $lang['hits'] . ''; 329 } 330 $html .= '</dt>'; 331 if ($cnt !== 0) { 332 if ($num < FT_SNIPPET_NUMBER) { // create snippets for the first number of matches only 333 $html .= '<dd>' . ft_snippet($id, $highlight) . '</dd>'; 334 } 335 $num++; 336 } 337 } 338 $html .= '</dl>'; 339 340 return $html; 341 } 342} 343