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