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