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