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