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 $imageNames = $this->buildConfiguredCandidates($namespace, $pageID, $sizeMode); 187 188 foreach ($imageNames as $name) { 189 if ($this->hasKnownExtension($name, $extensions)) { 190 if (@file_exists(mediaFN($name))) return $name; 191 continue; 192 } 193 194 foreach ($extensions as $ext) { 195 $path = $name . '.' . $ext; 196 if (@file_exists(mediaFN($path))) return $path; 197 } 198 } 199 200 return false; 201 } 202 203 private function resolveNamespacePageIconId(string $namespace, string $sizeMode, array $extensions) { 204 global $conf; 205 206 $namespace = cleanID($namespace); 207 if ($namespace === '') return false; 208 209 $parentNamespace = (string)(getNS($namespace) ?: ''); 210 $pageID = noNS($namespace); 211 212 $iconID = $this->resolveOwnPageIconId($parentNamespace, $pageID, $sizeMode, $extensions); 213 if ($iconID) return $iconID; 214 215 $leafPageID = cleanID($namespace . ':' . $pageID); 216 if ($leafPageID !== '' && page_exists($leafPageID)) { 217 $iconID = $this->resolveOwnPageIconId($namespace, $pageID, $sizeMode, $extensions); 218 if ($iconID) return $iconID; 219 } 220 221 if (isset($conf['start'])) { 222 $startId = cleanID((string)$conf['start']); 223 if ($startId !== '') { 224 $iconID = $this->resolveOwnPageIconId($namespace, $startId, $sizeMode, $extensions); 225 if ($iconID) return $iconID; 226 } 227 } 228 229 return false; 230 } 231 232 /** 233 * Added in version 2026-03-09. 234 * Resolves the icon media ID for a page, or false when no icon matches. 235 * Replaces the older getPageImage() name. 236 */ 237 public function getPageIconId( 238 string $namespace, 239 string $pageID, 240 string $size = 'bigorsmall' 241 ) 242 { 243 $sizeMode = $this->normalizeSizeMode($size); 244 $extensions = $this->getConfiguredExtensions(); 245 $iconID = $this->resolveOwnPageIconId($namespace, $pageID, $sizeMode, $extensions); 246 if ($iconID) return $iconID; 247 248 $fallbackMode = $this->getParentFallbackMode(); 249 if ($fallbackMode === 'none') return false; 250 251 $currentNamespace = $namespace ?: ''; 252 while ($currentNamespace !== '') { 253 $parentNamespace = (string)(getNS($currentNamespace) ?: ''); 254 $lookupNamespace = $parentNamespace !== '' ? $parentNamespace : $currentNamespace; 255 $iconID = $this->resolveNamespacePageIconId($lookupNamespace, $sizeMode, $extensions); 256 if ($iconID) return $iconID; 257 if ($fallbackMode === 'direct' || $parentNamespace === '') break; 258 $currentNamespace = $parentNamespace; 259 } 260 261 return false; 262 } 263 264 /** 265 * Added in version 2026-03-06. 266 * Deprecated since version 2026-03-09, kept for backward compatibility. 267 * Use getPageIconId() instead. 268 */ 269 public function getPageImage( 270 string $namespace, 271 string $pageID, 272 string $size = 'bigorsmall', 273 bool $withDefault = false 274 ) { 275 return $this->getPageIconId($namespace, $pageID, $size); 276 } 277 278 /** 279 * Added in version 2026-03-06. 280 * Returns the icon management URL for a page, or null when upload is not allowed. 281 */ 282 public function getUploadIconPage(string $targetPage = '') { 283 global $ID; 284 285 $targetPage = cleanID($targetPage); 286 if ($targetPage === '') { 287 $targetPage = cleanID(getNS((string)$ID)); 288 } 289 if ($targetPage === '') { 290 $targetPage = cleanID((string)$ID); 291 } 292 if ($targetPage === '') return null; 293 294 if (auth_quickaclcheck($targetPage) < AUTH_UPLOAD) { 295 return null; 296 } 297 298 return wl($targetPage, ['do' => 'pagesicon']); 299 } 300 301 /** 302 * Added in version 2026-03-09. 303 * Resolves the icon media ID associated with a media file, or false when none matches. 304 * Replaces the older getMediaImage() name. 305 */ 306 public function getMediaIconId(string $mediaID, string $size = 'bigorsmall') { 307 $mediaID = cleanID($mediaID); 308 if ($mediaID === '') return false; 309 310 $namespace = getNS($mediaID); 311 $filename = noNS($mediaID); 312 $base = (string)pathinfo($filename, PATHINFO_FILENAME); 313 $pageID = cleanID($base); 314 if ($pageID === '') return false; 315 316 return $this->getPageIconId($namespace, $pageID, $size); 317 } 318 319 /** 320 * Added in version 2026-03-06. 321 * Deprecated since version 2026-03-09, kept for backward compatibility. 322 * Use getMediaIconId() instead. 323 */ 324 public function getMediaImage(string $mediaID, string $size = 'bigorsmall', bool $withDefault = false) { 325 return $this->getMediaIconId($mediaID, $size); 326 } 327 328 private function matchesPageIconVariant(string $mediaID, string $namespace, string $pageID): bool { 329 $bigIconID = $this->getPageIconId($namespace, $pageID, 'big'); 330 if ($bigIconID && cleanID((string)$bigIconID) === $mediaID) return true; 331 332 $smallIconID = $this->getPageIconId($namespace, $pageID, 'small'); 333 if ($smallIconID && cleanID((string)$smallIconID) === $mediaID) return true; 334 335 return false; 336 } 337 338 /** 339 * Added in version 2026-03-11. 340 * Checks whether a media ID should be considered a page icon managed by the pagesicon plugin. 341 */ 342 public function isPageIconMedia(string $mediaID): bool { 343 global $conf; 344 345 $mediaID = cleanID($mediaID); 346 if ($mediaID === '') return false; 347 348 $namespace = getNS($mediaID); 349 $filename = noNS($mediaID); 350 $basename = cleanID((string)pathinfo($filename, PATHINFO_FILENAME)); 351 if ($basename === '') return false; 352 353 // Case 1: this media is the big or small icon selected for a page with the same base name. 354 $sameNamePageID = $namespace !== '' ? ($namespace . ':' . $basename) : $basename; 355 if (page_exists($sameNamePageID)) { 356 if ($this->matchesPageIconVariant($mediaID, $namespace, $basename)) return true; 357 } 358 359 // Case 2: this media is the big or small icon selected for a page whose ID matches the namespace. 360 if ($namespace !== '' && page_exists($namespace)) { 361 $parentNamespace = getNS($namespace); 362 $pageID = noNS($namespace); 363 if ($this->matchesPageIconVariant($mediaID, $parentNamespace, $pageID)) return true; 364 } 365 366 // Case 3: this media is the big or small icon selected for a page whose ID 367 // matches the namespace leaf, for example "...:playground:playground". 368 if ($namespace !== '') { 369 $namespaceLeaf = noNS($namespace); 370 $leafPageID = cleanID($namespace . ':' . $namespaceLeaf); 371 if ($leafPageID !== '' && page_exists($leafPageID)) { 372 if ($this->matchesPageIconVariant($mediaID, $namespace, $namespaceLeaf)) return true; 373 } 374 } 375 376 // Case 4: this media is the big or small icon selected for the namespace start page 377 // (for example "...:start"), which often carries the visible page content. 378 if ($namespace !== '' && isset($conf['start'])) { 379 $startId = cleanID((string)$conf['start']); 380 $startPage = $startId !== '' ? cleanID($namespace . ':' . $startId) : ''; 381 if ($startPage !== '' && page_exists($startPage)) { 382 if ($this->matchesPageIconVariant($mediaID, $namespace, noNS($startPage))) return true; 383 } 384 } 385 386 return false; 387 } 388 389 /** 390 * Added in version 2026-03-09. 391 * Returns the configured default icon URL, or the bundled fallback image when available. 392 */ 393 public function getDefaultIconUrl(array $params = ['w' => 55], ?int &$mtime = null) { 394 $mediaID = $this->getConfiguredDefaultImageMediaID(); 395 if ($mediaID) { 396 $mtime = $this->getMediaMTime((string)$mediaID); 397 $url = (string)ml((string)$mediaID, $params); 398 if ($url === '') return false; 399 return $this->appendVersionToUrl($url, $mtime); 400 } 401 402 $mtime = 0; 403 $bundled = $this->getBundledDefaultImageUrl(); 404 if ($bundled !== '') return $bundled; 405 406 return false; 407 } 408 409 /** 410 * Added in version 2026-03-09. 411 * Deprecated since version 2026-03-09, kept for backward compatibility. 412 * Use getDefaultIconUrl() instead. 413 */ 414 public function getDefaultImageIcon(array $params = ['w' => 55], ?int &$mtime = null) { 415 return $this->getDefaultIconUrl($params, $mtime); 416 } 417 418 /** 419 * Added in version 2026-03-09. 420 * Returns a versioned icon URL for a page, or false when no icon matches. 421 * Replaces the older getImageIcon() name. 422 */ 423 public function getPageIconUrl( 424 string $namespace, 425 string $pageID, 426 string $size = 'bigorsmall', 427 array $params = ['w' => 55], 428 ?int &$mtime = null, 429 bool $withDefault = false 430 ) { 431 $mediaID = $this->getPageIconId($namespace, $pageID, $size); 432 if (!$mediaID) { 433 if ($withDefault) { 434 return $this->getDefaultIconUrl($params, $mtime); 435 } 436 $mtime = 0; 437 return false; 438 } 439 440 $mtime = $this->getMediaMTime((string)$mediaID); 441 $url = (string)ml((string)$mediaID, $params); 442 if ($url === '') return false; 443 return $this->appendVersionToUrl($url, $mtime); 444 } 445 446 /** 447 * Added in version 2026-03-06. 448 * Deprecated since version 2026-03-09, kept for backward compatibility. 449 * Use getPageIconUrl() instead. 450 */ 451 public function getImageIcon( 452 string $namespace, 453 string $pageID, 454 string $size = 'bigorsmall', 455 array $params = ['w' => 55], 456 ?int &$mtime = null, 457 bool $withDefault = false 458 ) { 459 return $this->getPageIconUrl($namespace, $pageID, $size, $params, $mtime, $withDefault); 460 } 461 462 /** 463 * Added in version 2026-03-09. 464 * Returns a versioned icon URL for a media file, or false when no icon matches. 465 * Replaces the older getMediaIcon() name. 466 */ 467 public function getMediaIconUrl( 468 string $mediaID, 469 string $size = 'bigorsmall', 470 array $params = ['w' => 55], 471 ?int &$mtime = null, 472 bool $withDefault = false 473 ) { 474 $iconMediaID = $this->getMediaIconId($mediaID, $size); 475 if (!$iconMediaID) { 476 if ($withDefault) { 477 return $this->getDefaultIconUrl($params, $mtime); 478 } 479 $mtime = 0; 480 return false; 481 } 482 483 $mtime = $this->getMediaMTime((string)$iconMediaID); 484 $url = (string)ml((string)$iconMediaID, $params); 485 if ($url === '') return false; 486 return $this->appendVersionToUrl($url, $mtime); 487 } 488 489 /** 490 * Added in version 2026-03-06. 491 * Deprecated since version 2026-03-09, kept for backward compatibility. 492 * Use getMediaIconUrl() instead. 493 */ 494 public function getMediaIcon( 495 string $mediaID, 496 string $size = 'bigorsmall', 497 array $params = ['w' => 55], 498 ?int &$mtime = null, 499 bool $withDefault = false 500 ) { 501 return $this->getMediaIconUrl($mediaID, $size, $params, $mtime, $withDefault); 502 } 503 504 /** 505 * Added in version 2026-03-06. 506 * Returns the icon management URL associated with a media file, or null when unavailable. 507 */ 508 public function getUploadMediaIconPage(string $mediaID = '') { 509 $mediaID = cleanID($mediaID); 510 if ($mediaID === '') return null; 511 512 $namespace = getNS($mediaID); 513 $filename = noNS($mediaID); 514 $base = (string)pathinfo($filename, PATHINFO_FILENAME); 515 $targetPage = cleanID($namespace !== '' ? ($namespace . ':' . $base) : $base); 516 if ($targetPage === '') return null; 517 518 return $this->getUploadIconPage($targetPage); 519 } 520} 521