1<?php
2
3require_once(DOKU_INC . 'inc/fulltext.php');
4
5
6class PageQuery {
7
8    private $lang = array();
9    private $snippet_cnt = 0;
10
11    function __construct(Array $lang) {
12        $this->lang = $lang;
13    }
14
15
16    /**
17     * Render a simple "no results" message
18     *
19     * @param string $query => original query
20     * @param string $error
21     * @return string
22     */
23    function render_as_empty($query, $error = '') {
24
25        $render = '<div class="pagequery no-border">' . DOKU_LF;
26        $render .= '<p class="no-results"><span>pagequery</span>' . sprintf($this->lang["no_results"],
27                '<strong>' . $query . '</strong>') . '</p>' . DOKU_LF;
28        if ( ! empty($error)) {
29            $render .= '<p class="no-results">' . $error . '</p>' . DOKU_LF;
30        }
31        $render .= '</div>' . DOKU_LF;
32        return $render;
33    }
34
35
36    function render_as_html($layout, $sorted_results, $opt, $count) {
37        $this->snippet_cnt = $opt['snippet']['count'];
38        $render_type = '_render_as_html_' . $layout;
39        return $this->$render_type($sorted_results, $opt, $count);
40    }
41
42    /**
43     * Used by the render_as_html_table function below
44     * **DEPRECATED**
45     *
46     * @param $sorted_results
47     * @param $ratios
48     * @return int
49     */
50    private function _adjusted_height($sorted_results, $ratios) {
51        // ratio of different heading heights (%), to ensure more even use of columns (h1 -> h6)
52        $adjusted_height = 0;
53        foreach ($sorted_results as $row) {
54            $adjusted_height += $ratios[$row[0]];
55        }
56        return $adjusted_height;
57    }
58
59
60    /**
61     * Render the final pagequery results list as HTML, indented and in columns as required.
62     *
63     * **DEPRECATED** --- I would like to scrap this ASAP (old browsers only).
64     * It's complicated and it's hard to maintain.
65     *
66     * @param array  $sorted_results
67     * @param array  $opt
68     * @param int    $count => count of results
69     * @return string => HTML rendered list
70     */
71    protected function _render_as_html_table($sorted_results, $opt, $count) {
72
73        $ratios = array(.80, 1.3, 1.17, 1.1, 1.03, .96, .90);   // height ratios: link, h1, h2, h3, h4, h5, h6
74        $render = '';
75        $prev_was_heading = false;
76        $can_start_col = true;
77        $cont_level = 1;
78        $col = 0;
79        $multi_col = $opt['cols'] > 1;  // single columns are displayed without tables (better for TOC)
80        $col_height = $this->_adjusted_height($sorted_results, $ratios) / $opt['cols'];
81        $cur_height = 0;
82        $width = floor(100 / $opt['cols']);
83        $is_first = true;
84        $fontsize = '';
85        $list_style = '';
86        $indent_style = '';
87
88        // basic result page markup (always needed)
89        $outer_border = ($opt['border'] == 'outside' || $opt['border'] == 'both') ? 'border' : '';
90        $inner_border = ($opt['border'] == 'inside' || $opt['border'] == 'both') ? 'border' : '';
91        $tableless = ( ! $multi_col) ? 'tableless' : '';
92
93        // fixed anchor point to jump back to at top of the table
94        $top_id = 'top-' . mt_rand();
95
96        if ( ! empty($opt['fontsize'])) {
97            $fontsize = 'font-size:' . $opt['fontsize'];
98        }
99        if ($opt['bullet'] != 'none') {
100            $list_style = 'list-style-position:inside;list-style-type:' . $opt['bullet'];
101        }
102        $can_indent = $opt['group'];
103
104        $render .= '<div class="pagequery ' . $outer_border . " " . $tableless . '" id="' . $top_id . '" style="' . $fontsize . '">' . DOKU_LF;
105
106        if ($opt['showcount'] == true) {
107            $render .= '<div class="count">' . $count . '</div>' . DOKU_LF;
108        }
109        if ($opt['label'] != '') {
110            $render .= '<h1 class="title">' . $opt['label'] . '</h1>' . DOKU_LF;
111        }
112        if ($multi_col) {
113            $render .= '<table><tbody><tr>' . DOKU_LF;
114        }
115
116
117        // now render the pagequery list
118        foreach ($sorted_results as $line) {
119
120            list($level, $name, $id, $_, $abstract, $display) = $line;
121
122            $heading = '';
123            $is_heading = ($level > 0);
124            if ($is_heading) {
125                $heading = $name;
126            }
127
128            // is it time to start a new column?
129            if ($can_start_col === false && $col < $opt['cols'] && $cur_height >= $col_height) {
130                $can_start_col = true;
131                $col++;
132            }
133
134            // no need for indenting if there is no grouping
135            if ($can_indent) {
136                $indent = ($is_heading) ? $level - 1 : $cont_level - 1;
137                $indent_style = 'margin-left:' . $indent * 10 . 'px;';
138            }
139
140            // Begin new column if: 1) we are at the start, 2) last item was not a heading or 3) if there is no grouping
141            if ($can_start_col && ! $prev_was_heading) {
142                $jump_tip = sprintf($this->lang['jump_section'], $heading);
143                // close the previous column if necessary; also adds a 'jump to anchor'
144                $col_close = ( ! $is_heading) ? '<a title="'. $jump_tip . '" href="#' .
145                    $top_id . '">' . "<h$cont_level>§... </h$cont_level></a>" : '';
146                $col_close = ( ! $is_first) ? $col_close . '</ul></td>' . DOKU_LF : '';
147                $col_open = ( ! $is_first && ! $is_heading) ? '<h' . $cont_level . ' style="' . $indent_style . '">' . $heading . '...</h' . $cont_level . '>' : '';
148                $td = ($multi_col) ? '<td class="' . $inner_border . '" valign="top" width="' . $width . '%">' : '';
149                $render .= $col_close . $td . $col_open . DOKU_LF;
150                $can_start_col = false;
151
152                // needed to correctly style page link lists <ul>...
153                $prev_was_heading = true;
154                $cur_height = 0;
155            }
156
157            // finally display the appropriate heading or page link(s)
158            if ($is_heading) {
159                // close previous sub list if necessary
160                if ( ! $prev_was_heading) {
161                    $render .= '</ul>' . DOKU_LF;
162                }
163                if ($opt['nstitle'] && ! empty($display)) {
164                    $heading = $display;
165                }
166                if ($opt['proper'] == 'header' || $opt['proper'] == 'both') {
167                    $heading = $this->_proper($heading);
168                }
169                if ( ! empty($id)) {
170                    $heading = $this->_html_wikilink($id, $heading, '', $opt, false, true);
171                }
172                $render .= '<h' . $level . ' style="' . $indent_style . '">' . $heading . '</h' . $level . '>' . DOKU_LF;
173                $prev_was_heading = true;
174                $cont_level = $level + 1;
175            } else {
176                // open a new sub list if necessary
177                if ($prev_was_heading || $is_first) {
178                    $render .= '<ul style="' . $indent_style . $list_style . '">';
179                }
180                // deal with normal page links
181                if ($opt['proper'] == 'name' || $opt['proper'] == 'both') {
182                    $display = $this->_proper($display);
183                }
184                $link = $this->_html_wikilink($id, $display, $abstract, $opt);
185                $render .= $link;
186                $prev_was_heading = false;
187            }
188            $cur_height += $ratios[$level];
189            $is_first = false;
190        }
191        $render .= '</ul>' . DOKU_LF;
192        if ($multi_col) {
193            $render .= '</td></tr></tbody></table>' . DOKU_LF;
194        }
195        if ($opt['hidejump'] == false) {
196            $render .= '<a class="top" href="#' . $top_id . '">' . $this->lang['link_to_top'] . '</a>' . DOKU_LF;
197        }
198        $render .= '</div>' . DOKU_LF;
199
200        return $render;
201    }
202
203
204    /**
205     * Render the final pagequery results list in an HTML column, indented and in columns as required
206     *
207     * @param array  $sorted_results
208     * @param array  $opt
209     * @param int    $count => count of results
210     *
211     * @return string HTML rendered list
212     */
213    protected function _render_as_html_column($sorted_results, $opt, $count) {
214
215        $prev_was_heading = false;
216        $cont_level = 1;
217        $is_first = true;
218        $top_id = 'top-' . mt_rand();   // A fixed anchor at top to jump back to
219
220        // CSS for the various display options
221        $fontsize = ( ! empty($opt['fontsize'])) ?
222            'font-size:' . $opt['fontsize'] : '';
223        $outer_border = ($opt['border'] == 'outside' || $opt['border'] == 'both') ?
224            'border' : '';
225        $inner_border =  ($opt['border'] == 'inside'|| $opt['border'] == 'both') ?
226            'inner-border' : '';
227        $show_count = ($opt['showcount'] == true) ?
228            '<div class="count">' . $count . ' ∞</div>' . DOKU_LF : '';
229        $label = ($opt['label'] != '') ?
230            '<h1 class="title">' . $opt['label'] . '</h1>' . DOKU_LF : '';
231        $show_jump = ($opt['hidejump'] === false) ?
232            '<a class="top" href="#' . $top_id . '">' . $this->lang['link_to_top'] . '</a>' . DOKU_LF : '';
233        $list_style = ($opt['bullet'] != 'none') ?
234            'list-style-position:inside;list-style-type:' . $opt['bullet'] : '';
235
236        // no grouping => no indenting
237        $can_indent = $opt['group'];
238
239        // now prepare the actual pagequery list
240        $pagequery = '';
241        foreach ($sorted_results as $line) {
242
243            list($level, $name, $id, $_, $abstract, $display) = $line;
244
245            $is_heading = ($level > 0);
246            $heading = ($is_heading) ? $name : '';
247
248            if ($can_indent) {
249                $indent = ($is_heading) ? $level - 1 : $cont_level - 1;
250                $indent_style = 'margin-left:' . $indent * 10 . 'px;';
251            } else {
252                $indent_style = '';
253            }
254
255            // finally display the appropriate heading...
256            if ($is_heading) {
257
258                // close previous subheading list if necessary
259                if ( ! $prev_was_heading) {
260                    $pagequery .= '</ul>' . DOKU_LF;
261                }
262                if ($opt['nstitle'] && ! empty($display)) {
263                    $heading = $display;
264                }
265                if ($opt['proper'] == 'header' || $opt['proper'] == 'both') {
266                    $heading = $this->_proper($heading);
267                }
268                if ( ! empty($id)) {
269                    $heading = $this->_html_wikilink($id, $heading, '', $opt, false, true);
270                }
271                $pagequery .= '<h' . $level . ' style="' . $indent_style . '">' . $heading . '</h' . $level . '>' . DOKU_LF;
272                $prev_was_heading = true;
273                $cont_level = $level + 1;
274
275            // ...or page link(s)
276            } else {
277                // open a new sub list if necessary
278                if ($prev_was_heading || $is_first) {
279                    $pagequery .= '<ul style="' . $indent_style . $list_style . '">';
280                }
281                // deal with normal page links
282                if ($opt['proper'] == 'name' || $opt['proper'] == 'both') {
283                    $display = $this->_proper($display);
284                }
285                $link = $this->_html_wikilink($id, $display, $abstract, $opt);
286                $pagequery .= $link;
287                $prev_was_heading = false;
288            }
289            $is_first = false;
290        }
291
292        // and put it all together for display
293        $render = '';
294        $render .= '<div class="pagequery ' . $outer_border . '" id="' . $top_id . '" style="' . $fontsize . '">' . DOKU_LF;
295        $render .= $show_count . $show_jump . $label . DOKU_LF;
296        $render .= '<div class="inner ' . $inner_border . '">' . DOKU_LF;
297        $render .= $pagequery . DOKU_LF;
298        $render .= '</ul>' . DOKU_LF;
299        $render .= '</div></div>' . DOKU_LF;
300        return $render;
301    }
302
303
304    /**
305     * Renders the page link, plus tooltip, abstract, casing, etc...
306     * @param string $id
307     * @param bool $display
308     * @param string $abstract
309     * @param array $opt
310     * @param bool $track_snippets
311     * @param bool $raw => non-formatted (no html)
312     * @return string
313     */
314    private function _html_wikilink($id, $display,  $abstract, $opt, $track_snippets = true, $raw = false) {
315
316        $id = (strpos($id, ':') === false) ? ':' . $id : $id;   // : needed for root pages (root level)
317
318        $link = html_wikilink($id, $display);
319        $type = $opt['snippet']['type'];
320        $inline = '';
321        $after = '';
322
323        if ($type == 'tooltip') {
324            $tooltip = str_replace("\n\n", "\n", $abstract);
325            $tooltip = htmlentities($tooltip, ENT_QUOTES, 'UTF-8');
326            $link = $this->_add_tooltip($link, $tooltip);
327
328        } elseif (in_array($type, array('quoted', 'plain', 'inline')) && $this->snippet_cnt > 0) {
329            $short = $this->_shorten($abstract, $opt['snippet']['extent']);
330            $short = htmlentities($short, ENT_QUOTES, 'UTF-8');
331            if ( ! empty($short)) {
332                if ($type == 'quoted' || $type == 'plain') {
333                    $more = html_wikilink($id, 'more');
334                    $after = trim($short);
335                    $after = str_replace("\n\n", "\n", $after);
336                    $after = str_replace("\n", '<br/>', $after);
337                    $after = '<div class="' . $type . '">' . $after . $more . '</div>' . DOKU_LF;
338                } elseif ($type == 'inline') {
339                    $inline .= '<span class=inline>' . $short . '</span>';
340                }
341            }
342        }
343
344        $border = ($opt['underline']) ? 'border' : '';
345        if ($raw) {
346            $wikilink = $link . $inline;
347        } else {
348            $wikilink = '<li class="' . $border . '">' . $link . $inline . DOKU_LF . $after . '</li>';
349        }
350        if ($track_snippets) {
351            $this->snippet_cnt--;
352        }
353        return $wikilink;
354    }
355
356
357    /**
358     * Swap normal link title (popup) for a more useful preview
359     *
360     * @param string    $link
361     * @param string    $tooltip  title
362     * @return string   complete href link
363     */
364    private function _add_tooltip($link, $tooltip) {
365        $tooltip = str_replace("\n", '  ', $tooltip);
366        $link = preg_replace('/title=\".+?\"/', 'title="' . $tooltip . '"', $link, 1);
367        return $link;
368    }
369
370
371    /**
372     * Return the first part of the text according to the extent given.
373     *
374     * @param string $text
375     * @param string $extent  c? = ? chars, w? = ? words, l? = ? lines, ~? = search up to text/char/symbol
376     * @param string $more  symbol to show if more text
377     * @return  string
378     */
379    private function _shorten($text, $extent, $more = '... ') {
380        $elem = $extent[0];
381        $cnt = (int) substr($extent, 1);
382        switch ($elem) {
383            case 'c':
384                $result = substr($text, 0, $cnt);
385                if ($cnt > 0 && strlen($result) < strlen($text)) $result .= $more;
386                break;
387            case 'w':
388                $words = str_word_count($text, 1, '.');
389                $result = implode(' ', array_slice($words, 0, $cnt));
390                if ($cnt > 0 && $cnt <= count($words) && $words[$cnt - 1] != '.') $result .= $more;
391                break;
392            case 'l':
393                $lines = explode("\n", $text);
394                $lines = array_filter($lines);  // remove blank lines
395                $result = implode("\n", array_slice($lines, 0, $cnt));
396                if ($cnt > 0 && $cnt < count($lines)) $result .= $more;
397                break;
398            case "~":
399                $result = strstr($text, $cnt, true);
400                break;
401            default:
402                $result = $text;
403        }
404        return $result;
405    }
406
407
408    /**
409     * Changes a wiki page id into proper case (allowing for :'s etc...)
410     * @param string    $id    page id
411     * @return string
412     */
413    private function _proper($id) {
414        $id = str_replace(':', ': ', $id); // make a little whitespace before words so ucwords can work!
415        $id = str_replace('_', ' ', $id);
416        $id = utf8_ucwords($id);
417        $id = str_replace(': ', ':', $id);
418        return $id;
419    }
420
421
422    /**
423     * a mb version of 'ucwords' that respects capitalised words
424     * does not work for hyphenated words (yet)
425     * **UNUSED**
426     */
427    private function _mb_ucwords($str) {
428        $result = array();
429        $words = mb_split('\s', $str);
430        foreach ($words as $word) {
431            if (mb_strtoupper($word) == $word) {
432                $result[] = $word;
433            } else {
434                $result[] = mb_convert_case($word, MB_CASE_TITLE, "UTF-8");
435            }
436        }
437        return implode(' ', $result);
438    }
439
440
441    /**
442     * Parse out the namespace, and convert to a regex for array search
443     *
444     * @param  string $query user page query
445     * @return string        processed query with necessary regex markup for namespace recognition
446     */
447    function parse_ns_query($query) {
448        global $INFO;
449
450        $cur_ns = $INFO['namespace'];
451        $incl_ns = array();
452        $excl_ns = array();
453        $page_qry = '';
454        $tokens = explode(' ', trim($query));
455        if (count($tokens) == 1) {
456            $page_qry = $query;
457        } else {
458            foreach ($tokens as $token) {
459                if (preg_match('/^(?:\^|-ns:)(.+)$/u', $token, $matches)) {
460                    $excl_ns[] = resolve_id($cur_ns, $matches[1]);  // also resolve relative and parent ns
461                } elseif (preg_match('/^(?:@|ns:)(.+)$/u', $token, $matches)) {
462                    $incl_ns[] = resolve_id($cur_ns, $matches[1]);
463                } else {
464                    $page_qry .= ' ' . $token;
465                }
466            }
467        }
468        $page_qry = trim($page_qry);
469        return array($page_qry, $incl_ns, $excl_ns);
470    }
471
472
473    /**
474     * Builds the sorting array: array of arrays (0 = id, 1 = name, 2 = abstract, 3 = ... , etc)
475     *
476     * @param array     $ids    array of page ids to be sorted
477     * @param array     $opt    all user options/settings
478     *
479     * @return array    $sort_array     array of array(one value for each key to be sorted)
480     *                  $sort_opts      sorting options for the msort function
481     *                  $group_opts     grouping options for the mgroup function
482     */
483    function build_sorting_array($ids, $opt) {
484        global $conf;
485
486        $sort_array = array();
487        $sort_opts = array();
488        $group_opts = array();
489
490        $dformat = array();
491        $wformat = array();
492
493        $cnt = 0;
494
495        // look for 'abc' by title instead of name ('abc' by page-id makes little sense)
496        // title takes precedence over name (should you try to sort by both...why?)
497        $from_title = (isset($opt['sort']['title'])) ? true : false;
498
499        // is it necessary to cache the abstract column?
500        $get_abstract = ($opt['snippet']['type'] != 'none');
501
502        // add any extra columns needed for filtering!
503        $extrakeys = array_diff_key($opt['filter'], $opt['sort']);
504        $col_keys = array_merge($opt['sort'], $extrakeys);
505
506        // it is more efficient to get all the back-links at once from the indexer metadata
507        if (isset($col_keys['backlinks'])) {
508            $backlinks = idx_get_indexer()->lookupKey('relation_references', $ids);
509        }
510
511        foreach ($ids as $id) {
512
513            // getting metadata is very time-consuming, hence ONCE per displayed row
514            $meta = p_get_metadata($id, '', METADATA_DONT_RENDER);
515
516            if ( ! isset($meta['date']['created'])) {
517                $meta['date']['created'] = 0;
518            }
519            if ( ! isset($meta['date']['modified'])) {
520                $meta['date']['modified'] = $meta['date']['created'];
521            }
522            // establish page name (without namespace)
523            $name = noNS($id);
524
525            // ref to current row, used through out function
526            $row = &$sort_array[$cnt];
527
528            // first column is the basic page id
529            $row['id'] = $id;
530
531            // second column is the display 'name' (used when sorting by 'name')
532            // this also avoids rebuilding the display name when building links later (DRY)
533            $row['name'] = $name;
534
535            // third column: cache the display name; taken from metadata => 1st heading
536            // used when sorting by 'title'
537            $title = (isset($meta['title']) && ! empty($meta['title'])) ? $meta['title'] : $name;
538            $row['title'] = $title;
539
540            // needed later in the a, ab ,abc clauses
541            $abc = ($from_title) ? $title : $name;
542
543            // fourth column: cache the page abstract if needed; this saves a lot of time later
544            // and avoids repeated slow metadata retrievals (v. slow!)
545            $abstract = ($get_abstract) ? $meta['description']['abstract'] : '';
546            $row['abstract'] = $abstract;
547
548            // fifth column is the displayed text for links; set below
549            $row['display'] = '';
550
551            // reset cache of full date for this row
552            $real_date = 0;
553
554            // ...optional columns
555            foreach ($col_keys as $key => $void) {
556                $value = '';
557                switch ($key) {
558                    case 'a':
559                    case 'ab':
560                    case 'abc':
561                        $value = $this->_first($abc, strlen($key));
562                        break;
563                    case 'name':
564                    case 'title':
565                        // name/title columns already exists by default (col 1,2)
566                        // save a few microseconds by just moving on to the next key
567                        continue 2;
568                    case 'id':
569                        $value = $id;
570                        break;
571                    case 'ns':
572                        $value = getNS($id);
573                        if (empty($value)) {
574                            $value = '[' . $conf['start'] . ']';
575                        }
576                        break;
577                    case 'creator':
578                        $value = $meta['creator'];
579                        break;
580                    case 'contributor':
581                        $value = implode(' ', $meta['contributor']);
582                        break;
583                    case 'mdate':
584                        $value = $meta['date']['modified'];
585                        break;
586                    case 'cdate':
587                        $value = $meta['date']['created'];
588                        break;
589                    case 'links':
590                        $value = $this->_join_keys_if(' ', $meta['relation']['references']);
591                        break;
592                    case 'backlinks':
593                        $value = implode(' ', current($backlinks));
594                        next($backlinks);
595                        break;
596                    default:
597                        // date sorting types (groupable)
598                        $dtype = $key[0];
599                        if ($dtype == 'c' || $dtype == 'm') {
600                            // we only set real date once per id (needed for grouping)
601                            // not per sort column--the date should remain same across all columns
602                            // this is always the last column!
603                            if ($real_date == 0) {
604                                $real_date = ($dtype == 'c') ? $meta['date']['created'] : $meta['date']['modified'];
605                                $row[self::MGROUP_REALDATE] = $real_date;
606                            }
607                            // only set date formats once per sort column/key (not per id!), i.e. on first row
608                            if ($cnt == 0) {
609                                $dformat[$key] = $this->_date_format($key);
610                                // collect date in word format for potential use in grouping
611                                $wformat[$key] = ($opt['spelldate']) ? $this->_date_format_words($dformat[$key]) : '';
612                            }
613                            // create a string date used for sorting only
614                            // (we cannot just use the real date otherwise it would not group correctly)
615                            $value = strftime($dformat[$key], $real_date);
616                        }
617                }
618                // set the optional column
619                $row[$key] = $value;
620            }
621
622            /* provide custom display formatting via string templating {...} */
623
624            $matches = array();
625            $display = $opt['display'];
626            $matched = preg_match_all('/\{(.+?)\}/', $display, $matches, PREG_SET_ORDER);
627
628            // first try to use the custom column names as entered by user
629            if ($matched > 0) {
630                foreach ($matches as $match) {
631                    $key = $match[1];
632                    $value = null;
633                    if (isset($row[$key])) {
634                        $value = $row[$key];
635                    } elseif (isset($meta[$key])) {
636                        $value = $meta[$key];
637                        // allow for nested meta keys (e.g. date:created)
638                    } elseif (strpos($key, ':') !== false) {
639                        $keys = explode(':', $key);
640                        if (isset($meta[$keys[0]][$keys[1]])) {
641                            $value = $meta[$keys[0]][$keys[1]];
642                        }
643                    } elseif ($key == 'mdate') {
644                        $value = $meta['date']['modified'];
645                    } elseif ($key == 'cdate') {
646                        $value = $meta['date']['created'];
647                    }
648                    if ( ! is_null($value)) {
649                        if (strpos($key, 'date') !== false) {
650                            $value = utf8_encode(strftime($opt['dformat'], $value));
651                        }
652                        $display = str_replace($match[0], $value, $display);
653                    }
654                }
655
656                // try to match any metadata field; to allow for plain single word display settings
657                // e.g. display=title or display=name
658            } elseif (isset($row[$display])) {
659                $display = $row[$display];
660
661                // if all else fails then use the page name (always available)
662            } else {
663                $display = $row['name'];
664            }
665            $row['display'] = $display;
666
667            $cnt++;
668        }
669
670
671        $idx = 0;
672        foreach ($opt['sort'] as $key => $value) {
673
674            $sort_opts['key'][] = $key;
675
676
677            // now the sort direction
678            switch ($value) {
679                case 'a':
680                case 'asc':
681                    $dir = self::MSORT_ASC;
682                    break;
683                case 'd':
684                case 'desc':
685                    $dir = self::MSORT_DESC;
686                    break;
687                default:
688                    switch ($key) {
689                        // sort dates descending by default; text ascending
690                        case 'a':
691                        case 'ab':
692                        case 'abc':
693                        case 'name':
694                        case 'title':
695                        case 'id':
696                        case 'ns':
697                        case 'creator':
698                        case 'contributor':
699                            $dir = self::MSORT_ASC;
700                            break;
701                        default:
702                            $dir = self::MSORT_DESC;
703                            break;
704                    }
705            }
706            $sort_opts['dir'][] = $dir;
707
708
709            // set the sort array's data type
710            switch ($key) {
711                case 'mdate':
712                case 'cdate':
713                    $type = self::MSORT_NUMERIC;
714                    break;
715                default:
716                    if ($opt['casesort']) {
717                        // case sensitive: a-z then A-Z
718                        $type = ($opt['natsort']) ? self::MSORT_NAT : self::MSORT_STRING;
719                    } else {
720                        // case-insensitive
721                        $type = ($opt['natsort']) ? self::MSORT_NAT_CASE : self::MSORT_STRING_CASE;
722                    }
723            }
724            $sort_opts['type'][] = $type;
725
726
727            // now establish grouping options
728            switch ($key) {
729                // name strings and full dates cannot be meaningfully grouped (no duplicates!)
730                case 'mdate':
731                case 'cdate':
732                case 'name':
733                case 'title':
734                case 'id':
735                    $group_by = self::MGROUP_NONE;
736                    break;
737                case 'ns':
738                    $group_by = self::MGROUP_NAMESPACE;
739                    break;
740                default:
741                    $group_by = self::MGROUP_HEADING;
742            }
743            if ($group_by != self::MGROUP_NONE) {
744                $group_opts['key'][$idx] = $key;
745                $group_opts['type'][$idx] = $group_by;
746                $group_opts['dformat'][$idx] = $wformat[$key];
747                $idx++;
748            }
749        }
750
751        return array($sort_array, $sort_opts, $group_opts);
752    }
753
754
755    private function _join_keys_if($delim, $arr) {
756        $result = '';
757        if ( ! empty($arr)) {
758            foreach ($arr as $key => $value) {
759                if ($value === true) {
760                    $result .= $key . $delim;
761                }
762            }
763            if ( ! empty($result)) {
764                $result = substr($result, 0, -1);
765            }
766        }
767        return $result;
768    }
769
770
771    // returns first $count letters from $text in lowercase
772    private function _first($text, $count) {
773        $result = ($count > 0) ? utf8_substr(utf8_strtolower($text), 0, $count) : '';
774        return $result;
775    }
776
777
778    /**
779     * Parse the c|m-year-month-day option; used for sorting/grouping
780     *
781     * @param string  $key
782     * @return string
783     */
784    private function _date_format($key) {
785        $dkey = array();
786        if (strpos($key, 'year') !== false) $dkey[] = '%Y';
787        if (strpos($key, 'month') !== false) $dkey[] = '%m';
788        if (strpos($key, 'day') !== false) $dkey[] = '%d';
789        $dformat = implode('-', $dkey);
790        return $dformat;
791    }
792
793
794    /**
795     * Provide month and day format in real words if required
796     * used for display only ($dformat is used for sorting/grouping)
797     *
798     * @param string $dformat
799     * @return string
800     */
801    private function _date_format_words($dformat) {
802        $wformat = '';
803        switch ($dformat) {
804            case '%m':
805                $wformat = "%B";
806                break;
807            case '%d':
808                $wformat = "%#d–%A ";
809                break;
810            case '%Y-%m':
811                $wformat = "%B %Y";
812                break;
813            case '%m-%d':
814                $wformat= "%B %#d, %A ";
815                break;
816            case '%Y-%m-%d':
817                $wformat = "%A, %B %#d, %Y";
818                break;
819        }
820        return $wformat;
821    }
822
823
824    /**
825     * Just a wrapper around the Dokuwiki pageSearch function.
826     *
827     * @param $query
828     * @return mixed
829     */
830    function page_search($query) {
831        $result = array_keys(ft_pageSearch($query, $highlight));
832        return $result;
833    }
834
835
836    /**
837     * A heavily customised version of _ft_pageLookup in inc/fulltext.php
838     * no sorting!
839     */
840    function page_lookup($query, $fullregex, $incl_ns, $excl_ns) {
841        global $conf;
842
843        $query = trim($query);
844        $pages = file($conf['indexdir'] . '/page.idx');
845
846        if ( ! $fullregex) {
847            // first deal with excluded namespaces, then included, order matters!
848            $pages = $this->_filter_ns($pages, $excl_ns, true);
849            $pages = $this->_filter_ns($pages, $incl_ns, false);
850        }
851
852        $cnt = count($pages);
853        for ($i = 0; $i < $cnt; $i++) {
854            $page = $pages[$i];
855            if ( ! page_exists($page) || isHiddenPage($page)) {
856                unset($pages[$i]);
857                continue;
858            }
859            if ( ! $fullregex) $page = noNS($page);
860            /*
861             * This is the actual "search" expression.
862             * Note: preg_grep cannot be used because we need to
863             *  allow for beginning of string "^" regex on normal page search
864             *  and the page-exists check above
865             * The @ prevents problems with invalid queries!
866             */
867            $matched = @preg_match('/' . $query . '/i', $page);
868            if ($matched === false) {
869                return false;
870            } elseif ($matched == 0) {
871                unset($pages[$i]);
872            }
873        }
874        if (count($pages) > 0) {
875            return $pages;
876        } else {
877            // we always return an array type
878            return array();
879        }
880    }
881
882
883    function validate_pages($pages, $nostart = true, $maxns = 0) {
884        global $conf;
885
886        $pages = array_map('trim',$pages);
887
888        // check ACL permissions, too many ns levels, and remove any 'start' pages as needed
889        $start = $conf['start'];
890        $offset = strlen($start);
891        foreach($pages as $idx => $name) {
892            if ($nostart && substr($name, -$offset) == $start) {
893                unset($pages[$idx]);
894            } elseif ($maxns > 0 && (substr_count($name,':')) > $maxns) {
895                unset($pages[$idx]);
896                // TODO: this function is one of slowest in the plugin; solutions?
897            } elseif(auth_quickaclcheck($pages[$idx]) < AUTH_READ) {
898                unset($pages[$idx]);
899            }
900        }
901        return $pages;
902    }
903
904
905    /**
906     * Include/Exclude specific namespaces from a list of pages
907     * @param array $pages      a list of wiki page ids
908     * @param array $ns_qry    namespace(s) to include/exclude
909     * @param string $exclude   true = exclude
910     * @return array
911     */
912    private function _filter_ns($pages, $ns_qry, $exclude) {
913        $invert = ($exclude) ? PREG_GREP_INVERT : 0;
914        foreach ($ns_qry as $ns) {
915            //  we only look for namespace from beginning of the id
916            $regexes[] = '^' . $ns . ':.*';
917        }
918        if ( ! empty($regexes)) {
919            $regex = '/(' . implode('|', $regexes) . ')/';
920            $result = array_values(preg_grep($regex, $pages, $invert));
921        } else {
922            $result = $pages;
923        }
924        return $result;
925    }
926
927
928    /**
929     * filter array of pages by specific meta data keys (or columns)
930     *
931     * @param array     $sort_array full sorting array, all meta columns included
932     * @param array     $filter     meta-data filter: <meta key>:<query>
933     * @return array
934     */
935    function filter_meta($sort_array, $filter) {
936        foreach ($filter as $metakey => $expr) {
937            // allow for exclusion matches (put ^ or ! in front of meta key)
938            $exclude = false;
939            if ($metakey[0] == '^' || $metakey[0] == '!') {
940                $exclude = true;
941                $metakey = substr($metakey, 1);
942            }
943            $that = $this;
944            $sort_array = array_filter($sort_array, function($row) use ($metakey, $expr, $exclude, $that) {
945                if ( ! isset($row[$metakey])) return false;
946                if (strpos($metakey, 'date') !== false) {
947                    $match = $that->_filter_by_date($expr, $row[$metakey]);
948                } else {
949                    $match = preg_match('`' . $expr . '`', $row[$metakey]) > 0;
950                }
951                if ($exclude) $match = ! $match;
952                return $match;
953            });
954        }
955        return $sort_array;
956    }
957
958
959    private function _filter_by_date($filter, $date) {
960        $filter = str_replace('/', '.', $filter);  // allow for Euro style date formats
961        $filters = explode('->', $filter);
962        $begin = (empty($filters[0]) ? null : strtotime($filters[0]));
963        $end   = (empty($filters[1]) ? null : strtotime($filters[1]));
964
965        $matched = false;
966        if ($begin !== null && $end !== null) {
967            $matched = ($date >= $begin && $date <= $end);
968        } elseif ($begin !== null) {
969            $matched = ($date >= $begin);
970        } elseif ($end !== null) {
971            $matched = ($date <= $end);
972        }
973        return $matched;
974    }
975
976
977    /****************************
978     * SORTING HELPER FUNCTIONS *
979     ****************************
980     *
981     */
982
983    /* msort options
984     *
985     */
986
987    // keep key associations
988    const MSORT_KEEP_ASSOC = 'msort01';
989
990    // additional sorting type
991    const MSORT_NUMERIC = 'msort02';
992    const MSORT_REGULAR = 'msort03';
993    const MSORT_STRING = 'msort04';
994    const MSORT_STRING_CASE = 'msort05'; // case insensitive
995    const MSORT_NAT = 'msort06';         // natural sorting
996    const MSORT_NAT_CASE = 'msort07';    // natural sorting, case insensitive
997
998    const MSORT_ASC = 'msort08';
999    const MSORT_DESC = 'msort09';
1000
1001    const MSORT_DEFAULT_DIRECTION = self::MSORT_ASC;
1002    const MSORT_DEFAULT_TYPE = self::MSORT_STRING;
1003
1004    /**
1005     * A replacement for "array_mulitsort" which permits natural and case-less sorting
1006     * This function will sort an 'array of rows' only (not array of 'columns')
1007     *
1008     * @param array $sort_array  : multi-dimensional array of arrays, where the first index refers to the row number
1009     *                             and the second to the column number (e.g. $array[row_number][column_number])
1010     *                             i.e. = array(
1011     *                                          array('name1', 'job1', 'start_date1', 'rank1'),
1012     *                                          array('name2', 'job2', 'start_date2', 'rank2'),
1013     *                                          ...
1014     *                                          );
1015     *
1016     * @param mixed $sort_opts   : options for how the array should be sorted
1017     *                    :AS ARRAY
1018     *                             $sort_opts['key'][<column>] = 'key'
1019     *                             $sort_opts['type'][<column>] = 'type'
1020     *                             $sort_opts['dir'][<column>] = 'dir'
1021     *                             $sort_opts['assoc'][<column>] = MSORT_KEEP_ASSOC | true
1022     * @return boolean
1023     */
1024    function msort(&$sort_array, $sort_opts) {
1025
1026        // if a full sort_opts array was passed
1027        $keep_assoc = false;
1028        if (is_array($sort_opts) && ! empty($sort_opts)) {
1029            if (isset($sort_opts['assoc'])) {
1030                $keep_assoc = true;
1031            }
1032        } else {
1033            return false;
1034        }
1035
1036        // Determine which u..sort function (with or without associations).
1037        $sort_func = ($keep_assoc) ? 'uasort' : 'usort';
1038
1039        $keys = $sort_opts['key'];
1040
1041        // HACK: self:: does not work inside a closure so...
1042        $self = __CLASS__;
1043
1044        // Sort the data and get the result.
1045        $result = $sort_func (
1046            $sort_array,
1047            function(array &$left, array &$right) use(&$sort_opts, $keys, $self) {
1048
1049                // Assume that the entries are the same.
1050                $cmp = 0;
1051
1052                // Work through each sort column
1053                foreach($keys as $idx => $key) {
1054                    // Handle the different sort types.
1055                    switch ($sort_opts['type'][$idx]) {
1056                        case $self::MSORT_NUMERIC:
1057                            $key_cmp = ((intval($left[$key]) == intval($right[$key])) ? 0 :
1058                                ((intval($left[$key]) < intval($right[$key])) ? -1 : 1 ) );
1059                            break;
1060
1061                        case $self::MSORT_STRING:
1062                            $key_cmp = strcmp((string)$left[$key], (string)$right[$key]);
1063                            break;
1064
1065                        case $self::MSORT_STRING_CASE: //case-insensitive
1066                            $key_cmp = strcasecmp((string)$left[$key], (string)$right[$key]);
1067                            break;
1068
1069                        case $self::MSORT_NAT:
1070                            $key_cmp = strnatcmp((string)$left[$key], (string)$right[$key]);
1071                            break;
1072
1073                        case $self::MSORT_NAT_CASE:    //case-insensitive
1074                            $key_cmp = strnatcasecmp((string)$left[$key], (string)$right[$key]);
1075                            break;
1076
1077                        case $self::MSORT_REGULAR:
1078                        default :
1079                            $key_cmp = (($left[$key] == $right[$key]) ? 0 :
1080                                (($left[$key] < $right[$key]) ? -1 : 1 ) );
1081                            break;
1082                    }
1083
1084                    // Is the column in the two arrays the same?
1085                    if ($key_cmp == 0) {
1086                        continue;
1087                    }
1088
1089                    // Are we sorting descending?
1090                    $cmp = $key_cmp * (($sort_opts['dir'][$idx] == $self::MSORT_DESC) ? -1 : 1);
1091
1092                    // no need for remaining keys as there was a difference
1093                    break;
1094                }
1095                return $cmp;
1096            }
1097        );
1098        return $result;
1099    }
1100
1101    // grouping types
1102    const MGROUP_NONE = 'mgrp00';
1103    const MGROUP_HEADING = 'mgrp01';
1104    const MGROUP_NAMESPACE = 'mgrp02';
1105
1106    // real date column
1107    const MGROUP_REALDATE = '__realdate__';
1108
1109
1110    /**
1111     * group a multi-dimensional array by each level heading
1112     * @param array $sort_array : array to be grouped (result of 'msort' function)
1113     *                             __realdate__' column should contain real dates if you need dates in words
1114     * @param array $keys       : which keys (columns) should be returned in results array? (as keys)
1115     * @param mixed $group_opts :  AS ARRAY:
1116     *                             $group_opts['key'][<order>] = column key to group by
1117     *                             $group_opts['type'][<order>] = grouping type [MGROUP...]
1118     *                             $group_opts['dformat'][<order>] = date formatting string
1119     *
1120     * @return array $results   : array of arrays: (level, name, page_id, title), e.g. array(1, 'Main Title')
1121     *                              array(0, '...') =>  0 = normal row item (not heading)
1122     */
1123    function mgroup(&$sort_array, $keys, $group_opts = array()) {
1124        $level = count($group_opts['key']) - 1;
1125        $prevs = array();
1126        $results = array();
1127        $idx = 0;
1128
1129        if (empty($sort_array)) {
1130            $results =  array();
1131        } elseif (empty($group_opts)) {
1132            foreach ($sort_array as $row) {
1133                $result = array(0);
1134                foreach ($keys as $key) {
1135                    $result[] = $row[$key];
1136                }
1137                $results[] = $result;
1138            }
1139        } else {
1140            foreach($sort_array as $row) {
1141                $this->_add_heading($results, $sort_array, $group_opts, $level, $idx, $prevs);
1142                $result = array(0); // basic item (page link) is level 0
1143                for ($i = 0; $i < count($keys); $i++) {
1144                    $result[] = $row[$keys[$i]];
1145                }
1146                $results[] = $result;
1147                $idx++;
1148            }
1149        }
1150        return $results;
1151    }
1152
1153
1154    /**
1155     * private function used by mgroup only!
1156     */
1157    private function _add_heading(&$results, &$sort_array, &$group_opts, $level, $idx, &$prevs) {
1158        global $conf;
1159
1160        // recurse to find all parent headings
1161        if ($level > 0) {
1162            $this->_add_heading($results, $sort_array, $group_opts, $level - 1, $idx, $prevs);
1163        }
1164        $group_type = $group_opts['type'][$level];
1165
1166        $prev = (isset($prevs[$level])) ? $prevs[$level] : '';
1167        $key = $group_opts['key'][$level];
1168        $cur = $sort_array[$idx][$key];
1169        if ($cur != $prev) {
1170            $prevs[$level] = $cur;
1171
1172            if ($group_type === self::MGROUP_HEADING) {
1173                $date_format = $group_opts['dformat'][$level];
1174                if ( ! empty($date_format)) {
1175                    // the real date is always the '__realdate__' column (MGROUP_REALDATE)
1176                    $cur = strftime($date_format, $sort_array[$idx][self::MGROUP_REALDATE]);
1177                }
1178                // args : $level, $name, $id, $_, $abstract, $display
1179                $results[] = array($level + 1, $cur, '');
1180
1181            } elseif ($group_type === self::MGROUP_NAMESPACE) {
1182                $cur_ns = explode(':', $cur);
1183                $prev_ns = explode(':', $prev);
1184                // only show namespaces that are different from the previous heading
1185                for ($i = 0; $i < count($cur_ns); $i++) {
1186                    if ($cur_ns[$i] != $prev_ns[$i]) {
1187                        $hl = $level + $i + 1;
1188                        $id = implode(':', array_slice($cur_ns, 0, $i + 1)) . ':' . $conf['start'];
1189                        if (page_exists($id)) {
1190                            $ns_start = $id;
1191                            // allow the first heading to be used instead of page id/name
1192                            $display = p_get_metadata($id, 'title');
1193                        } else {
1194                            $ns_start = $display = '';
1195                        }
1196                        // args : $level, $name, $id, $_, $abstract, $display
1197                        $results[] = array($hl , $cur_ns[$i], $ns_start, '', '', $display);
1198                    }
1199                }
1200            }
1201        }
1202    }
1203
1204}
1205