xref: /plugin/simplenavi/syntax.php (revision d418c0312966259c1e7c5b4672e7cb5733092796)
1<?php
2
3use dokuwiki\Extension\SyntaxPlugin;
4use dokuwiki\File\PageResolver;
5use dokuwiki\Utf8\Sort;
6
7/**
8 * DokuWiki Plugin simplenavi (Syntax Component)
9 *
10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
11 * @author  Andreas Gohr <gohr@cosmocode.de>
12 */
13class syntax_plugin_simplenavi extends SyntaxPlugin
14{
15    private $startpages = [];
16
17    /** @inheritdoc */
18    public function getType()
19    {
20        return 'substition';
21    }
22
23    /** @inheritdoc */
24    public function getPType()
25    {
26        return 'block';
27    }
28
29    /** @inheritdoc */
30    public function getSort()
31    {
32        return 155;
33    }
34
35    /** @inheritdoc */
36    public function connectTo($mode)
37    {
38        $this->Lexer->addSpecialPattern('{{simplenavi>[^}]*}}', $mode, 'plugin_simplenavi');
39    }
40
41    /** @inheritdoc */
42    public function handle($match, $state, $pos, Doku_Handler $handler)
43    {
44        return explode(' ', substr($match, 13, -2));
45    }
46
47    /** @inheritdoc */
48    public function render($format, Doku_Renderer $renderer, $data)
49    {
50        if ($format != 'xhtml') return false;
51
52        global $INFO;
53        $renderer->nocache();
54
55        // first data is namespace, rest is options
56        $ns = array_shift($data);
57        if ($ns && $ns[0] === '.') {
58            // resolve relative to current page
59            $ns = getNS((new PageResolver($INFO['id']))->resolveId("$ns:xxx"));
60        } else {
61            $ns = cleanID($ns);
62        }
63
64        $items = $this->getSortedItems(
65            $ns,
66            $INFO['id'],
67            $this->getConf('usetitle'),
68            $this->getConf('natsort'),
69            $this->getConf('nsfirst'),
70            in_array('home', $data)
71        );
72
73        $class = 'plugin__simplenavi';
74        if (in_array('filter', $data)) $class .= ' plugin__simplenavi_filter';
75
76        $renderer->doc .= '<div class="' . $class . '">';
77        $renderer->doc .= html_buildlist($items, 'idx', [$this, 'cbList'], [$this, 'cbListItem']);
78        $renderer->doc .= '</div>';
79
80        return true;
81    }
82
83    /**
84     * Fetch the items to display
85     *
86     * This returns a flat list suitable for html_buildlist()
87     *
88     * @param string $ns the namespace to search in
89     * @param string $current the current page, the tree will be expanded to this
90     * @param bool $useTitle Sort by the title instead of the ID?
91     * @param bool $useNatSort Use natural sorting or just sort by ASCII?
92     * @param bool $nsFirst Sort namespaces before pages?
93     * @param bool $home Add namespace's start page as top level item?
94     * @return array
95     */
96    public function getSortedItems($ns, $current, $useTitle, $useNatSort, $nsFirst, $home)
97    {
98        global $conf;
99
100        // convert to path
101        $nspath = utf8_encodeFN(str_replace(':', '/', $ns));
102
103        // get the start page of the main namespace, this adds it to the list of seen pages in $this->startpages
104        // and will skip it by default in the search callback
105        $startPage = $this->getMainStartPage($ns, $useTitle);
106
107        $items = [];
108        if ($home) {
109            // when home is requested, add the start page as top level item
110            $items[$startPage['id']] = $startPage;
111            $minlevel = 0;
112        } else {
113            $minlevel = 1;
114        }
115
116        // execute search using our own callback
117        search(
118            $items,
119            $conf['datadir'],
120            [$this, 'cbSearch'],
121            [
122                'currentID' => $current,
123                'usetitle' => $useTitle,
124            ],
125            $nspath,
126            1,
127            '' // no sorting, we do ourselves
128        );
129        if (!$items) return [];
130
131        // split into separate levels
132        $parents = [];
133        $levels = [];
134        $curLevel = $minlevel;
135        foreach ($items as $idx => $item) {
136            if ($curLevel < $item['level']) {
137                // previous item was the parent
138                $parents[] = array_key_last($levels[$curLevel]);
139            }
140            $curLevel = $item['level'];
141            $levels[$item['level']][$idx] = $item;
142        }
143
144        // sort each level separately
145        foreach ($levels as $level => $items) {
146            uasort($items, fn($a, $b) => $this->itemComparator($a, $b, $useNatSort, $nsFirst));
147            $levels[$level] = $items;
148        }
149
150        // merge levels into a flat list again
151        $levels = array_reverse($levels, true);
152        foreach (array_keys($levels) as $level) {
153            if ($level == $minlevel) break;
154
155            $parent = array_pop($parents);
156            $pos = array_search($parent, array_keys($levels[$level - 1])) + 1;
157
158            /** @noinspection PhpArrayAccessCanBeReplacedWithForeachValueInspection */
159            $levels[$level - 1] = array_slice($levels[$level - 1], 0, $pos, true) +
160                $levels[$level] +
161                array_slice($levels[$level - 1], $pos, null, true);
162        }
163
164        return $levels[$minlevel];
165    }
166
167    /**
168     * Compare two items
169     *
170     * @param array $a
171     * @param array $b
172     * @param bool $useNatSort
173     * @param bool $nsFirst
174     * @return int
175     */
176    public function itemComparator($a, $b, $useNatSort, $nsFirst)
177    {
178        if ($nsFirst && $a['type'] != $b['type']) {
179            return $a['type'] == 'd' ? -1 : 1;
180        }
181
182        if ($useNatSort) {
183            return Sort::strcmp($a['title'], $b['title']);
184        } else {
185            return strcmp($a['title'], $b['title']);
186        }
187    }
188
189
190    /**
191     * Create a list openening
192     *
193     * @param array $item
194     * @return string
195     * @see html_buildlist()
196     */
197    public function cbList($item)
198    {
199        global $INFO;
200
201        if (($item['type'] == 'd' && $item['open']) || $INFO['id'] == $item['id']) {
202            return '<strong>' . html_wikilink(':' . $item['id'], $item['title']) . '</strong>';
203        } else {
204            return html_wikilink(':' . $item['id'], $item['title']);
205        }
206    }
207
208    /**
209     * Create a list item
210     *
211     * @param array $item
212     * @return string
213     * @see html_buildlist()
214     */
215    public function cbListItem($item)
216    {
217        if ($item['type'] == "f") {
218            return '<li class="level' . $item['level'] . '">';
219        } elseif ($item['open']) {
220            return '<li class="open">';
221        } else {
222            return '<li class="closed">';
223        }
224    }
225
226    /**
227     * Custom search callback
228     *
229     * @param $data
230     * @param $base
231     * @param $file
232     * @param $type
233     * @param $lvl
234     * @param array $opts - currentID is the currently shown page
235     * @return bool
236     */
237    public function cbSearch(&$data, $base, $file, $type, $lvl, $opts)
238    {
239        global $conf;
240        $return = true;
241
242        $id = pathID($file);
243
244        if (
245            $type == 'd' && !(
246                preg_match('#^' . $id . '(:|$)#', $opts['currentID']) ||
247                preg_match('#^' . $id . '(:|$)#', getNS($opts['currentID']))
248
249            )
250        ) {
251            //add but don't recurse
252            $return = false;
253        } elseif ($type == 'f' && (!empty($opts['nofiles']) || substr($file, -4) != '.txt')) {
254            //don't add
255            return false;
256        }
257
258        // for sneaky index, check access to the namespace's start page
259        if ($type == 'd' && $conf['sneaky_index']) {
260            $sp = (new PageResolver(''))->resolveId($id . ':');
261            if (auth_quickaclcheck($sp) < AUTH_READ) {
262                return false;
263            }
264        }
265
266        if ($type == 'd') {
267            // link directories to their start pages
268            $original = $id;
269            $id = "$id:";
270            $id = (new PageResolver(''))->resolveId($id);
271            $this->startpages[$id] = 1;
272
273            // if the resolve id is in the same namespace as the original it's a start page named like the dir
274            if (getNS($original) === getNS($id)) {
275                $useNS = $original;
276            }
277        } elseif (!empty($this->startpages[$id])) {
278            // skip already shown start pages
279            return false;
280        }
281
282        //check hidden
283        if (isHiddenPage($id)) {
284            return false;
285        }
286
287        //check ACL
288        if ($type == 'f' && auth_quickaclcheck($id) < AUTH_READ) {
289            return false;
290        }
291
292        $data[$id] = [
293            'id' => $id,
294            'type' => $type,
295            'level' => $lvl,
296            'open' => $return,
297            'title' => $this->getTitle($id, $opts['usetitle']),
298            'ns' => $useNS ?? (string)getNS($id),
299        ];
300
301        return $return;
302    }
303
304    /**
305     * @param string $id
306     * @param bool $useTitle
307     * @return array
308     */
309    protected function getMainStartPage($ns, $useTitle)
310    {
311        $resolver = new PageResolver('');
312        $id = $resolver->resolveId($ns . ':');
313
314        $item = [
315            'id' => $id,
316            'type' => 'd',
317            'level' => 0,
318            'open' => true,
319            'title' => $this->getTitle($id, $useTitle),
320            'ns' => $ns,
321        ];
322        $this->startpages[$id] = 1;
323        return $item;
324    }
325
326    /**
327     * Get the title for the given page ID
328     *
329     * @param string $id
330     * @param bool $usetitle - use the first heading as title
331     * @return string
332     */
333    protected function getTitle($id, $usetitle)
334    {
335        global $conf;
336
337        if ($usetitle) {
338            $p = p_get_first_heading($id);
339            if (!empty($p)) return $p;
340        }
341
342        $p = noNS($id);
343        if ($p == $conf['start'] || !$p) {
344            $p = noNS(getNS($id));
345            if (!$p) {
346                return $conf['start'];
347            }
348        }
349        return $p;
350    }
351}
352