1<?php 2/** 3 * DokuWiki Plugin elasticsearch (Action Component) 4 * 5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 6 * @author Andreas Gohr <gohr@cosmocode.de> 7 */ 8 9// must be run within Dokuwiki 10if(!defined('DOKU_INC')) die(); 11 12/** 13 * Main search helper 14 */ 15class action_plugin_elasticsearch_search extends DokuWiki_Action_Plugin { 16 17 /** 18 * Registers a callback function for a given event 19 * 20 * @param Doku_Event_Handler $controller DokuWiki's event controller object 21 * @return void 22 */ 23 public function register(Doku_Event_Handler $controller) { 24 25 $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handle_preprocess'); 26 $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE', $this, 'handle_action'); 27 28 } 29 30 /** 31 * allow our custom do command 32 * 33 * @param Doku_Event $event 34 * @param $param 35 */ 36 public function handle_preprocess(Doku_Event $event, $param) { 37 if($event->data != 'search') return; 38 $event->preventDefault(); 39 $event->stopPropagation(); 40 } 41 42 /** 43 * do the actual search 44 * 45 * @param Doku_Event $event 46 * @param $param 47 */ 48 public function handle_action(Doku_Event $event, $param) { 49 if($event->data != 'search') return; 50 $event->preventDefault(); 51 $event->stopPropagation(); 52 global $QUERY; 53 global $INPUT; 54 global $ID; 55 56 if (empty($QUERY)) $QUERY = $INPUT->str('q'); 57 if (empty($QUERY)) $QUERY = $ID; 58 59 /** @var helper_plugin_elasticsearch_client $hlp */ 60 $hlp = plugin_load('helper', 'elasticsearch_client'); 61 62 /** @var helper_plugin_elasticsearch_form $hlpform */ 63 $hlpform = plugin_load('helper', 'elasticsearch_form'); 64 65 $client = $hlp->connect(); 66 $index = $client->getIndex($this->getConf('indexname')); 67 68 // define the query string 69 $qstring = new \Elastica\Query\SimpleQueryString($QUERY); 70 71 // create the actual search object 72 $equery = new \Elastica\Query(); 73 $subqueries = new \Elastica\Query\BoolQuery(); 74 $subqueries->addMust($qstring); 75 76 $equery->setHighlight( 77 [ 78 "pre_tags" => ['ELASTICSEARCH_MARKER_IN'], 79 "post_tags" => ['ELASTICSEARCH_MARKER_OUT'], 80 "fields" => [ 81 $this->getConf('snippets') => new \stdClass(), 82 'title' => new \stdClass()] 83 ] 84 ); 85 86 // paginate 87 $equery->setSize($this->getConf('perpage')); 88 $equery->setFrom($this->getConf('perpage') * ($INPUT->int('p', 1, true) - 1)); 89 90 // add ACL subqueries 91 $this->addACLSubqueries($subqueries); 92 93 // add date subquery 94 if ($INPUT->has('min')) { 95 $this->addDateSubquery($subqueries, $INPUT->str('min')); 96 } 97 98 // add namespace filter 99 if($INPUT->has('ns')) { 100 $nsSubquery = new \Elastica\Query\BoolQuery(); 101 foreach($INPUT->arr('ns') as $ns) { 102 $term = new \Elastica\Query\Term(); 103 $term->setTerm('namespace', $ns); 104 $nsSubquery->addShould($term); 105 } 106 $equery->setPostFilter($nsSubquery); 107 } 108 109 $equery->setQuery($subqueries); 110 111 // add aggregations for namespaces 112 $agg = new \Elastica\Aggregation\Terms('namespace'); 113 $agg->setField('namespace.keyword'); 114 $agg->setSize(25); 115 $equery->addAggregation($agg); 116 117 try { 118 $result = $index->search($equery); 119 $aggs = $result->getAggregations(); 120 121 $this->print_intro(); 122 $hlpform->tpl($aggs['namespace']['buckets'] ?: []); 123 $this->print_results($result) && $this->print_pagination($result); 124 } catch(Exception $e) { 125 msg('Something went wrong on searching please try again later or ask an admin for help.<br /><pre>' . hsc($e->getMessage()) . '</pre>', -1); 126 } 127 } 128 129 /** 130 * Adds date subquery 131 * 132 * @param Elastica\Query\BoolQuery $subqueries 133 * @param string $min Modified at the latest one {year|month|week} ago 134 */ 135 protected function addDateSubquery($subqueries, $min) 136 { 137 // FIXME 138 if (!in_array($min, ['year', 'month', 'week'])) return; 139 140 $dateSubquery = new \Elastica\Query\Range( 141 'modified', 142 ['gte' => date('Y-m-d', strtotime('1 ' . $min . ' ago'))] 143 ); 144 $subqueries->addMust($dateSubquery); 145 } 146 147 /** 148 * Inserts subqueries based on current user's ACLs, none for superusers 149 * 150 * @param \Elastica\Query\BoolQuery $subqueries 151 */ 152 protected function addACLSubqueries($subqueries) 153 { 154 global $USERINFO; 155 global $conf; 156 157 $groups = array_merge(['ALL'], $USERINFO['grps'] ?: []); 158 159 // no ACL filters for superusers 160 if (in_array(ltrim($conf['superuser'], '@'), $groups)) return; 161 162 // include if group OR user have read permissions, allows for ACLs such as "block @group except user" 163 $includeSubquery = new \Elastica\Query\BoolQuery(); 164 foreach($groups as $group) { 165 $term = new \Elastica\Query\Term(); 166 $term->setTerm('groups_include', $group); 167 $includeSubquery->addShould($term); 168 } 169 if (isset($_SERVER['REMOTE_USER'])) { 170 $userIncludeSubquery = new \Elastica\Query\BoolQuery(); 171 $term = new \Elastica\Query\Term(); 172 $term->setTerm('users_include', $_SERVER['REMOTE_USER']); 173 $userIncludeSubquery->addMust($term); 174 $includeSubquery->addShould($userIncludeSubquery); 175 } 176 $subqueries->addMust($includeSubquery); 177 178 // groups exclusion SHOULD be respected, not MUST, since that would not allow for exceptions 179 $groupExcludeSubquery = new \Elastica\Query\BoolQuery(); 180 foreach($groups as $group) { 181 $term = new \Elastica\Query\Term(); 182 $term->setTerm('groups_exclude', $group); 183 $groupExcludeSubquery->addShould($term); 184 } 185 $excludeSubquery = new \Elastica\Query\BoolQuery(); 186 $excludeSubquery->addMustNot($groupExcludeSubquery); 187 $subqueries->addShould($excludeSubquery); 188 189 // user specific excludes must always be respected 190 if (isset($_SERVER['REMOTE_USER'])) { 191 $term = new \Elastica\Query\Term(); 192 $term->setTerm('users_exclude', $_SERVER['REMOTE_USER']); 193 $subqueries->addMustNot($term); 194 } 195 } 196 197 /** 198 * Prints the introduction text 199 */ 200 protected function print_intro() { 201 global $QUERY; 202 global $ID; 203 global $lang; 204 205 // just reuse the standard search page intro: 206 $intro = p_locale_xhtml('searchpage'); 207 // allow use of placeholder in search intro 208 $pagecreateinfo = ''; 209 if (auth_quickaclcheck($ID) >= AUTH_CREATE) { 210 $pagecreateinfo = sprintf($lang['searchcreatepage'], $QUERY); 211 } 212 $intro = str_replace( 213 ['@QUERY@', '@SEARCH@', '@CREATEPAGEINFO@'], 214 [hsc(rawurlencode($QUERY)), hsc($QUERY), $pagecreateinfo], 215 $intro 216 ); 217 echo $intro; 218 flush(); 219 } 220 221 /** 222 * Output the search results 223 * 224 * @param \Elastica\ResultSet $results 225 * @return bool true when results where shown 226 */ 227 protected function print_results($results) { 228 global $lang; 229 230 // output results 231 $found = $results->getTotalHits(); 232 233 if(!$found) { 234 echo '<h2>' . $lang['nothingfound'] . '</h2>'; 235 return (bool)$found; 236 } 237 238 echo '<dl class="search_results">'; 239 echo '<h2>' . sprintf($this->getLang('totalfound'), $found) . '</h2>'; 240 foreach($results as $row) { 241 242 /** @var Elastica\Result $row */ 243 $page = $row->getSource()['uri']; 244 if(!page_exists($page) || auth_quickaclcheck($page) < AUTH_READ) continue; 245 246 // get highlighted title 247 $title = str_replace( 248 ['ELASTICSEARCH_MARKER_IN', 'ELASTICSEARCH_MARKER_OUT'], 249 ['<strong class="search_hit">', '</strong>'], 250 hsc(join(' … ', (array) $row->getHighlights()['title'])) 251 ); 252 if(!$title) $title = hsc($row->getSource()['title']); 253 if(!$title) $title = hsc(p_get_first_heading($page)); 254 if(!$title) $title = hsc($page); 255 256 // get highlighted snippet 257 $snippet = str_replace( 258 ['ELASTICSEARCH_MARKER_IN', 'ELASTICSEARCH_MARKER_OUT'], 259 ['<strong class="search_hit">', '</strong>'], 260 hsc(join(' … ', (array) $row->getHighlights()[$this->getConf('snippets')])) 261 ); 262 if(!$snippet) $snippet = hsc($row->getSource()['abstract']); // always fall back to abstract 263 264 echo '<dt>'; 265 echo '<a href="'.wl($page).'" class="wikilink1" title="'.hsc($page).'">'; 266 echo $title; 267 echo '</a>'; 268 echo '</dt>'; 269 270 // meta 271 echo '<dd class="meta elastic-resultmeta">'; 272 if($row->getSource()['namespace']) { 273 echo '<span class="ns">' . $this->getLang('ns') . ' ' . hsc($row->getSource()['namespace']) . '</span>'; 274 } 275 if($row->getSource()['user']) { 276 echo ' <span class="author">' . $this->getLang('author') . ' ' . userlink($row->getSource()['user']) . '</span>'; 277 } 278 if($row->getSource()['modified']) { 279 $lastmod = strtotime($row->getSource()['modified']); 280 echo ' <span class="">' . $lang['lastmod'] . ' ' . dformat($lastmod) . '</span>'; 281 } 282 echo '</dd>'; 283 284 // snippets 285 echo '<dd class="snippet">'; 286 echo $snippet; 287 echo '</dd>'; 288 289 } 290 echo '</dl>'; 291 292 return (bool) $found; 293 } 294 295 /** 296 * @param \Elastica\ResultSet $result 297 */ 298 protected function print_pagination($result) { 299 global $INPUT; 300 global $QUERY; 301 302 $all = $result->getTotalHits(); 303 $pages = ceil($all / $this->getConf('perpage')); 304 $cur = $INPUT->int('p', 1, true); 305 306 if($pages < 2) return; 307 308 // which pages to show 309 $toshow = [1, 2, $cur, $pages, $pages - 1]; 310 if($cur - 1 > 1) $toshow[] = $cur - 1; 311 if($cur + 1 < $pages) $toshow[] = $cur + 1; 312 $toshow = array_unique($toshow); 313 // fill up to seven, if possible 314 if(count($toshow) < 7) { 315 if($cur < 4) { 316 if($cur + 2 < $pages && count($toshow) < 7) $toshow[] = $cur + 2; 317 if($cur + 3 < $pages && count($toshow) < 7) $toshow[] = $cur + 3; 318 if($cur + 4 < $pages && count($toshow) < 7) $toshow[] = $cur + 4; 319 } else { 320 if($cur - 2 > 1 && count($toshow) < 7) $toshow[] = $cur - 2; 321 if($cur - 3 > 1 && count($toshow) < 7) $toshow[] = $cur - 3; 322 if($cur - 4 > 1 && count($toshow) < 7) $toshow[] = $cur - 4; 323 } 324 } 325 sort($toshow); 326 $showlen = count($toshow); 327 328 echo '<ul class="elastic_pagination">'; 329 if($cur > 1) { 330 echo '<li class="prev">'; 331 echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => ($cur-1)])) . '">'; 332 echo '«'; 333 echo '</a>'; 334 echo '</li>'; 335 } 336 337 for($i = 0; $i < $showlen; $i++) { 338 if($toshow[$i] == $cur) { 339 echo '<li class="cur">' . $toshow[$i] . '</li>'; 340 } else { 341 echo '<li>'; 342 echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => $toshow[$i]])) . '">'; 343 echo $toshow[$i]; 344 echo '</a>'; 345 echo '</li>'; 346 } 347 348 // show seperator when a jump follows 349 if(isset($toshow[$i + 1]) && $toshow[$i + 1] - $toshow[$i] > 1) { 350 echo '<li class="sep">…</li>'; 351 } 352 } 353 354 if($cur < $pages) { 355 echo '<li class="next">'; 356 echo '<a href="' . wl('', http_build_query(['q' => $QUERY, 'do' => 'search', 'ns' => $INPUT->arr('ns'), 'min' => $INPUT->arr('min'), 'p' => ($cur+1)])) . '">'; 357 echo '»'; 358 echo '</a>'; 359 echo '</li>'; 360 } 361 362 echo '</ul>'; 363 } 364 365} 366