1<?php
2
3use dokuwiki\Extension\SyntaxPlugin;
4
5/**
6 * PageQuery Plugin: search for and list pages, sorted/grouped by name, date, creator, etc
7 *
8 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
9 * @author     Symon Bent <hendrybadao@gmail.com>
10 *
11 * @phpcs      :disable Squiz.Classes.ValidClassName.NotCamelCaps
12 */
13class syntax_plugin_pagequery extends SyntaxPlugin
14{
15    public const MAX_COLS = 12;
16
17    public function getType(): string
18    {
19        return 'substition';
20    }
21
22    public function getPType(): string
23    {
24        return 'block';
25    }
26
27    public function getSort(): int
28    {
29        return 98;
30    }
31
32    public function connectTo($mode): void
33    {
34        // this regex allows multi-line syntax for easier composition/reading
35        $this->Lexer->addSpecialPattern('\{\{pagequery>(?m).*?(?-m)\}\}', $mode, 'plugin_pagequery');
36    }
37
38    /**
39     * Parses all the pagequery options:
40     * Insert the pagequery markup wherever you want your list to appear. E.g:
41     *
42     *   {{pagequery>}}
43     *
44     *   {{pagequery>[query];fulltext;sort=key:direction,key2:direction;group;limit=??;cols=?;spelldate;proper}}
45     *
46     * @link https://www.dokuwiki.org/plugin:pagequery See PageQuery page for full details
47     */
48    public function handle($match, $state, $pos, Doku_Handler $handler): array
49    {
50        $opt    = [];
51        $match  = substr($match, 12, -2); // strip markup "{{pagequery>...}}"
52        $params = explode(';', $match);
53        // remove any pre/trailing spaces due to multi-line syntax
54        $params = array_map('trim', $params);
55
56        $opt['query'] = $params[0];
57
58        // establish some basic option defaults
59        $opt['border']    = 'none';     // show borders around entire list and/or between columns
60        $opt['bullet']    = 'none';     // bullet style for list items
61        $opt['casesort']  = false;      // allow case sorting
62        $opt['cols']      = 1;          // number of displayed columns (fixed for table layout, max for column layout
63        $opt['dformat']   = "%d %b %Y"; // general display date format
64        $opt['display']   = 'name';     // how page links should be displayed
65        $opt['filter']    = [];    // filtering by metadata prior to sorting
66        $opt['fontsize']  = '';         // base fontsize of pagequery; best to use %
67        $opt['fullregex'] = false;      // power-user regex search option--file name only
68        $opt['fulltext']  = false;      // search full-text; including file contents
69        $opt['group']     = false;      // group the results based on sort headings
70        $opt['hidejump']  = false;      // hide the jump to top link
71        $opt['hidemsg']   = false;      // hide any error messages
72        $opt['hidestart'] = false;      // hide start pages
73        $opt['label']     = '';         // label to put at top of the list
74        $opt['layout']    = 'table';    // html layout type: table (1 col = div only) or columns (html 5 only)
75        $opt['limit']     = 0;          // limit results to certain number
76        $opt['maxns']     = 0;          // max number of namespaces to display (i.e. ns depth)
77        $opt['natsort']   = false;      // allow natural case sorting
78        $opt['proper']    = 'none';     // display file names in Proper Case
79        $opt['showcount'] = false;      // show the count of links found
80        $opt['snippet']   = ['type' => 'none', 'count' => 0, 'extent' => '']; // show content snippets/abstracts
81        $opt['sort']      = [];    // sort by various headings
82        $opt['spelldate'] = false;      // spell out date headings in words where possible
83        $opt['underline'] = false;      // faint underline below each link for clarity
84        $opt['nstitle']   = false;      // internal use currently...
85
86        foreach ($params as $param) {
87            [$option, $value] = $this->keyvalue($param, '=');
88            switch ($option) {
89                case 'casesort':
90                case 'fullregex':
91                case 'fulltext':
92                case 'group':
93                case 'hidejump':
94                case 'hidemsg':
95                case 'hidestart':
96                case 'natsort':
97                case 'showcount':
98                case 'spelldate':
99                case 'underline':
100                    $opt[$option] = true;
101                    break;
102                case 'limit':
103                case 'maxns':
104                    $opt[$option] = abs($value);
105                    break;
106                case 'sort':
107                case 'filter':
108                    $fields = explode(',', $value);
109                    foreach ($fields as $field) {
110                        [$key, $expr] = $this->keyvalue($field);
111                        // allow for a few common naming differences
112                        switch ($key) {
113                            case 'pagename':
114                                $key = 'name';
115                                break;
116                            case 'heading':
117                            case 'firstheading':
118                                $key = 'title';
119                                break;
120                            case 'pageid':
121                            case 'id':
122                                $key = 'id';
123                                break;
124                            case 'contrib':
125                                $key = 'contributor';
126                                break;
127                        }
128                        $opt[$option][$key] = $expr;
129                    }
130                    break;
131                case 'proper':
132                    switch ($value) {
133                        case 'hdr':
134                        case 'header':
135                        case 'group':
136                            $opt['proper'] = 'header';
137                            break;
138                        case 'name':
139                        case 'page':
140                            $opt['proper'] = 'name';
141                            break;
142                        default:
143                            $opt['proper'] = 'both';
144                    }
145                    break;
146                case 'cols':
147                    $opt['cols'] = ($value > self::MAX_COLS) ? self::MAX_COLS : $value;
148                    break;
149                case 'border':
150                    switch ($value) {
151                        case 'both':
152                        case 'inside':
153                        case 'outside':
154                        case 'none':
155                            $opt['border'] = $value;
156                            break;
157                        default:
158                            $opt['border'] = 'both';
159                    }
160                    break;
161                case 'snippet':
162                    $default = 'tooltip';
163                    if (empty($value)) {
164                        $opt['snippet']['type'] = $default;
165                        break;
166                    } else {
167                        $options = explode(',', $value);
168                        $type    = (empty($options[0])) ? $opt['snippet']['type'] : $options[0];
169                        $count   = (empty($options[1])) ? $opt['snippet']['count'] : $options[1];
170                        $extent  = (empty($options[2])) ? $opt['snippet']['extent'] : $options[2];
171
172                        $valid = ['none', 'tooltip', 'inline', 'plain', 'quoted'];
173                        if (!in_array($type, $valid)) {
174                            $type = $default;  // empty snippet type => tooltip
175                        }
176                        $opt['snippet'] = ['type' => $type, 'count' => $count, 'extent' => $extent];
177                        break;
178                    }
179                case 'label':
180                case 'bullet':
181                    $opt[$option] = $value;
182                    break;
183                case 'display':
184                    switch ($value) {
185                        case 'name':
186                            $opt['display'] = 'name';
187                            break;
188                        case 'title':
189                        case 'heading':
190                        case 'firstheading':
191                            $opt['display'] = 'title';
192                            $opt['nstitle'] = true;
193                            break;
194                        case 'pageid':
195                        case 'id':
196                            $opt['display'] = 'id';
197                            break;
198                        default:
199                            $opt['display'] = $value;
200                    }
201                    if (preg_match('/{(title|heading|firstheading)}/', $value)) {
202                        $opt['nstitle'] = true;
203                    }
204                    break;
205                case 'layout':
206                    if (!in_array($value, ['table', 'column'])) {
207                        $value = 'table';
208                    }
209                    $opt['layout'] = $value;
210                    break;
211                case 'fontsize':
212                    if (!empty($value)) {
213                        $opt['fontsize'] = $value;
214                    }
215                    break;
216            }
217        }
218        return $opt;
219    }
220
221    /**
222     * Split a string into key => value parts.
223     *
224     * @param string $str
225     * @param string $delim
226     */
227    private function keyvalue(string $str, string $delim = ':'): array
228    {
229        $parts = explode($delim, $str);
230        $key   = $parts[0] ?? '';
231        $value = $parts[1] ?? '';
232        return [$key, $value];
233    }
234
235    public function render($format, Doku_Renderer $renderer, $data): bool
236    {
237        $incl_ns    = [];
238        $excl_ns    = [];
239        $sort_opts  = [];
240        $group_opts = [];
241        $message    = '';
242
243        $lang = ['jump_section' => $this->getLang('jump_section'), 'link_to_top'  => $this->getLang('link_to_top'),
244            'no_results'   => $this->getLang('no_results')];
245        require_once DOKU_PLUGIN . 'pagequery/PageQuery.php';
246        $pq = new PageQuery($lang);
247
248        $query = $data['query'];
249
250        if ($format === 'xhtml') {
251            // first get a raw list of matching results
252            if ($data['fulltext']) {
253                // full text searching (Dokuwiki style)
254                $results = $pq->pageSearch($query);
255            } else {
256                // page id searching (i.e. namespace and name, faster)
257
258                // fullregex option considers entire query to be a regex
259                // over the whole page id, incl. namespace
260                if (!$data['fullregex']) {
261                    [$query, $incl_ns, $excl_ns] = $pq->parseNamespaceQuery($query);
262                }
263
264                // Allow for a lazy man's option!
265                if ($query === '*') {
266                    $query = '.*';
267                }
268                // search by page name or path only
269                $results = $pq->pageLookup($query, $data['fullregex'], $incl_ns, $excl_ns);
270            }
271            $no_result = false;
272            $sort_array = [];
273            if ($results == false) {
274                $no_result = true;
275                $message   = $this->getLang('regex_error');
276            } elseif (!empty($results)) {
277                $results = $pq->validatePages($results, $data['hidestart'], $data['maxns']);
278                // prepare the necessary sorting arrays, as per users options
279                [$sort_array, $sort_opts, $group_opts] = $pq->buildSortingArray($results, $data);
280
281                // meta data filtering of the list is next
282                $sort_array = $pq->filterMetadata($sort_array, $data['filter']);
283                if ($sort_array === []) {
284                    $no_result = true;
285                    $message   = $this->getLang("empty_filter");
286                }
287            } else {
288                $no_result = true;
289            }
290            // successful search...
291            if (!$no_result) {
292                // now do the sorting
293                $pq->msort($sort_array, $sort_opts);
294                // limit the result list length if required; this can only be done after sorting!
295                if ($data['limit'] > 0) {
296                    $sort_array = array_slice($sort_array, 0, $data['limit']);
297                }
298                // do a link count BEFORE grouping (don't want to count headers...)
299                $count = count($sort_array);
300                // and finally the grouping
301                $keys = ['name', 'id', 'title', 'abstract', 'display'];
302                if (!$data['group']) {
303                    $group_opts = [];
304                }
305                $sorted_results = $pq->mgroup($sort_array, $keys, $group_opts);
306                // and out to the page
307                $renderer->doc .= $pq->renderAsHtml($data['layout'], $sorted_results, $data, $count);
308                // no results...
309            } elseif (!$data['hidemsg']) {
310                $renderer->doc .= $pq->renderAsEmpty($query, $message);
311            }
312            return true;
313        } elseif ($format === 'metadata') {
314            // this is a pagequery page needing PARSER_CACHE_USE event trigger;
315            $renderer->meta['pagequery'] = true;
316            unset($renderer->persistent['pagequery']);
317            return true;
318        } else {
319            return false;
320        }
321    }
322}
323