Mereged updates from DokuWiki 38
[sudaraka-org:dokuwiki-mods.git] / inc / search.php
1 <?php
2 /**
3  * DokuWiki search functions
4  *
5  * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6  * @author     Andreas Gohr <andi@splitbrain.org>
7  */
8
9 if(!defined('DOKU_INC')) die('meh.');
10
11 /**
12  * recurse direcory
13  *
14  * This function recurses into a given base directory
15  * and calls the supplied function for each file and directory
16  *
17  * @param   array ref $data The results of the search are stored here
18  * @param   string    $base Where to start the search
19  * @param   callback  $func Callback (function name or arayy with object,method)
20  * @param   string    $dir  Current directory beyond $base
21  * @param   int       $lvl  Recursion Level
22  * @author  Andreas Gohr <andi@splitbrain.org>
23  */
24 function search(&$data,$base,$func,$opts,$dir='',$lvl=1,$sort=false){
25     $dirs   = array();
26     $files  = array();
27     $filepaths = array();
28
29     //read in directories and files
30     $dh = @opendir($base.'/'.$dir);
31     if(!$dh) return;
32     while(($file = readdir($dh)) !== false){
33         if(preg_match('/^[\._]/',$file)) continue; //skip hidden files and upper dirs
34         if(is_dir($base.'/'.$dir.'/'.$file)){
35             $dirs[] = $dir.'/'.$file;
36             continue;
37         }
38         $files[] = $dir.'/'.$file;
39         $filepaths[] = $base.'/'.$dir.'/'.$file;
40     }
41     closedir($dh);
42     if ($sort == 'date') {
43         @array_multisort(array_map('filemtime', $filepaths), SORT_NUMERIC, SORT_DESC, $files);
44     } else {
45         sort($files);
46     }
47     sort($dirs);
48
49     //give directories to userfunction then recurse
50     foreach($dirs as $dir){
51         if (call_user_func_array($func, array(&$data,$base,$dir,'d',$lvl,$opts))){
52             search($data,$base,$func,$opts,$dir,$lvl+1);
53         }
54     }
55     //now handle the files
56     foreach($files as $file){
57         call_user_func_array($func, array(&$data,$base,$file,'f',$lvl,$opts));
58     }
59 }
60
61 /**
62  * Wrapper around call_user_func_array.
63  *
64  * @deprecated
65  */
66 function search_callback($func,&$data,$base,$file,$type,$lvl,$opts){
67     return call_user_func_array($func, array(&$data,$base,$file,$type,$lvl,$opts));
68 }
69
70 /**
71  * The following functions are userfunctions to use with the search
72  * function above. This function is called for every found file or
73  * directory. When a directory is given to the function it has to
74  * decide if this directory should be traversed (true) or not (false)
75  * The function has to accept the following parameters:
76  *
77  * &$data - Reference to the result data structure
78  * $base  - Base usually $conf['datadir']
79  * $file  - current file or directory relative to $base
80  * $type  - Type either 'd' for directory or 'f' for file
81  * $lvl   - Current recursion depht
82  * $opts  - option array as given to search()
83  *
84  * return values for files are ignored
85  *
86  * All functions should check the ACL for document READ rights
87  * namespaces (directories) are NOT checked (when sneaky_index is 0) as this
88  * would break the recursion (You can have an nonreadable dir over a readable
89  * one deeper nested) also make sure to check the file type (for example
90  * in case of lockfiles).
91  */
92
93 /**
94  * Searches for pages beginning with the given query
95  *
96  * @author Andreas Gohr <andi@splitbrain.org>
97  */
98 function search_qsearch(&$data,$base,$file,$type,$lvl,$opts){
99     $opts = array(
100             'idmatch'   => '(^|:)'.preg_quote($opts['query'],'/').'/',
101             'listfiles' => true,
102             'pagesonly' => true,
103             );
104     return search_universal($data,$base,$file,$type,$lvl,$opts);
105 }
106
107 /**
108  * Build the browsable index of pages
109  *
110  * $opts['ns'] is the currently viewed namespace
111  *
112  * @author  Andreas Gohr <andi@splitbrain.org>
113  */
114 function search_index(&$data,$base,$file,$type,$lvl,$opts){
115     global $conf;
116     $opts = array(
117         'pagesonly' => true,
118         'listdirs' => true,
119         'listfiles' => !$opts['nofiles'],
120         'sneakyacl' => $conf['sneaky_index'],
121         // Hacky, should rather use recmatch
122         'depth' => preg_match('#^'.preg_quote($file, '#').'(/|$)#','/'.$opts['ns']) ? 0 : -1
123     );
124
125     return search_universal($data, $base, $file, $type, $lvl, $opts);
126 }
127
128 /**
129  * List all namespaces
130  *
131  * @author  Andreas Gohr <andi@splitbrain.org>
132  */
133 function search_namespaces(&$data,$base,$file,$type,$lvl,$opts){
134     $opts = array(
135             'listdirs' => true,
136             );
137     return search_universal($data,$base,$file,$type,$lvl,$opts);
138 }
139
140 /**
141  * List all mediafiles in a namespace
142  *
143  * @author  Andreas Gohr <andi@splitbrain.org>
144  */
145 function search_media(&$data,$base,$file,$type,$lvl,$opts){
146
147     //we do nothing with directories
148     if($type == 'd') {
149         if(!$opts['depth']) return true; // recurse forever
150         $depth = substr_count($file,'/');
151         if($depth >= $opts['depth']) return false; // depth reached
152         return true;
153     }
154
155     $info         = array();
156     $info['id']   = pathID($file,true);
157     if($info['id'] != cleanID($info['id'])){
158         if($opts['showmsg'])
159             msg(hsc($info['id']).' is not a valid file name for DokuWiki - skipped',-1);
160         return false; // skip non-valid files
161     }
162
163     //check ACL for namespace (we have no ACL for mediafiles)
164     $info['perm'] = auth_quickaclcheck(getNS($info['id']).':*');
165     if(!$opts['skipacl'] && $info['perm'] < AUTH_READ){
166         return false;
167     }
168
169     //check pattern filter
170     if($opts['pattern'] && !@preg_match($opts['pattern'], $info['id'])){
171         return false;
172     }
173
174     $info['file']     = utf8_basename($file);
175     $info['size']     = filesize($base.'/'.$file);
176     $info['mtime']    = filemtime($base.'/'.$file);
177     $info['writable'] = is_writable($base.'/'.$file);
178     if(preg_match("/\.(jpe?g|gif|png)$/",$file)){
179         $info['isimg'] = true;
180         $info['meta']  = new JpegMeta($base.'/'.$file);
181     }else{
182         $info['isimg'] = false;
183     }
184     if($opts['hash']){
185         $info['hash'] = md5(io_readFile(mediaFN($info['id']),false));
186     }
187
188     $data[] = $info;
189
190     return false;
191 }
192
193 /**
194  * This function just lists documents (for RSS namespace export)
195  *
196  * @author  Andreas Gohr <andi@splitbrain.org>
197  */
198 function search_list(&$data,$base,$file,$type,$lvl,$opts){
199     //we do nothing with directories
200     if($type == 'd') return false;
201     //only search txt files
202     if(substr($file,-4) == '.txt'){
203         //check ACL
204         $id = pathID($file);
205         if(auth_quickaclcheck($id) < AUTH_READ){
206             return false;
207         }
208         $data[]['id'] = $id;
209     }
210     return false;
211 }
212
213 /**
214  * Quicksearch for searching matching pagenames
215  *
216  * $opts['query'] is the search query
217  *
218  * @author  Andreas Gohr <andi@splitbrain.org>
219  */
220 function search_pagename(&$data,$base,$file,$type,$lvl,$opts){
221     //we do nothing with directories
222     if($type == 'd') return true;
223     //only search txt files
224     if(substr($file,-4) != '.txt') return true;
225
226     //simple stringmatching
227     if (!empty($opts['query'])){
228         if(strpos($file,$opts['query']) !== false){
229             //check ACL
230             $id = pathID($file);
231             if(auth_quickaclcheck($id) < AUTH_READ){
232                 return false;
233             }
234             $data[]['id'] = $id;
235         }
236     }
237     return true;
238 }
239
240 /**
241  * Just lists all documents
242  *
243  * $opts['depth']   recursion level, 0 for all
244  * $opts['hash']    do md5 sum of content?
245  * $opts['skipacl'] list everything regardless of ACL
246  *
247  * @author  Andreas Gohr <andi@splitbrain.org>
248  */
249 function search_allpages(&$data,$base,$file,$type,$lvl,$opts){
250     //we do nothing with directories
251     if($type == 'd'){
252         if(!$opts['depth']) return true; // recurse forever
253         $parts = explode('/',ltrim($file,'/'));
254         if(count($parts) == $opts['depth']) return false; // depth reached
255         return true;
256     }
257
258     //only search txt files
259     if(substr($file,-4) != '.txt') return true;
260
261     $item['id']   = pathID($file);
262     if(!$opts['skipacl'] && auth_quickaclcheck($item['id']) < AUTH_READ){
263         return false;
264     }
265
266     $item['rev']   = filemtime($base.'/'.$file);
267     $item['mtime'] = $item['rev'];
268     $item['size']  = filesize($base.'/'.$file);
269     if($opts['hash']){
270         $item['hash'] = md5(trim(rawWiki($item['id'])));
271     }
272
273     $data[] = $item;
274     return true;
275 }
276
277 /**
278  * Search for backlinks to a given page
279  *
280  * $opts['ns']    namespace of the page
281  * $opts['name']  name of the page without namespace
282  *
283  * @author  Andreas Gohr <andi@splitbrain.org>
284  * @deprecated Replaced by ft_backlinks()
285  */
286 function search_backlinks(&$data,$base,$file,$type,$lvl,$opts){
287     //we do nothing with directories
288     if($type == 'd') return true;
289     //only search txt files
290     if(substr($file,-4) != '.txt') return true;
291
292     //absolute search id
293     $sid = cleanID($opts['ns'].':'.$opts['name']);
294
295     //current id and namespace
296     $cid = pathID($file);
297     $cns = getNS($cid);
298
299     //check ACL
300     if(auth_quickaclcheck($cid) < AUTH_READ){
301         return false;
302     }
303
304     //fetch instructions
305     $instructions = p_cached_instructions($base.$file,true);
306     if(is_null($instructions)) return false;
307
308     global $conf;
309     //check all links for match
310     foreach($instructions as $ins){
311         if($ins[0] == 'internallink' || ($conf['camelcase'] && $ins[0] == 'camelcaselink') ){
312             $mid = $ins[1][0];
313             resolve_pageid($cns,$mid,$exists); //exists is not used
314             if($mid == $sid){
315                 //we have a match - finish
316                 $data[]['id'] = $cid;
317                 break;
318             }
319         }
320     }
321
322     return false;
323 }
324
325 /**
326  * Fulltextsearch
327  *
328  * $opts['query'] is the search query
329  *
330  * @author  Andreas Gohr <andi@splitbrain.org>
331  * @deprecated - fulltext indexer is used instead
332  */
333 function search_fulltext(&$data,$base,$file,$type,$lvl,$opts){
334     //we do nothing with directories
335     if($type == 'd') return true;
336     //only search txt files
337     if(substr($file,-4) != '.txt') return true;
338
339     //check ACL
340     $id = pathID($file);
341     if(auth_quickaclcheck($id) < AUTH_READ){
342         return false;
343     }
344
345     //create regexp from queries
346     $poswords = array();
347     $negwords = array();
348     $qpreg = preg_split('/\s+/',$opts['query']);
349
350     foreach($qpreg as $word){
351         switch(substr($word,0,1)){
352             case '-':
353                 if(strlen($word) > 1){  // catch single '-'
354                     array_push($negwords,preg_quote(substr($word,1),'#'));
355                 }
356                 break;
357             case '+':
358                 if(strlen($word) > 1){  // catch single '+'
359                     array_push($poswords,preg_quote(substr($word,1),'#'));
360                 }
361                 break;
362             default:
363                 array_push($poswords,preg_quote($word,'#'));
364                 break;
365         }
366     }
367
368     // a search without any posword is useless
369     if (!count($poswords)) return true;
370
371     $reg  = '^(?=.*?'.join(')(?=.*?',$poswords).')';
372             $reg .= count($negwords) ? '((?!'.join('|',$negwords).').)*$' : '.*$';
373             search_regex($data,$base,$file,$reg,$poswords);
374             return true;
375             }
376
377             /**
378              * Reference search
379              * This fuction searches for existing references to a given media file
380              * and returns an array with the found pages. It doesn't pay any
381              * attention to ACL permissions to find every reference. The caller
382              * must check if the user has the appropriate rights to see the found
383              * page and eventually have to prevent the result from displaying.
384              *
385              * @param array  $data Reference to the result data structure
386              * @param string $base Base usually $conf['datadir']
387              * @param string $file current file or directory relative to $base
388              * @param char   $type Type either 'd' for directory or 'f' for file
389              * @param int    $lvl  Current recursion depht
390              * @param mixed  $opts option array as given to search()
391              *
392              * $opts['query'] is the demanded media file name
393              *
394              * @author  Andreas Gohr <andi@splitbrain.org>
395              * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
396              */
397 function search_reference(&$data,$base,$file,$type,$lvl,$opts){
398     global $conf;
399
400     //we do nothing with directories
401     if($type == 'd') return true;
402
403     //only search txt files
404     if(substr($file,-4) != '.txt') return true;
405
406     //we finish after 'cnt' references found. The return value
407     //'false' will skip subdirectories to speed search up.
408     $cnt = $conf['refshow'] > 0 ? $conf['refshow'] : 1;
409     if(count($data) >= $cnt) return false;
410
411     $reg = '\{\{ *\:?'.$opts['query'].' *(\|.*)?\}\}';
412     search_regex($data,$base,$file,$reg,array($opts['query']));
413     return true;
414 }
415
416 /* ------------- helper functions below -------------- */
417
418 /**
419  * fulltext search helper
420  * searches a text file with a given regular expression
421  * no ACL checks are performed. This have to be done by
422  * the caller if necessary.
423  *
424  * @param array  $data  reference to array for results
425  * @param string $base  base directory
426  * @param string $file  file name to search in
427  * @param string $reg   regular expression to search for
428  * @param array  $words words that should be marked in the results
429  *
430  * @author  Andreas Gohr <andi@splitbrain.org>
431  * @author  Matthias Grimm <matthiasgrimm@users.sourceforge.net>
432  *
433  * @deprecated - fulltext indexer is used instead
434  */
435 function search_regex(&$data,$base,$file,$reg,$words){
436
437     //get text
438     $text = io_readfile($base.'/'.$file);
439     //lowercase text (u modifier does not help with case)
440     $lctext = utf8_strtolower($text);
441
442     //do the fulltext search
443     $matches = array();
444     if($cnt = preg_match_all('#'.$reg.'#usi',$lctext,$matches)){
445         //this is not the best way for snippet generation but the fastest I could find
446         $q = $words[0];  //use first word for snippet creation
447         $p = utf8_strpos($lctext,$q);
448         $f = $p - 100;
449         $l = utf8_strlen($q) + 200;
450         if($f < 0) $f = 0;
451         $snippet = '<span class="search_sep"> ... </span>'.
452             htmlspecialchars(utf8_substr($text,$f,$l)).
453             '<span class="search_sep"> ... </span>';
454         $mark    = '('.join('|', $words).')';
455         $snippet = preg_replace('#'.$mark.'#si','<strong class="search_hit">\\1</strong>',$snippet);
456
457         $data[] = array(
458                 'id'       => pathID($file),
459                 'count'    => preg_match_all('#'.$mark.'#usi',$lctext,$matches),
460                 'poswords' => join(' ',$words),
461                 'snippet'  => $snippet,
462                 );
463     }
464
465     return true;
466 }
467
468
469 /**
470  * fulltext sort
471  *
472  * Callback sort function for use with usort to sort the data
473  * structure created by search_fulltext. Sorts descending by count
474  *
475  * @author  Andreas Gohr <andi@splitbrain.org>
476  */
477 function sort_search_fulltext($a,$b){
478     if($a['count'] > $b['count']){
479         return -1;
480     }elseif($a['count'] < $b['count']){
481         return 1;
482     }else{
483         return strcmp($a['id'],$b['id']);
484     }
485 }
486
487 /**
488  * translates a document path to an ID
489  *
490  * @author  Andreas Gohr <andi@splitbrain.org>
491  * @todo    move to pageutils
492  */
493 function pathID($path,$keeptxt=false){
494     $id = utf8_decodeFN($path);
495     $id = str_replace('/',':',$id);
496     if(!$keeptxt) $id = preg_replace('#\.txt$#','',$id);
497     $id = trim($id, ':');
498     return $id;
499 }
500
501
502 /**
503  * This is a very universal callback for the search() function, replacing
504  * many of the former individual functions at the cost of a more complex
505  * setup.
506  *
507  * How the function behaves, depends on the options passed in the $opts
508  * array, where the following settings can be used.
509  *
510  * depth      int     recursion depth. 0 for unlimited
511  * keeptxt    bool    keep .txt extension for IDs
512  * listfiles  bool    include files in listing
513  * listdirs   bool    include namespaces in listing
514  * pagesonly  bool    restrict files to pages
515  * skipacl    bool    do not check for READ permission
516  * sneakyacl  bool    don't recurse into nonreadable dirs
517  * hash       bool    create MD5 hash for files
518  * meta       bool    return file metadata
519  * filematch  string  match files against this regexp
520  * idmatch    string  match full ID against this regexp
521  * dirmatch   string  match directory against this regexp when adding
522  * nsmatch    string  match namespace against this regexp when adding
523  * recmatch   string  match directory against this regexp when recursing
524  * showmsg    bool    warn about non-ID files
525  * showhidden bool    show hidden files too
526  * firsthead  bool    return first heading for pages
527  *
528  * @author Andreas Gohr <gohr@cosmocode.de>
529  */
530 function search_universal(&$data,$base,$file,$type,$lvl,$opts){
531     $item   = array();
532     $return = true;
533
534     // get ID and check if it is a valid one
535     $item['id'] = pathID($file,($type == 'd' || $opts['keeptxt']));
536     if($item['id'] != cleanID($item['id'])){
537         if($opts['showmsg'])
538             msg(hsc($item['id']).' is not a valid file name for DokuWiki - skipped',-1);
539         return false; // skip non-valid files
540     }
541     $item['ns']  = getNS($item['id']);
542
543     if($type == 'd') {
544         // decide if to recursion into this directory is wanted
545         if(!$opts['depth']){
546             $return = true; // recurse forever
547         }else{
548             $depth = substr_count($file,'/');
549             if($depth >= $opts['depth']){
550                 $return = false; // depth reached
551             }else{
552                 $return = true;
553             }
554         }
555         if($return && !preg_match('/'.$opts['recmatch'].'/',$file)){
556             $return = false; // doesn't match
557         }
558     }
559
560     // check ACL
561     if(!$opts['skipacl']){
562         if($type == 'd'){
563             $item['perm'] = auth_quickaclcheck($item['id'].':*');
564         }else{
565             $item['perm'] = auth_quickaclcheck($item['id']); //FIXME check namespace for media files
566         }
567     }else{
568         $item['perm'] = AUTH_DELETE;
569     }
570
571     // are we done here maybe?
572     if($type == 'd'){
573         if(!$opts['listdirs']) return $return;
574         if(!$opts['skipacl'] && $opts['sneakyacl'] && $item['perm'] < AUTH_READ) return false; //neither list nor recurse
575         if($opts['dirmatch'] && !preg_match('/'.$opts['dirmatch'].'/',$file)) return $return;
576         if($opts['nsmatch'] && !preg_match('/'.$opts['nsmatch'].'/',$item['ns'])) return $return;
577     }else{
578         if(!$opts['listfiles']) return $return;
579         if(!$opts['skipacl'] && $item['perm'] < AUTH_READ) return $return;
580         if($opts['pagesonly'] && (substr($file,-4) != '.txt')) return $return;
581         if(!$opts['showhidden'] && isHiddenPage($item['id'])) return $return;
582         if($opts['filematch'] && !preg_match('/'.$opts['filematch'].'/',$file)) return $return;
583         if($opts['idmatch'] && !preg_match('/'.$opts['idmatch'].'/',$item['id'])) return $return;
584     }
585
586     // still here? prepare the item
587     $item['type']  = $type;
588     $item['level'] = $lvl;
589     $item['open']  = $return;
590
591     if($opts['meta']){
592         $item['file']       = utf8_basename($file);
593         $item['size']       = filesize($base.'/'.$file);
594         $item['mtime']      = filemtime($base.'/'.$file);
595         $item['rev']        = $item['mtime'];
596         $item['writable']   = is_writable($base.'/'.$file);
597         $item['executable'] = is_executable($base.'/'.$file);
598     }
599
600     if($type == 'f'){
601         if($opts['hash']) $item['hash'] = md5(io_readFile($base.'/'.$file,false));
602         if($opts['firsthead']) $item['title'] = p_get_first_heading($item['id'],METADATA_DONT_RENDER);
603     }
604
605     // finally add the item
606     $data[] = $item;
607     return $return;
608 }
609
610 //Setup VIM: ex: et ts=4 :