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