xref: /plugin/catmenu/syntax/catmenu.php (revision aa591c9040aa9d58df44eaf65df693766613dc9f)
1<?php
2/**
3 * Plugin catmenu
4 * Affiche les pages d'un namespace donné sous forme de menu hiérarchique.
5 * Auteur: Lortetv
6 */
7
8use dokuwiki\Extension\SyntaxPlugin;
9use dokuwiki\File\PageResolver;
10use dokuwiki\Ui\Index;
11
12class syntax_plugin_catmenu_catmenu extends SyntaxPlugin {
13    /** @var helper_plugin_pagesicon|null|false */
14    private $pagesiconHelper = false;
15
16    /** @var helper_plugin_catmenu_namespace|null */
17    private $nsHelper = null;
18
19    /**
20     * Retourne le helper namespace (chargé en lazy).
21     */
22    private function getNsHelper(): helper_plugin_catmenu_namespace
23    {
24        if ($this->nsHelper === null) {
25            $this->nsHelper = $this->loadHelper('catmenu_namespace');
26        }
27        return $this->nsHelper;
28    }
29
30    public function getType() {
31        return 'substition'; // substitution = remplacer la balise par du contenu
32    }
33
34    public function getPType() {
35        return 'block';
36    }
37
38    public function getSort() { // priorité du plugin par rapport à d'autres
39        return 15;
40    }
41
42    /**
43     * Reconnaît la syntaxe {{catmenu>[namespace]}}
44     */
45    public function connectTo($mode) {
46        $this->Lexer->addSpecialPattern('{{catmenu>.*?}}', $mode, 'plugin_catmenu_catmenu');
47    }
48
49    /**
50     * Nettoie {{catmenu>[namespace]}} et extrait le namespace.
51     */
52    public function handle($match, $state, $pos, Doku_Handler $handler) {
53        $namespace = trim(substr($match, 10, -2)); // retirer {{catmenu> et }}
54        return ['namespace' => $namespace];
55    }
56
57    public function render($mode, Doku_Renderer $renderer, $data) {
58        if ($mode !== 'xhtml') return false;
59
60        global $ID;
61        global $conf;
62
63        $random = uniqid();
64        $nsHelper = $this->getNsHelper();
65
66        if ($data['namespace'] === '.') { // Résolution du namespace courant
67            $namespace = $nsHelper->getCurrentNamespace($ID);
68        } else {
69            $namespace = cleanID($data['namespace']);
70        }
71
72        $pages = $this->getPagesAndSubfoldersItems($namespace);
73        if ($pages === false) {
74            $renderer->doc .= '<div>' . hsc($this->getLang('namespace_not_found')) . '</div>';
75            return true;
76        }
77
78        $renderer->doc .= '<div id="catmenu_' . $random . '" class="catmenu" style=""></div>';
79        $renderer->doc .= "<script>
80            let catmenuconf_" . $random . " = { userewrite: '" . $conf['userewrite'] . "', start: '" . $conf['start'] . "' };
81            let catmenuobj_" . $random . " = JSON.parse(`" . htmlspecialchars_decode(json_encode($pages)) . "`);
82            (function initCatmenu_" . $random . "() {
83                const target = document.getElementById('catmenu_" . $random . "');
84                if (!target) return;
85                const renderOnce = function () {
86                    if (target.dataset.catmenuRendered === '1') return;
87                    if (typeof window.catmenu_generateSectionMenu !== 'function') return;
88                    target.dataset.catmenuRendered = '1';
89                    target.innerHTML = '';
90                    window.catmenu_generateSectionMenu(catmenuconf_" . $random . ", catmenuobj_" . $random . ", target);
91                };
92                if (typeof window.catmenu_generateSectionMenu === 'function') {
93                    renderOnce();
94                    return;
95                }
96
97                // En mode édition/aperçu, les scripts du plugin peuvent se charger
98                // après le rendu inline — on attend l'événement catmenu:ready.
99                document.addEventListener('catmenu:ready', function onReady() {
100                    renderOnce();
101                }, { once: true });
102
103                setTimeout(function retryCatmenu_" . $random . "() {
104                    renderOnce();
105                }, 0);
106            })();
107        </script>";
108
109        // Injection du footer DokuCode (si configuré)
110        $footerContent = trim((string)$this->getConf('footer_content'));
111        if ($footerContent !== '') {
112            $footerHtml = p_render('xhtml', p_get_instructions($footerContent), $info);
113            if ($footerHtml !== '') {
114                $renderer->doc .= '<div class="catmenu-footer">' . $footerHtml . '</div>';
115            }
116        }
117
118        return true;
119    }
120
121    /**
122     * Récupère à la fois les pages et les sous-dossiers d'un namespace.
123     *
124     * @return array|false  Tableau d'items ou false si le namespace n'existe pas
125     */
126    public function getPagesAndSubfoldersItems($namespace) {
127        global $conf;
128        $skipPageWithoutTitle = (bool)$this->getConf('skip_page_without_title');
129        $nsHelper = $this->getNsHelper();
130
131        $childrens = @scandir($nsHelper->namespaceDir($namespace));
132        if ($childrens === false) {
133            return false;
134        }
135
136        $start = $conf['start']; // page de démarrage (ex. 'accueil', 'start')
137
138        $items = [];
139        foreach ($childrens as $child) {
140            if ($child[0] === '.') { // ignorer ., .. et fichiers cachés
141                continue;
142            }
143
144            $childPathInfo  = pathinfo($child);
145            $childID        = cleanID($childPathInfo['filename']);
146            $childNamespace = cleanID($namespace !== '' ? ($namespace . ':' . $childID) : $childID);
147
148            $childHasExtension = isset($childPathInfo['extension']) && $childPathInfo['extension'] !== '';
149            $isDirNamespace    = is_dir($nsHelper->namespaceDir($childNamespace));
150            $isPageNamespace   = page_exists($childNamespace);
151
152            if (!$childHasExtension && $isDirNamespace) { // Dossier/namespace
153                $pageNamespaceInfo = $nsHelper->getPageNamespaceInfo($childNamespace);
154                if ($nsHelper->isHomepage($childID, (string)$pageNamespaceInfo['parentID'])) {
155                    // Aplatir les dossiers "page d'accueil" (ex. ns:ns) — leurs enfants remontent d'un niveau.
156                    $subItems = $this->getPagesAndSubfoldersItems($childNamespace);
157                    if (is_array($subItems) && $subItems) {
158                        $items = array_merge($items, $subItems);
159                    }
160                    continue;
161                }
162
163                $pageID = null;
164                if (page_exists("$childNamespace:$start")) {
165                    // Page d'accueil standard
166                    $pageID = "$childNamespace:$start";
167                } elseif (page_exists("$childNamespace:$childID")) {
168                    // Page homonyme dans le dossier
169                    $pageID = "$childNamespace:$childID";
170                } elseif ($isPageNamespace) {
171                    // Page homonyme au même niveau que le dossier
172                    $pageID = cleanID($namespace !== '' ? ($namespace . ':' . $childID) : $childID);
173                }
174
175                $permission = auth_quickaclcheck($pageID);
176                if ($permission < AUTH_READ) {
177                    continue;
178                }
179
180                $title = $pageID ? p_get_first_heading($pageID) : $pageID;
181                if (empty($title)) {
182                    if ($skipPageWithoutTitle || empty($pageID)) {
183                        continue;
184                    }
185                    $title = noNS($pageID);
186                }
187
188                $items[] = [
189                    'title'              => $title,
190                    'url'                => $pageID ? wl($pageID) : null,
191                    'icon'               => $this->getPageImage($pageID),
192                    'pagesiconUploadUrl' => $this->getPagesiconUploadUrl($pageID ?: $childNamespace),
193                    'folderNamespace'    => $childNamespace,
194                    'namespace'          => $childNamespace,
195                    'subtree'            => $this->getPagesAndSubfoldersItems($childNamespace),
196                    'permission'         => $permission,
197                ];
198                continue;
199            }
200
201            if (!$isDirNamespace && $isPageNamespace) { // Page seule
202                $skipRegex = $this->resolveSkipRegex();
203                if (!empty($skipRegex) && preg_match($skipRegex, $childNamespace)) {
204                    continue;
205                }
206
207                $pageNamespaceInfo = $nsHelper->getPageNamespaceInfo("$namespace:$childID");
208                if ($nsHelper->isHomepage($childID, $pageNamespaceInfo['parentID'])) {
209                    continue;
210                }
211
212                $permission = auth_quickaclcheck($childNamespace);
213                if ($permission < AUTH_READ) {
214                    continue;
215                }
216
217                $title = p_get_first_heading($childNamespace);
218                if (empty($title)) {
219                    if ($skipPageWithoutTitle) {
220                        continue;
221                    }
222                    $title = noNS($childNamespace);
223                }
224
225                $items[] = [
226                    'title'              => $title,
227                    'url'                => $childNamespace ? wl($childNamespace) : null,
228                    'icon'               => $this->getPageImage($childNamespace),
229                    'pagesiconUploadUrl' => $this->getPagesiconUploadUrl($childNamespace),
230                    'folderNamespace'    => $namespace,
231                    'namespace'          => $childNamespace,
232                    'permission'         => $permission,
233                ];
234            }
235        }
236
237        return $items;
238    }
239
240    /**
241     * Résout la valeur effective de l'option skip_file.
242     *
243     * Si la valeur est le jeton spécial "@hidepages", retourne la regex de masquage
244     * de pages configurée globalement dans DokuWiki ($conf['hidepages']).
245     * Sinon, retourne la valeur brute telle quelle.
246     */
247    private function resolveSkipRegex(): string
248    {
249        global $conf;
250        $raw = (string)$this->getConf('skip_file');
251        if (trim($raw) === '@hidepages') {
252            return (string)($conf['hidepages'] ?? '');
253        }
254        return $raw;
255    }
256
257    /**
258     * Retourne l'URL de la miniature d'icône via le helper pagesicon.
259     * Retourne une chaîne vide si aucune icône n'est définie.
260     */
261    public function getPageImage($page) {
262        if (!$page) return '';
263
264        $page = cleanID((string)$page);
265        if ($page === '') return '';
266
267        /** @var helper_plugin_pagesicon|null $helper */
268        $helper = plugin_load('helper', 'pagesicon');
269        if (!$helper) return '';
270
271        $namespace = getNS($page);
272        $pageID    = noNS($page);
273
274        // Nouvelle API pagesicon (préférée)
275        if (method_exists($helper, 'getPageIconUrl')) {
276            $mtime   = null;
277            $iconUrl = $helper->getPageIconUrl($namespace, $pageID, 'smallorbig', ['width' => 55], $mtime, true);
278            if ($iconUrl) return $iconUrl;
279        } elseif (method_exists($helper, 'getImageIcon')) {
280            $mtime                = null;
281            $withDefaultSupported = false;
282            try {
283                $method               = new ReflectionMethod($helper, 'getImageIcon');
284                $withDefaultSupported = $method->getNumberOfParameters() >= 6;
285            } catch (ReflectionException $e) {
286                $withDefaultSupported = false;
287            }
288
289            $iconUrl = $withDefaultSupported
290                ? $helper->getImageIcon($namespace, $pageID, 'smallorbig', ['width' => 55], $mtime, true)
291                : $helper->getImageIcon($namespace, $pageID, 'smallorbig', ['width' => 55], $mtime);
292            if ($iconUrl) return $iconUrl;
293        }
294
295        // Fallback : récupération de l'ID média puis génération de l'URL
296        $iconMediaID = false;
297        if (method_exists($helper, 'getPageIconId')) {
298            $iconMediaID = $helper->getPageIconId($namespace, $pageID, 'smallorbig');
299        } elseif (method_exists($helper, 'getPageImage')) {
300            $withDefaultSupported = false;
301            try {
302                $method               = new ReflectionMethod($helper, 'getPageImage');
303                $withDefaultSupported = $method->getNumberOfParameters() >= 4;
304            } catch (ReflectionException $e) {
305                $withDefaultSupported = false;
306            }
307
308            $iconMediaID = $withDefaultSupported
309                ? $helper->getPageImage($namespace, $pageID, 'smallorbig', true)
310                : $helper->getPageImage($namespace, $pageID, 'smallorbig');
311        }
312        if (!$iconMediaID) return '';
313
314        return ml($iconMediaID, ['width' => 55]);
315    }
316
317    /**
318     * Retourne l'URL de la page d'upload d'icône pour un namespace (via pagesicon).
319     */
320    private function getPagesiconUploadUrl($namespace) {
321        if ($this->pagesiconHelper === false) {
322            $this->pagesiconHelper = plugin_load('helper', 'pagesicon');
323        }
324        if (!$this->pagesiconHelper) return null;
325        if (!method_exists($this->pagesiconHelper, 'getUploadIconPage')) return null;
326
327        return $this->pagesiconHelper->getUploadIconPage((string)$namespace);
328    }
329}
330