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