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