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