xref: /plugin/struct/types/Page.php (revision 996125f59dcf8fe8e4cb5fac35851bcf6557e8c3)
1<?php
2
3namespace dokuwiki\plugin\struct\types;
4
5use dokuwiki\File\PageResolver;
6use dokuwiki\plugin\struct\meta\QueryBuilder;
7use dokuwiki\plugin\struct\meta\QueryBuilderWhere;
8use dokuwiki\plugin\struct\meta\StructException;
9use dokuwiki\Utf8\PhpString;
10
11/**
12 * Class Page
13 *
14 * Represents a single page in the wiki. Will be linked in output.
15 *
16 * @package dokuwiki\plugin\struct\types
17 */
18class Page extends AbstractMultiBaseType
19{
20    protected $config = [
21        'usetitles' => false,
22        'autocomplete' => [
23            'mininput' => 2,
24            'maxresult' => 5,
25            'filter' => '',
26        ]
27    ];
28
29    /**
30     * Return the current configuration.
31     * Overwrites parent method to migrate deprecated options.
32     *
33     * @return array
34     */
35    public function getConfig()
36    {
37        // migrate autocomplete options 'namespace' and 'postfix' to 'filter'
38        if (empty($this->config['autocomplete']['filter'])) {
39            if (!empty($this->config['autocomplete']['namespace'])) {
40                $this->config['autocomplete']['filter'] = $this->config['autocomplete']['namespace'];
41                unset($this->config['autocomplete']['namespace']);
42            }
43            if (!empty($this->config['autocomplete']['postfix'])) {
44                $this->config['autocomplete']['filter'] .= '.+?' . $this->config['autocomplete']['postfix'] . '$';
45                unset($this->config['autocomplete']['namespace']);
46            }
47        }
48        return $this->config;
49    }
50
51    /**
52     * Output the stored data
53     *
54     * @param string $value the value stored in the database - JSON when titles are used
55     * @param \Doku_Renderer $R the renderer currently used to render the data
56     * @param string $mode The mode the output is rendered in (eg. XHTML)
57     * @return bool true if $mode could be satisfied
58     */
59    public function renderValue($value, \Doku_Renderer $R, $mode)
60    {
61        if ($this->config['usetitles']) {
62            [$id, $title] = \helper_plugin_struct::decodeJson($value);
63        } else {
64            $id = $value;
65            $title = $id; // cannot be empty or internallink() might hijack %pageid% and use headings
66        }
67
68        if (!$id) return true;
69
70        $R->internallink(":$id", $title);
71        return true;
72    }
73
74    /**
75     * Cleans the link
76     *
77     * @param string $rawvalue
78     * @return string
79     */
80    public function validate($rawvalue)
81    {
82        [$page, $fragment] = array_pad(explode('#', $rawvalue, 2), 2, '');
83        return cleanID($page) . (strlen(cleanID($fragment)) > 0 ? '#' . cleanID($fragment) : '');
84    }
85
86    /**
87     * Autocompletion support for pages
88     *
89     * @return array
90     */
91    public function handleAjax()
92    {
93        global $INPUT;
94
95        // check minimum length
96        $lookup = trim($INPUT->str('search'));
97        if (PhpString::strlen($lookup) < $this->config['autocomplete']['mininput']) return [];
98
99        // results wanted?
100        $max = $this->config['autocomplete']['maxresult'];
101        if ($max <= 0) return [];
102
103        $data = ft_pageLookup($lookup, true, $this->config['usetitles']);
104        if ($data === []) return [];
105
106        $filter = $this->config['autocomplete']['filter'];
107
108        // try to use deprecated options namespace and postfix as filter
109        $namespace = $this->config['autocomplete']['namespace'] ?? '';
110        $postfix = $this->config['autocomplete']['postfix'] ?? '';
111        if (!$filter) {
112            $filter = $namespace ? $namespace . ':' : '';
113            $filter .= $postfix ? '.+?' . $postfix . '$' : '';
114        }
115
116        // this basically duplicates what we do in ajax_qsearch() but with a filter
117        $result = [];
118        $counter = 0;
119        foreach ($data as $id => $title) {
120            if (!empty($filter) && !$this->filterMatch($id, $filter)) {
121                continue;
122            }
123            if ($this->config['usetitles']) {
124                $name = $title . ' (' . $id . ')';
125            } else {
126                $ns = getNS($id);
127                if ($ns) {
128                    $name = noNS($id) . ' (' . $ns . ')';
129                } else {
130                    $name = $id;
131                }
132            }
133
134            $result[] = [
135                'label' => $name,
136                'value' => $id
137            ];
138
139            $counter++;
140            if ($counter > $max) break;
141        }
142
143        return $result;
144    }
145
146    /**
147     * When using titles, we need ot join the titles table
148     *
149     * @param QueryBuilder $QB
150     * @param string $tablealias
151     * @param string $colname
152     * @param string $alias
153     */
154    public function select(QueryBuilder $QB, $tablealias, $colname, $alias)
155    {
156        if (!$this->config['usetitles']) {
157            parent::select($QB, $tablealias, $colname, $alias);
158            return;
159        }
160        $rightalias = $QB->generateTableAlias();
161        $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.$colname = $rightalias.pid");
162        $QB->addSelectStatement("STRUCT_JSON($tablealias.$colname, $rightalias.title)", $alias);
163    }
164
165    /**
166     * When using titles, sort by them first
167     *
168     * @param QueryBuilder $QB
169     * @param string $tablealias
170     * @param string $colname
171     * @param string $order
172     */
173    public function sort(QueryBuilder $QB, $tablealias, $colname, $order)
174    {
175        if (!$this->config['usetitles']) {
176            parent::sort($QB, $tablealias, $colname, $order);
177            return;
178        }
179
180        $rightalias = $QB->generateTableAlias();
181        $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.$colname = $rightalias.pid");
182        $QB->addOrderBy("$rightalias.title $order");
183        $QB->addOrderBy("$tablealias.$colname $order");
184    }
185
186    /**
187     * Return the pageid only
188     *
189     * @param string $value
190     * @return string
191     */
192    public function rawValue($value)
193    {
194        if ($this->config['usetitles']) {
195            [$value] = \helper_plugin_struct::decodeJson($value);
196        }
197        return $value;
198    }
199
200    /**
201     * Return the title only
202     *
203     * @param string $value
204     * @return string
205     */
206    public function displayValue($value)
207    {
208        if ($this->config['usetitles']) {
209            [$pageid, $value] = \helper_plugin_struct::decodeJson($value);
210            if (blank($value)) {
211                $value = $pageid;
212            }
213        }
214        return $value;
215    }
216
217    /**
218     * When using titles, we need to compare against the title table, too
219     *
220     * @param QueryBuilderWhere $add
221     * @param string $tablealias
222     * @param string $colname
223     * @param string $comp
224     * @param string $value
225     * @param string $op
226     */
227    public function filter(QueryBuilderWhere $add, $tablealias, $colname, $comp, $value, $op)
228    {
229        if (!$this->config['usetitles']) {
230            parent::filter($add, $tablealias, $colname, $comp, $value, $op);
231            return;
232        }
233
234        $QB = $add->getQB();
235        $rightalias = $QB->generateTableAlias();
236        $QB->addLeftJoin($tablealias, 'titles', $rightalias, "$tablealias.$colname = $rightalias.pid");
237
238        // compare against page and title
239        $sub = $add->where($op);
240        $pl = $QB->addValue($value);
241        $sub->whereOr("$tablealias.$colname $comp $pl");
242        $pl = $QB->addValue($value);
243        $sub->whereOr("$rightalias.title $comp $pl");
244    }
245
246    /**
247     * Check if the given id matches a configured filter pattern
248     *
249     * @param string $id
250     * @param string $filter
251     * @return bool
252     */
253    public function filterMatch($id, $filter)
254    {
255        // absolute namespace?
256        if (PhpString::substr($filter, 0, 1) === ':') {
257            $filter = '^' . $filter;
258        }
259
260        try {
261            $check = preg_match('/' . $filter . '/', ':' . $id, $matches);
262        } catch (\Exception $e) {
263            throw new StructException("Error processing regular expression '$filter'");
264        }
265        return (bool)$check;
266    }
267
268    /**
269     * Merge the current config with the base config of the type.
270     *
271     * In contrast to parent, this method does not throw away unknown keys.
272     * Required to migrate deprecated / obsolete options, no longer part of type config.
273     *
274     * @param array $current Current configuration
275     * @param array $config Base Type configuration
276     */
277    protected function mergeConfig($current, &$config)
278    {
279        foreach ($current as $key => $value) {
280            if (isset($config[$key]) && is_array($config[$key])) {
281                $this->mergeConfig($value, $config[$key]);
282            } else {
283                $config[$key] = $value;
284            }
285        }
286    }
287}
288