1<?php
2/**
3 *
4 * @package    solr
5 * @author     Gabriel Birke <birke@d-scribe.de>
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 */
8
9if(!defined('DOKU_INC')) die();
10if(!defined('DOKU_PLUGIN')) define('DOKU_PLUGIN',DOKU_INC.'lib/plugins/');
11require_once(DOKU_PLUGIN.'action.php');
12require_once(dirname(__FILE__).'/AddDocument.php');
13require_once(dirname(__FILE__).'/Pageinfo.php');
14require_once dirname(__FILE__).'/ConnectionException.php';
15
16class action_plugin_solr extends DokuWiki_Action_Plugin {
17
18  const PAGING_SIZE = 100;
19
20  /**
21   * Quuery params used in all search requests to Solr
22   *
23   * @var array
24   */
25  protected $common_params = array(
26    'q.op' => 'AND',
27    'wt'   => 'phps',
28    'debugQuery' => 'false',
29    'start' => 0
30  );
31
32  /**
33   * Query params used in search requests to Solr that highlight snippets
34   *
35   * @var array
36   */
37  protected $highlight_params = array(
38    'hl' => 'true',
39    'hl.fl' => 'content',
40    'hl.snippets' => 4,
41    'hl.simple.pre' => '!!SOLR_HIGH!!',
42    'hl.simple.post' => '!!END_SOLR_HIGH!!'
43  );
44
45  public $highlight2html = array(
46    '!!SOLR_HIGH!!' => '<strong class="search_hit">',
47    '!!END_SOLR_HIGH!!' => '</strong>'
48  );
49
50  protected $allowed_actions = array('solr_search', 'solr_adv_search');
51
52  /**
53   * return some info
54   */
55  function getInfo(){
56    return array(
57		 'author' => 'Gabriel Birke',
58		 'email'  => 'birke@d-scribe.de',
59		 'date'   => '2011-12-21',
60		 'name'   => 'Solr (Action component)',
61		 'desc'   => 'Update the Solr index during the indexing event, show search page.',
62		 'url'    => 'http://www.d-scribe.de/',
63		 );
64  }
65
66  /**
67   * Register the handlers with the dokuwiki's event controller
68   */
69  function register(&$controller) {
70    $controller->register_hook('INDEXER_TASKS_RUN', 'BEFORE',  $this, 'updateindex');
71    $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE',  $this, 'allowsearchpage');
72    $controller->register_hook('TPL_ACT_UNKNOWN', 'BEFORE',  $this, 'dispatch_search');
73    $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'quicksearch');
74    $controller->register_hook('IO_WIKIPAGE_WRITE', 'AFTER', $this, 'delete_index');
75  }
76
77  /**
78   * Update Solr index
79   *
80   * This event handler is called from the lib/exe/indexer.php file.
81   */
82  function updateindex(&$event, $param) {
83    global $ID;
84    $helper = $this->loadHelper('solr', true);
85
86    // Look for index file, if not modified, return
87    if(!$helper->needs_indexing($ID)){
88      print "solr_indexer: index for $ID up to date".NL;
89      return;
90    }
91
92    // get index lock
93    $lock = $helper->lock_index();
94
95    // gather page info
96    $writer = new XmlWriter();
97    $writer->openMemory();
98    $info = new Solr_Pageinfo($ID);
99    $doc = new Solr_AddDocument($writer);
100    $doc->start();
101    $doc->addPage($info->getFields());
102    $doc->end();
103
104    // post to SOLR
105    try {
106      $result = $helper->solr_query('update', 'commit=true', 'POST', $writer->outputMemory());
107      $xml = simplexml_load_string($result);
108      // Check response
109      if($xml->getName() != "response") {
110        print "solr_indexer: Unexpected response:\n$result\n";
111      }
112      else {
113        print "solr_indexer: index was updated\n";
114        // update index file
115        $helper->update_idxfile($ID);
116      }
117    }
118    catch(ConnectionException $e) {
119      print "solr_indexer: Request failed: ".$e->getMessage().NL;
120    }
121
122    // release lock
123    @rmdir($lock);
124
125    // Stop event propagation to avoid script timeout
126    $event->preventDefault();
127    $event->stopPropagation();
128  }
129
130  /**
131   * Event handler for displaying the search result page
132   */
133  function dispatch_search(&$event, $param) {
134    // only handle our actions
135    if(!in_array($event->data, $this->allowed_actions)) {
136      return;
137    }
138    $method = 'page_'.$event->data;
139    $this->$method();
140
141    $event->preventDefault();
142    $event->stopPropagation();
143  }
144
145  /**
146   * Display advanced search form and handle the sent form fields
147   */
148  protected function page_solr_adv_search() {
149    global $QUERY;
150    $helper = $this->loadHelper('solr', true);
151    echo $helper->htmlAdvancedSearchform();
152
153    // Build search string
154    $q = '';
155    if(!empty($_REQUEST['search_plus'])) {
156      $val = utf8_stripspecials(utf8_strtolower($_REQUEST['search_plus']));
157      $q .= $this->search_words($val, '+', '*');
158    }
159    elseif(!empty($QUERY)) {
160      $val = utf8_stripspecials(utf8_strtolower($QUERY));
161      $q .= $this->search_words($val, '+', '*');
162    }
163    if(!empty($_REQUEST['search_exact'])) {
164      $q .= ' +"'.$_REQUEST['search_exact'].'"';
165    }
166    if(!empty($_REQUEST['search_minus'])) {
167      $val = utf8_stripspecials(utf8_strtolower($_REQUEST['search_minus']));
168      $q .= $this->search_words($val, '-', '*');
169    }
170    if(!empty($_REQUEST['search_ns'])) {
171      foreach($_REQUEST['search_ns'] as $ns) {
172        if(($ns = trim($ns)) != '') {
173          $q .= ' idpath:'.strtr($ns, ':','/');
174        }
175      }
176    }
177    if(!empty($_REQUEST['search_fields'])) {
178        foreach($_REQUEST['search_fields'] as $key => $value) {
179          //$value = utf8_stripspecials(utf8_strtolower($value));
180          if(!$value) {
181            continue;
182          }
183          $q .= $this->search_words($value, ''.$key.':', '*');
184        }
185    }
186    $q = trim($q); // remove first space
187    // Don't search with empty params
188    if(!$q) {
189      return;
190    }
191
192    $content_params = array_merge($this->common_params, $this->highlight_params, array(
193        'q' => $q,
194        'rows' => self::PAGING_SIZE,
195       // 'q.op' => 'OR'
196    ));
197    //print("<p>search string: $q</p>");
198    print $this->locale_xhtml('searchpage');
199    print '<div class="search_allresults">';
200    $this->search_query($content_params);
201    print '</div>';
202
203  }
204
205  protected function search_words($str, $prefix='', $suffix='') {
206    $words = preg_split('/\s+/', $str);
207    $search_words = '';
208    foreach($words as $w) {
209      $search_words .= ' ' . $prefix . $w . $suffix;
210    }
211    return $search_words;
212  }
213
214  /**
215   * Do a simple search and display search results
216   */
217  protected function page_solr_search() {
218    global $QUERY;
219    $val = utf8_strtolower($QUERY);
220    $q_title .= $this->search_words($val, 'title:', '*');
221    $q_text  .= $this->search_words($val, '', '*');
222
223    // Prepare the parameters to be sent to Solr
224    $title_params = array_merge($this->common_params, array('q' => $q_title, 'rows' => self::PAGING_SIZE));
225    $content_params = array_merge($this->common_params, $this->highlight_params, array(
226      'q' => $q_text,
227      'rows' => self::PAGING_SIZE,
228      'x-dw-query-type' => 'content' // Dummy parameter to make this query identifyable in handlers for the SOLR_QUERY event
229    ));
230
231    // Other plugins can manipulate the parameters
232    trigger_event('SOLR_QUERY_TITLE', $title_params);
233    trigger_event('SOLR_QUERY_CONTENT', $content_params);
234
235    $query_str_title = substr($this->array2paramstr($title_params), 1);
236    $helper = $this->loadHelper('solr', true);
237
238    // Build HTML result
239    print $this->locale_xhtml('searchpage');
240    flush();
241
242    //do a search for page titles
243    try {
244      $title_result = unserialize($helper->solr_query('select', $query_str_title));
245    }
246    catch(ConnectionException $e) {
247      echo $this->getLang('search_failed');
248    }
249    if(!empty($title_result['response']['docs'])){
250      print '<div class="search_quickresult">';
251      print '<h3>'.$this->getLang('quickhits').':</h3>';
252      $helper->html_render_titles($title_result, 'search_quickhits');
253      print '<div class="clearer">&nbsp;</div>';
254      print '</div>';
255    }
256    flush();
257
258    // Output search
259    print '<div class="search_allresults">';
260    $this->search_query($content_params);
261    print '</div>';
262  }
263
264  /**
265   * Query Solr and render search result.
266   *
267   * If the result contains more documents than the PAGING_SIZE constant,
268   * do another Solr request with increased 'start' parameter.
269   *
270   * @param array $params Solr Search query params
271   */
272  protected function search_query($params){
273    global $QUERY;
274    $helper = $this->loadHelper('solr', true);
275    $start = empty($params['start']) ? 0 : $params['start'];
276    $query_str = substr($this->array2paramstr($params), 1);
277    // Solr query for content
278    try {
279      $content_result = unserialize($helper->solr_query('select', $query_str));
280      //echo "<pre>";print_r($content_result);echo "</pre>";
281    }
282    catch(Exception $e) {
283      echo $this->getLang('search_failed');
284      return;
285    }
286    $q_arr = preg_split('/\s+/', $QUERY);
287    $num_snippets = $this->getConf('num_snippets');
288    if(!empty($content_result['response']['docs'])){
289        $num = $start+1;
290        if(!$start) {
291          print '<h3>'.$this->getLang('all_hits').':</h3>';
292        }
293        foreach($content_result['response']['docs'] as $doc){
294            $id = $doc['id'];
295            if(auth_quickaclcheck($id) < AUTH_READ) {
296              continue;
297            }
298            $data = array('result' => $content_result, 'id' => $id, 'html' => array());
299            $data['html']['head'] = html_wikilink(':'.$id, useHeading('navigation')?null:$id, $q_arr);
300            if(!$num_snippets || $num < $num_snippets){
301                if(!empty($content_result['highlighting'][$id]['content'])){
302                  // Escape <code> and other tags
303                  $highlight = htmlspecialchars(implode('... ', $content_result['highlighting'][$id]['content']));
304                  // replace highlight placeholders with HTML
305                  $highlight = str_replace(
306                    array_keys($this->highlight2html),
307                    array_values($this->highlight2html),
308                    $highlight
309                  );
310                  $data['html']['body'] = '<div class="search_snippet">'.$highlight.'</div>';
311                }
312            }
313            $num++;
314            // Enable plugins to add data or render result differently.
315            print trigger_event('SOLR_RENDER_RESULT_CONTENT', $data, array($this, '_render_content_search_result'));
316            flush();
317        }
318        if($content_result['response']['numFound'] > $content_result['response']['start'] + self::PAGING_SIZE) {
319          $params['start'] = $content_result['response']['start'] + self::PAGING_SIZE;
320          $this->search_query($params);
321        }
322    }
323    elseif(!$start) { // if the first search result returned nothing, print nothing found message
324        print '<div class="nothing">'.$this->getLang('nothingfound').'</div>';
325    }
326  }
327
328  public function _render_content_search_result($data) {
329    return '<div class="search_result">'.implode('', $data['html']).'</div>';
330  }
331
332  /**
333   * Convert an associative array to a parameter string.
334   * Array values are urlencoded
335   *
336   * @param array $params
337   * @return string
338   */
339  protected function array2paramstr($params) {
340    $paramstr = '';
341    foreach($params as $p => $v) {
342      $paramstr .= '&'.$p.'='.rawurlencode($v);
343    }
344    return $paramstr;
345  }
346
347  /**
348   * Allow the solr_search action if the global variable $QUERY is not empty
349   */
350  public function allowsearchpage(&$event, $param) {
351    global $QUERY;
352    if(!in_array($event->data, $this->allowed_actions)) return;
353    if(!$QUERY && $event->data ==  'solr_search') {
354      $event->data = 'show';
355      return;
356    }
357    $event->preventDefault();
358  }
359
360  /**
361   * Handle AJAX request for quickly displaying titles
362   */
363  public function quicksearch(&$event, $params){
364    if($event->data != 'solr_qsearch') {
365      return;
366    }
367    $q_arr = preg_split('/\s+/', $_REQUEST['q']);
368    $q_title = '';
369    // construct query string with field name and wildcards
370    foreach($q_arr as $val) {
371      $val = utf8_stripspecials(utf8_strtolower($val));
372      $q_title .= ' title:'.$val.'*';
373    }
374    $title_params = array_merge($this->common_params, array('q' => $q_title));
375    // Other plugins can manipulate the parameters
376    trigger_event('SOLR_QUERY_TITLE', $title_params);
377
378    $query_str_title = substr($this->array2paramstr($title_params), 1);
379
380    $helper = $this->loadHelper('solr', true);
381
382    //do quick pagesearch
383    // Solr query for title
384    try {
385      $title_result = unserialize($helper->solr_query('select', $query_str_title));
386      //echo "<pre>";print_r($title_result);echo "</pre>";
387    }
388    catch(ConnectionException $e) {
389      echo $this->getLang('search_failed');
390    }
391
392    if(!empty($title_result['response']['docs'])){
393      print '<strong>'.$this->getLang('quickhits').'</strong>';
394      $helper->html_render_titles($title_result);
395    }
396    flush();
397    $event->preventDefault();
398    $event->stopPropagation();
399  }
400
401  /**
402   * This event handler deletes a page from the Solr index when it is deleted
403   * in the wiki.
404   */
405  public function delete_index(&$event, $params){
406    // If a revision is stored, do nothing
407    if(!empty($event->data[3])) {
408      return;
409    }
410    // If non-empty content is saved, do nothing
411    if(!empty($event->data[0][1])) {
412      return;
413    }
414    // create page ID from event data
415    $id = $event->data[1] ? "{$event->data[1]}:{$event->data[2]}" : $event->data[2];
416    $helper = $this->loadHelper('solr', true);
417
418    // send delete command to Solr
419    $query = $this->array2paramstr(array(
420      'stream.body' => "<delete><id>{$id}</id></delete>",
421      'commit' => "true"
422    ));
423    try {
424      $helper->solr_query('update', $query);
425    }
426    catch(ConnectionException $e) {
427      msg($this->getLang('delete_failed'), -1);
428      dbglog($e->getMessage(), $this->getLang('delete_failed'));
429    }
430  }
431
432}
433