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