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