xref: /plugin/pagesicon/helper.php (revision a3ea184d8fbbe3d7b0907bb9f539dee5a6e56ad6)
1<?php
2if (!defined('DOKU_INC')) die();
3
4class helper_plugin_pagesicon extends DokuWiki_Plugin {
5    private const BUNDLED_DEFAULT_IMAGE_RELATIVE_PATH = 'lib/plugins/pagesicon/images/default_image.png';
6
7    private function getBundledDefaultImagePath(): string {
8        return DOKU_INC . self::BUNDLED_DEFAULT_IMAGE_RELATIVE_PATH;
9    }
10
11    private function getBundledDefaultImageUrl(): string {
12        $path = $this->getBundledDefaultImagePath();
13        if (!@file_exists($path)) return '';
14
15        $base = rtrim((string)DOKU_BASE, '/');
16        $url = $base . '/' . self::BUNDLED_DEFAULT_IMAGE_RELATIVE_PATH;
17        $mtime = @filemtime($path);
18        return $this->appendVersionToUrl($url, $mtime ? (int)$mtime : 0);
19    }
20
21    private function getConfiguredDefaultImageMediaID() {
22        $mediaID = cleanID((string)$this->getConf('default_image'));
23        if ($mediaID === '') return false;
24        if (!@file_exists(mediaFN($mediaID))) return false;
25        return $mediaID;
26    }
27
28    private function getMediaMTime(string $mediaID): int {
29        $mediaID = cleanID($mediaID);
30        if ($mediaID === '') return 0;
31        $file = mediaFN($mediaID);
32        if (!@file_exists($file)) return 0;
33        $mtime = @filemtime($file);
34        return $mtime ? (int)$mtime : 0;
35    }
36
37    private function appendVersionToUrl(string $url, int $mtime): string {
38        if ($url === '' || $mtime <= 0) return $url;
39        $sep = strpos($url, '?') === false ? '?' : '&';
40        return $url . $sep . 'pi_ts=' . $mtime;
41    }
42
43    /**
44     * Added in version 2026-03-06.
45     * Notifies consumers that an icon changed and triggers cache invalidation hooks.
46     */
47    public function notifyIconUpdated(string $targetPage, string $action = 'update', string $mediaID = ''): void {
48        global $conf;
49
50        @io_saveFile($conf['cachedir'] . '/purgefile', time());
51
52        $data = [
53            'target_page' => cleanID($targetPage),
54            'action' => $action,
55            'media_id' => cleanID($mediaID),
56        ];
57        \dokuwiki\Extension\Event::createAndTrigger('PLUGIN_PAGESICON_UPDATED', $data);
58    }
59
60    /**
61     * Added in version 2026-03-11.
62     * Returns the configured filename templates for the requested icon variant.
63     */
64    public function getVariantTemplates(string $variant): array {
65        $confKey = $variant === 'small' ? 'icon_thumbnail_name' : 'icon_name';
66        $raw = (string)$this->getConf($confKey);
67
68        if (trim($raw) === '') {
69            trigger_error('pagesicon: missing required configuration "' . $confKey . '"', E_USER_WARNING);
70            return [];
71        }
72
73        $templates = array_values(array_unique(array_filter(array_map('trim', explode(';', $raw)))));
74        if (!$templates) {
75            trigger_error('pagesicon: configuration "' . $confKey . '" does not contain any usable value', E_USER_WARNING);
76        }
77
78        return $templates;
79    }
80
81    /**
82     * Added in version 2026-03-11.
83     * Normalizes an icon filename candidate to its base media name without namespace or extension.
84     */
85    public function normalizeIconBaseName(string $name): string {
86        $name = trim($name);
87        if ($name === '') return '';
88        $name = noNS($name);
89        $name = preg_replace('/\.[a-z0-9]+$/i', '', $name);
90        $name = cleanID($name);
91        return str_replace(':', '', $name);
92    }
93
94    /**
95     * Added in version 2026-03-11.
96     * Returns the allowed target base names for an upload, indexed by their normalized value.
97     */
98    public function getUploadNameChoices(string $targetPage, string $variant): array {
99        $pageID = noNS($targetPage);
100        $choices = [];
101
102        foreach ($this->getVariantTemplates($variant) as $tpl) {
103            $resolved = str_replace('~pagename~', $pageID, $tpl);
104            $base = $this->normalizeIconBaseName($resolved);
105            if ($base === '') continue;
106            $choices[$base] = $base . '.ext';
107        }
108
109        return $choices;
110    }
111
112    private function buildConfiguredCandidatesFromRaw(string $raw, string $namespace, string $pageID): array {
113        $configured = [];
114        $entries = array_filter(array_map('trim', explode(';', $raw)));
115
116        foreach ($entries as $entry) {
117            $name = str_replace('~pagename~', $pageID, $entry);
118            if ($name === '') continue;
119
120            if (strpos($name, ':') === false && $namespace !== '') {
121                $configured[] = $namespace . ':' . $name;
122            } else {
123                $configured[] = ltrim($name, ':');
124            }
125        }
126
127        return array_values(array_unique($configured));
128    }
129
130    private function buildConfiguredCandidates(string $namespace, string $pageID, string $sizeMode): array {
131        $bigRaw = trim((string)$this->getConf('icon_name'));
132        $smallRaw = trim((string)$this->getConf('icon_thumbnail_name'));
133
134        $big = $this->buildConfiguredCandidatesFromRaw($bigRaw, $namespace, $pageID);
135        $small = $this->buildConfiguredCandidatesFromRaw($smallRaw, $namespace, $pageID);
136
137        if ($sizeMode === 'big') return $big;
138        if ($sizeMode === 'small') return $small;
139        if ($sizeMode === 'smallorbig') return array_values(array_unique(array_merge($small, $big)));
140
141        // Default: bigorsmall
142        return array_values(array_unique(array_merge($big, $small)));
143    }
144
145    private function normalizeSizeMode(string $size): string {
146        $size = strtolower(trim($size));
147        $allowed = ['big', 'small', 'bigorsmall', 'smallorbig'];
148        if (in_array($size, $allowed, true)) return $size;
149        return 'bigorsmall';
150    }
151
152    /**
153     * Added in version 2026-03-11.
154     * Returns the configured list of allowed icon file extensions.
155     */
156    public function getConfiguredExtensions(): array {
157        $raw = trim((string)$this->getConf('extensions'));
158        if ($raw === '') {
159            trigger_error('pagesicon: missing required configuration "extensions"', E_USER_WARNING);
160            return [];
161        }
162
163        $extensions = array_values(array_unique(array_filter(array_map(function ($ext) {
164            return strtolower(ltrim(trim((string)$ext), '.'));
165        }, explode(';', $raw)))));
166
167        if (!$extensions) {
168            trigger_error('pagesicon: configuration "extensions" does not contain any usable value', E_USER_WARNING);
169        }
170
171        return $extensions;
172    }
173
174    private function hasKnownExtension(string $name, array $extensions): bool {
175        $fileExt = strtolower((string)pathinfo($name, PATHINFO_EXTENSION));
176        return $fileExt !== '' && in_array($fileExt, $extensions, true);
177    }
178
179    private function getParentFallbackMode(): string {
180        $mode = strtolower(trim((string)$this->getConf('parent_fallback')));
181        if ($mode !== 'direct' && $mode !== 'first') return 'none';
182        return $mode;
183    }
184
185    private function resolveOwnPageIconId(string $namespace, string $pageID, string $sizeMode, array $extensions) {
186        $namespace = $namespace ?: '';
187        $pageBase = $namespace ? ($namespace . ':' . $pageID) : $pageID;
188        $nsBase = $namespace ? ($namespace . ':') : '';
189
190        $genericBig = [
191            $pageBase,
192            $pageBase . ':logo',
193            $nsBase . 'logo',
194        ];
195        $genericSmall = [
196            $pageBase . ':thumbnail',
197            $nsBase . 'thumbnail',
198        ];
199
200        if ($sizeMode === 'big') {
201            $generic = $genericBig;
202        } elseif ($sizeMode === 'small') {
203            $generic = $genericSmall;
204        } elseif ($sizeMode === 'smallorbig') {
205            $generic = array_merge($genericSmall, $genericBig);
206        } else {
207            $generic = array_merge($genericBig, $genericSmall);
208        }
209
210        $imageNames = array_merge($this->buildConfiguredCandidates($namespace, $pageID, $sizeMode), $generic);
211
212        foreach ($imageNames as $name) {
213            if ($this->hasKnownExtension($name, $extensions)) {
214                if (@file_exists(mediaFN($name))) return $name;
215                continue;
216            }
217
218            foreach ($extensions as $ext) {
219                $path = $name . '.' . $ext;
220                if (@file_exists(mediaFN($path))) return $path;
221            }
222        }
223
224        return false;
225    }
226
227    private function resolveNamespacePageIconId(string $namespace, string $sizeMode, array $extensions) {
228        global $conf;
229
230        $namespace = cleanID($namespace);
231        if ($namespace === '') return false;
232
233        $parentNamespace = (string)(getNS($namespace) ?: '');
234        $pageID = noNS($namespace);
235
236        $iconID = $this->resolveOwnPageIconId($parentNamespace, $pageID, $sizeMode, $extensions);
237        if ($iconID) return $iconID;
238
239        $leafPageID = cleanID($namespace . ':' . $pageID);
240        if ($leafPageID !== '' && page_exists($leafPageID)) {
241            $iconID = $this->resolveOwnPageIconId($namespace, $pageID, $sizeMode, $extensions);
242            if ($iconID) return $iconID;
243        }
244
245        if (isset($conf['start'])) {
246            $startId = cleanID((string)$conf['start']);
247            if ($startId !== '') {
248                $iconID = $this->resolveOwnPageIconId($namespace, $startId, $sizeMode, $extensions);
249                if ($iconID) return $iconID;
250            }
251        }
252
253        return false;
254    }
255
256    /**
257     * Added in version 2026-03-09.
258     * Resolves the icon media ID for a page, or false when no icon matches.
259     * Replaces the older getPageImage() name.
260     */
261    public function getPageIconId(
262        string $namespace,
263        string $pageID,
264        string $size = 'bigorsmall'
265    )
266    {
267        $sizeMode = $this->normalizeSizeMode($size);
268        $extensions = $this->getConfiguredExtensions();
269        $iconID = $this->resolveOwnPageIconId($namespace, $pageID, $sizeMode, $extensions);
270        if ($iconID) return $iconID;
271
272        $fallbackMode = $this->getParentFallbackMode();
273        if ($fallbackMode === 'none') return false;
274
275        $currentNamespace = $namespace ?: '';
276        while ($currentNamespace !== '') {
277            $parentNamespace = (string)(getNS($currentNamespace) ?: '');
278            $lookupNamespace = $parentNamespace !== '' ? $parentNamespace : $currentNamespace;
279            $iconID = $this->resolveNamespacePageIconId($lookupNamespace, $sizeMode, $extensions);
280            if ($iconID) return $iconID;
281            if ($fallbackMode === 'direct' || $parentNamespace === '') break;
282            $currentNamespace = $parentNamespace;
283        }
284
285        return false;
286    }
287
288    /**
289     * Added in version 2026-03-06.
290     * Deprecated since version 2026-03-09, kept for backward compatibility.
291     * Use getPageIconId() instead.
292     */
293    public function getPageImage(
294        string $namespace,
295        string $pageID,
296        string $size = 'bigorsmall',
297        bool $withDefault = false
298    ) {
299        return $this->getPageIconId($namespace, $pageID, $size);
300    }
301
302    /**
303     * Added in version 2026-03-06.
304     * Returns the icon management URL for a page, or null when upload is not allowed.
305     */
306    public function getUploadIconPage(string $targetPage = '') {
307        global $ID;
308
309        $targetPage = cleanID($targetPage);
310        if ($targetPage === '') {
311            $targetPage = cleanID(getNS((string)$ID));
312        }
313        if ($targetPage === '') {
314            $targetPage = cleanID((string)$ID);
315        }
316        if ($targetPage === '') return null;
317
318        if (auth_quickaclcheck($targetPage) < AUTH_UPLOAD) {
319            return null;
320        }
321
322        return wl($targetPage, ['do' => 'pagesicon']);
323    }
324
325    /**
326     * Added in version 2026-03-09.
327     * Resolves the icon media ID associated with a media file, or false when none matches.
328     * Replaces the older getMediaImage() name.
329     */
330    public function getMediaIconId(string $mediaID, string $size = 'bigorsmall') {
331        $mediaID = cleanID($mediaID);
332        if ($mediaID === '') return false;
333
334        $namespace = getNS($mediaID);
335        $filename = noNS($mediaID);
336        $base = (string)pathinfo($filename, PATHINFO_FILENAME);
337        $pageID = cleanID($base);
338        if ($pageID === '') return false;
339
340        return $this->getPageIconId($namespace, $pageID, $size);
341    }
342
343    /**
344     * Added in version 2026-03-06.
345     * Deprecated since version 2026-03-09, kept for backward compatibility.
346     * Use getMediaIconId() instead.
347     */
348    public function getMediaImage(string $mediaID, string $size = 'bigorsmall', bool $withDefault = false) {
349        return $this->getMediaIconId($mediaID, $size);
350    }
351
352    private function matchesPageIconVariant(string $mediaID, string $namespace, string $pageID): bool {
353        $bigIconID = $this->getPageIconId($namespace, $pageID, 'big');
354        if ($bigIconID && cleanID((string)$bigIconID) === $mediaID) return true;
355
356        $smallIconID = $this->getPageIconId($namespace, $pageID, 'small');
357        if ($smallIconID && cleanID((string)$smallIconID) === $mediaID) return true;
358
359        return false;
360    }
361
362    /**
363     * Added in version 2026-03-11.
364     * Checks whether a media ID should be considered a page icon managed by the pagesicon plugin.
365     */
366    public function isPageIconMedia(string $mediaID): bool {
367        global $conf;
368
369        $mediaID = cleanID($mediaID);
370        if ($mediaID === '') return false;
371
372        $namespace = getNS($mediaID);
373        $filename = noNS($mediaID);
374        $basename = cleanID((string)pathinfo($filename, PATHINFO_FILENAME));
375        if ($basename === '') return false;
376
377        // Case 1: this media is the big or small icon selected for a page with the same base name.
378        $sameNamePageID = $namespace !== '' ? ($namespace . ':' . $basename) : $basename;
379        if (page_exists($sameNamePageID)) {
380            if ($this->matchesPageIconVariant($mediaID, $namespace, $basename)) return true;
381        }
382
383        // Case 2: this media is the big or small icon selected for a page whose ID matches the namespace.
384        if ($namespace !== '' && page_exists($namespace)) {
385            $parentNamespace = getNS($namespace);
386            $pageID = noNS($namespace);
387            if ($this->matchesPageIconVariant($mediaID, $parentNamespace, $pageID)) return true;
388        }
389
390        // Case 3: this media is the big or small icon selected for a page whose ID
391        // matches the namespace leaf, for example "...:playground:playground".
392        if ($namespace !== '') {
393            $namespaceLeaf = noNS($namespace);
394            $leafPageID = cleanID($namespace . ':' . $namespaceLeaf);
395            if ($leafPageID !== '' && page_exists($leafPageID)) {
396                if ($this->matchesPageIconVariant($mediaID, $namespace, $namespaceLeaf)) return true;
397            }
398        }
399
400        // Case 4: this media is the big or small icon selected for the namespace start page
401        // (for example "...:start"), which often carries the visible page content.
402        if ($namespace !== '' && isset($conf['start'])) {
403            $startId = cleanID((string)$conf['start']);
404            $startPage = $startId !== '' ? cleanID($namespace . ':' . $startId) : '';
405            if ($startPage !== '' && page_exists($startPage)) {
406                if ($this->matchesPageIconVariant($mediaID, $namespace, noNS($startPage))) return true;
407            }
408        }
409
410        return false;
411    }
412
413    /**
414     * Added in version 2026-03-09.
415     * Returns the configured default icon URL, or the bundled fallback image when available.
416     */
417    public function getDefaultIconUrl(array $params = ['width' => 55], ?int &$mtime = null) {
418        $mediaID = $this->getConfiguredDefaultImageMediaID();
419        if ($mediaID) {
420            $mtime = $this->getMediaMTime((string)$mediaID);
421            $url = (string)ml((string)$mediaID, $params);
422            if ($url === '') return false;
423            return $this->appendVersionToUrl($url, $mtime);
424        }
425
426        $mtime = 0;
427        $bundled = $this->getBundledDefaultImageUrl();
428        if ($bundled !== '') return $bundled;
429
430        return false;
431    }
432
433    /**
434     * Added in version 2026-03-09.
435     * Deprecated since version 2026-03-09, kept for backward compatibility.
436     * Use getDefaultIconUrl() instead.
437     */
438    public function getDefaultImageIcon(array $params = ['width' => 55], ?int &$mtime = null) {
439        return $this->getDefaultIconUrl($params, $mtime);
440    }
441
442    /**
443     * Added in version 2026-03-09.
444     * Returns a versioned icon URL for a page, or false when no icon matches.
445     * Replaces the older getImageIcon() name.
446     */
447    public function getPageIconUrl(
448        string $namespace,
449        string $pageID,
450        string $size = 'bigorsmall',
451        array $params = ['width' => 55],
452        ?int &$mtime = null,
453        bool $withDefault = false
454    ) {
455        $mediaID = $this->getPageIconId($namespace, $pageID, $size);
456        if (!$mediaID) {
457            if ($withDefault) {
458                return $this->getDefaultIconUrl($params, $mtime);
459            }
460            $mtime = 0;
461            return false;
462        }
463
464        $mtime = $this->getMediaMTime((string)$mediaID);
465        $url = (string)ml((string)$mediaID, $params);
466        if ($url === '') return false;
467        return $this->appendVersionToUrl($url, $mtime);
468    }
469
470    /**
471     * Added in version 2026-03-06.
472     * Deprecated since version 2026-03-09, kept for backward compatibility.
473     * Use getPageIconUrl() instead.
474     */
475    public function getImageIcon(
476        string $namespace,
477        string $pageID,
478        string $size = 'bigorsmall',
479        array $params = ['width' => 55],
480        ?int &$mtime = null,
481        bool $withDefault = false
482    ) {
483        return $this->getPageIconUrl($namespace, $pageID, $size, $params, $mtime, $withDefault);
484    }
485
486    /**
487     * Added in version 2026-03-09.
488     * Returns a versioned icon URL for a media file, or false when no icon matches.
489     * Replaces the older getMediaIcon() name.
490     */
491    public function getMediaIconUrl(
492        string $mediaID,
493        string $size = 'bigorsmall',
494        array $params = ['width' => 55],
495        ?int &$mtime = null,
496        bool $withDefault = false
497    ) {
498        $iconMediaID = $this->getMediaIconId($mediaID, $size);
499        if (!$iconMediaID) {
500            if ($withDefault) {
501                return $this->getDefaultIconUrl($params, $mtime);
502            }
503            $mtime = 0;
504            return false;
505        }
506
507        $mtime = $this->getMediaMTime((string)$iconMediaID);
508        $url = (string)ml((string)$iconMediaID, $params);
509        if ($url === '') return false;
510        return $this->appendVersionToUrl($url, $mtime);
511    }
512
513    /**
514     * Added in version 2026-03-06.
515     * Deprecated since version 2026-03-09, kept for backward compatibility.
516     * Use getMediaIconUrl() instead.
517     */
518    public function getMediaIcon(
519        string $mediaID,
520        string $size = 'bigorsmall',
521        array $params = ['width' => 55],
522        ?int &$mtime = null,
523        bool $withDefault = false
524    ) {
525        return $this->getMediaIconUrl($mediaID, $size, $params, $mtime, $withDefault);
526    }
527
528    /**
529     * Added in version 2026-03-06.
530     * Returns the icon management URL associated with a media file, or null when unavailable.
531     */
532    public function getUploadMediaIconPage(string $mediaID = '') {
533        $mediaID = cleanID($mediaID);
534        if ($mediaID === '') return null;
535
536        $namespace = getNS($mediaID);
537        $filename = noNS($mediaID);
538        $base = (string)pathinfo($filename, PATHINFO_FILENAME);
539        $targetPage = cleanID($namespace !== '' ? ($namespace . ':' . $base) : $base);
540        if ($targetPage === '') return null;
541
542        return $this->getUploadIconPage($targetPage);
543    }
544}
545