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