1<?php 2/** 3 * DokuWiki Plugin calendar (Action Component) 4 * 5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 6 * @author DokuWiki Community 7 */ 8 9if (!defined('DOKU_INC')) die(); 10 11class action_plugin_calendar extends DokuWiki_Action_Plugin { 12 13 public function register(Doku_Event_Handler $controller) { 14 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax'); 15 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets'); 16 } 17 18 public function handleAjax(Doku_Event $event, $param) { 19 if ($event->data !== 'plugin_calendar') return; 20 $event->preventDefault(); 21 $event->stopPropagation(); 22 23 $action = $_REQUEST['action'] ?? ''; 24 25 switch ($action) { 26 case 'save_event': 27 $this->saveEvent(); 28 break; 29 case 'delete_event': 30 $this->deleteEvent(); 31 break; 32 case 'get_event': 33 $this->getEvent(); 34 break; 35 case 'load_month': 36 $this->loadMonth(); 37 break; 38 case 'toggle_task': 39 $this->toggleTaskComplete(); 40 break; 41 default: 42 echo json_encode(['success' => false, 'error' => 'Unknown action']); 43 } 44 } 45 46 private function saveEvent() { 47 global $INPUT; 48 49 $namespace = $INPUT->str('namespace', ''); 50 $date = $INPUT->str('date'); 51 $eventId = $INPUT->str('eventId', ''); 52 $title = $INPUT->str('title'); 53 $time = $INPUT->str('time', ''); 54 $description = $INPUT->str('description', ''); 55 $color = $INPUT->str('color', '#3498db'); 56 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 57 $isTask = $INPUT->bool('isTask', false); 58 $completed = $INPUT->bool('completed', false); 59 $endDate = $INPUT->str('endDate', ''); 60 $isRecurring = $INPUT->bool('isRecurring', false); 61 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 62 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 63 64 if (!$date || !$title) { 65 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 66 return; 67 } 68 69 // If editing, find the event's stored namespace first (before normalizing) 70 $storedNamespace = ''; 71 if ($eventId) { 72 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 73 } 74 75 // Use stored namespace if editing, otherwise normalize wildcards to empty 76 if ($eventId && $storedNamespace !== null) { 77 $namespace = $storedNamespace; 78 } else { 79 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 80 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 81 $namespace = ''; 82 } 83 } 84 85 // Generate event ID if new 86 $generatedId = $eventId ?: uniqid(); 87 88 // If recurring, generate multiple events 89 if ($isRecurring) { 90 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $description, 91 $color, $isTask, $recurrenceType, $recurrenceEnd, $generatedId); 92 echo json_encode(['success' => true]); 93 return; 94 } 95 96 list($year, $month, $day) = explode('-', $date); 97 98 $dataDir = DOKU_INC . 'data/meta/'; 99 if ($namespace) { 100 $dataDir .= str_replace(':', '/', $namespace) . '/'; 101 } 102 $dataDir .= 'calendar/'; 103 104 if (!is_dir($dataDir)) { 105 mkdir($dataDir, 0755, true); 106 } 107 108 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 109 110 $events = []; 111 if (file_exists($eventFile)) { 112 $events = json_decode(file_get_contents($eventFile), true); 113 } 114 115 // If editing and date changed, remove from old date first 116 if ($eventId && $oldDate && $oldDate !== $date) { 117 list($oldYear, $oldMonth, $oldDay) = explode('-', $oldDate); 118 $oldEventFile = $dataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 119 120 if (file_exists($oldEventFile)) { 121 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 122 if (isset($oldEvents[$oldDate])) { 123 $oldEvents[$oldDate] = array_values(array_filter($oldEvents[$oldDate], function($evt) use ($eventId) { 124 return $evt['id'] !== $eventId; 125 })); 126 127 if (empty($oldEvents[$oldDate])) { 128 unset($oldEvents[$oldDate]); 129 } 130 131 file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT)); 132 } 133 } 134 } 135 136 if (!isset($events[$date])) { 137 $events[$date] = []; 138 } elseif (!is_array($events[$date])) { 139 // Fix corrupted data - ensure it's an array 140 error_log("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 141 $events[$date] = []; 142 } 143 144 // Store the namespace with the event 145 $eventData = [ 146 'id' => $generatedId, 147 'title' => $title, 148 'time' => $time, 149 'description' => $description, 150 'color' => $color, 151 'isTask' => $isTask, 152 'completed' => $completed, 153 'endDate' => $endDate, 154 'namespace' => $namespace, // Store namespace with event 155 'created' => date('Y-m-d H:i:s') 156 ]; 157 158 // If editing, replace existing event 159 if ($eventId) { 160 $found = false; 161 foreach ($events[$date] as $key => $evt) { 162 if ($evt['id'] === $eventId) { 163 $events[$date][$key] = $eventData; 164 $found = true; 165 break; 166 } 167 } 168 if (!$found) { 169 $events[$date][] = $eventData; 170 } 171 } else { 172 $events[$date][] = $eventData; 173 } 174 175 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 176 177 // If event spans multiple months, add it to the first day of each subsequent month 178 if ($endDate && $endDate !== $date) { 179 $startDateObj = new DateTime($date); 180 $endDateObj = new DateTime($endDate); 181 182 // Get the month/year of the start date 183 $startMonth = $startDateObj->format('Y-m'); 184 185 // Iterate through each month the event spans 186 $currentDate = clone $startDateObj; 187 $currentDate->modify('first day of next month'); // Jump to first of next month 188 189 while ($currentDate <= $endDateObj) { 190 $currentMonth = $currentDate->format('Y-m'); 191 $firstDayOfMonth = $currentDate->format('Y-m-01'); 192 193 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 194 195 // Get the file for this month 196 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 197 198 $currentEvents = []; 199 if (file_exists($currentEventFile)) { 200 $contents = file_get_contents($currentEventFile); 201 $decoded = json_decode($contents, true); 202 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 203 $currentEvents = $decoded; 204 } else { 205 error_log("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 206 } 207 } 208 209 // Add entry for the first day of this month 210 if (!isset($currentEvents[$firstDayOfMonth])) { 211 $currentEvents[$firstDayOfMonth] = []; 212 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 213 // Fix corrupted data - ensure it's an array 214 error_log("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 215 $currentEvents[$firstDayOfMonth] = []; 216 } 217 218 // Create a copy with the original start date preserved 219 $eventDataForMonth = $eventData; 220 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 221 222 // Check if event already exists (when editing) 223 $found = false; 224 if ($eventId) { 225 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 226 if ($evt['id'] === $eventId) { 227 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 228 $found = true; 229 break; 230 } 231 } 232 } 233 234 if (!$found) { 235 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 236 } 237 238 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 239 240 // Move to next month 241 $currentDate->modify('first day of next month'); 242 } 243 } 244 245 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 246 } 247 248 private function deleteEvent() { 249 global $INPUT; 250 251 $namespace = $INPUT->str('namespace', ''); 252 $date = $INPUT->str('date'); 253 $eventId = $INPUT->str('eventId'); 254 255 // Find where the event actually lives 256 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 257 258 if ($storedNamespace === null) { 259 echo json_encode(['success' => false, 'error' => 'Event not found']); 260 return; 261 } 262 263 // Use the found namespace 264 $namespace = $storedNamespace; 265 266 list($year, $month, $day) = explode('-', $date); 267 268 $dataDir = DOKU_INC . 'data/meta/'; 269 if ($namespace) { 270 $dataDir .= str_replace(':', '/', $namespace) . '/'; 271 } 272 $dataDir .= 'calendar/'; 273 274 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 275 276 // First, get the event to check if it spans multiple months 277 $eventToDelete = null; 278 if (file_exists($eventFile)) { 279 $events = json_decode(file_get_contents($eventFile), true); 280 281 if (isset($events[$date])) { 282 foreach ($events[$date] as $event) { 283 if ($event['id'] === $eventId) { 284 $eventToDelete = $event; 285 break; 286 } 287 } 288 289 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 290 return $event['id'] !== $eventId; 291 })); 292 293 if (empty($events[$date])) { 294 unset($events[$date]); 295 } 296 297 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 298 } 299 } 300 301 // If event spans multiple months, delete it from the first day of each subsequent month 302 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 303 $startDateObj = new DateTime($date); 304 $endDateObj = new DateTime($eventToDelete['endDate']); 305 306 // Iterate through each month the event spans 307 $currentDate = clone $startDateObj; 308 $currentDate->modify('first day of next month'); // Jump to first of next month 309 310 while ($currentDate <= $endDateObj) { 311 $firstDayOfMonth = $currentDate->format('Y-m-01'); 312 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 313 314 // Get the file for this month 315 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 316 317 if (file_exists($currentEventFile)) { 318 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 319 320 if (isset($currentEvents[$firstDayOfMonth])) { 321 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 322 return $event['id'] !== $eventId; 323 })); 324 325 if (empty($currentEvents[$firstDayOfMonth])) { 326 unset($currentEvents[$firstDayOfMonth]); 327 } 328 329 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 330 } 331 } 332 333 // Move to next month 334 $currentDate->modify('first day of next month'); 335 } 336 } 337 338 echo json_encode(['success' => true]); 339 } 340 341 private function getEvent() { 342 global $INPUT; 343 344 $namespace = $INPUT->str('namespace', ''); 345 $date = $INPUT->str('date'); 346 $eventId = $INPUT->str('eventId'); 347 348 // Find where the event actually lives 349 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 350 351 if ($storedNamespace === null) { 352 echo json_encode(['success' => false, 'error' => 'Event not found']); 353 return; 354 } 355 356 // Use the found namespace 357 $namespace = $storedNamespace; 358 359 list($year, $month, $day) = explode('-', $date); 360 361 $dataDir = DOKU_INC . 'data/meta/'; 362 if ($namespace) { 363 $dataDir .= str_replace(':', '/', $namespace) . '/'; 364 } 365 $dataDir .= 'calendar/'; 366 367 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 368 369 if (file_exists($eventFile)) { 370 $events = json_decode(file_get_contents($eventFile), true); 371 372 if (isset($events[$date])) { 373 foreach ($events[$date] as $event) { 374 if ($event['id'] === $eventId) { 375 echo json_encode(['success' => true, 'event' => $event]); 376 return; 377 } 378 } 379 } 380 } 381 382 echo json_encode(['success' => false, 'error' => 'Event not found']); 383 } 384 385 private function loadMonth() { 386 global $INPUT; 387 388 // Prevent caching of AJAX responses 389 header('Cache-Control: no-cache, no-store, must-revalidate'); 390 header('Pragma: no-cache'); 391 header('Expires: 0'); 392 393 $namespace = $INPUT->str('namespace', ''); 394 $year = $INPUT->int('year'); 395 $month = $INPUT->int('month'); 396 397 error_log("=== Calendar loadMonth DEBUG ==="); 398 error_log("Requested: year=$year, month=$month, namespace='$namespace'"); 399 400 // Check if multi-namespace or wildcard 401 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 402 403 error_log("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 404 405 if ($isMultiNamespace) { 406 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 407 } else { 408 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 409 } 410 411 error_log("Returning " . count($events) . " date keys"); 412 foreach ($events as $dateKey => $dayEvents) { 413 error_log(" dateKey=$dateKey has " . count($dayEvents) . " events"); 414 } 415 416 echo json_encode([ 417 'success' => true, 418 'year' => $year, 419 'month' => $month, 420 'events' => $events 421 ]); 422 } 423 424 private function loadEventsSingleNamespace($namespace, $year, $month) { 425 $dataDir = DOKU_INC . 'data/meta/'; 426 if ($namespace) { 427 $dataDir .= str_replace(':', '/', $namespace) . '/'; 428 } 429 $dataDir .= 'calendar/'; 430 431 // Load ONLY current month 432 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 433 $events = []; 434 if (file_exists($eventFile)) { 435 $contents = file_get_contents($eventFile); 436 $decoded = json_decode($contents, true); 437 if (json_last_error() === JSON_ERROR_NONE) { 438 $events = $decoded; 439 } 440 } 441 442 return $events; 443 } 444 445 private function loadEventsMultiNamespace($namespaces, $year, $month) { 446 // Check for wildcard pattern 447 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 448 $baseNamespace = $matches[1]; 449 return $this->loadEventsWildcard($baseNamespace, $year, $month); 450 } 451 452 // Check for root wildcard 453 if ($namespaces === '*') { 454 return $this->loadEventsWildcard('', $year, $month); 455 } 456 457 // Parse namespace list (semicolon separated) 458 $namespaceList = array_map('trim', explode(';', $namespaces)); 459 460 // Load events from all namespaces 461 $allEvents = []; 462 foreach ($namespaceList as $ns) { 463 $ns = trim($ns); 464 if (empty($ns)) continue; 465 466 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 467 468 // Add namespace tag to each event 469 foreach ($events as $dateKey => $dayEvents) { 470 if (!isset($allEvents[$dateKey])) { 471 $allEvents[$dateKey] = []; 472 } 473 foreach ($dayEvents as $event) { 474 $event['_namespace'] = $ns; 475 $allEvents[$dateKey][] = $event; 476 } 477 } 478 } 479 480 return $allEvents; 481 } 482 483 private function loadEventsWildcard($baseNamespace, $year, $month) { 484 $dataDir = DOKU_INC . 'data/meta/'; 485 if ($baseNamespace) { 486 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 487 } 488 489 $allEvents = []; 490 491 // First, load events from the base namespace itself 492 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 493 494 foreach ($events as $dateKey => $dayEvents) { 495 if (!isset($allEvents[$dateKey])) { 496 $allEvents[$dateKey] = []; 497 } 498 foreach ($dayEvents as $event) { 499 $event['_namespace'] = $baseNamespace; 500 $allEvents[$dateKey][] = $event; 501 } 502 } 503 504 // Recursively find all subdirectories 505 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 506 507 return $allEvents; 508 } 509 510 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 511 if (!is_dir($dir)) return; 512 513 $items = scandir($dir); 514 foreach ($items as $item) { 515 if ($item === '.' || $item === '..') continue; 516 517 $path = $dir . $item; 518 if (is_dir($path) && $item !== 'calendar') { 519 // This is a namespace directory 520 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 521 522 // Load events from this namespace 523 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 524 foreach ($events as $dateKey => $dayEvents) { 525 if (!isset($allEvents[$dateKey])) { 526 $allEvents[$dateKey] = []; 527 } 528 foreach ($dayEvents as $event) { 529 $event['_namespace'] = $namespace; 530 $allEvents[$dateKey][] = $event; 531 } 532 } 533 534 // Recurse into subdirectories 535 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 536 } 537 } 538 } 539 540 private function toggleTaskComplete() { 541 global $INPUT; 542 543 $namespace = $INPUT->str('namespace', ''); 544 $date = $INPUT->str('date'); 545 $eventId = $INPUT->str('eventId'); 546 $completed = $INPUT->bool('completed', false); 547 548 // Find where the event actually lives 549 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 550 551 if ($storedNamespace === null) { 552 echo json_encode(['success' => false, 'error' => 'Event not found']); 553 return; 554 } 555 556 // Use the found namespace 557 $namespace = $storedNamespace; 558 559 list($year, $month, $day) = explode('-', $date); 560 561 $dataDir = DOKU_INC . 'data/meta/'; 562 if ($namespace) { 563 $dataDir .= str_replace(':', '/', $namespace) . '/'; 564 } 565 $dataDir .= 'calendar/'; 566 567 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 568 569 if (file_exists($eventFile)) { 570 $events = json_decode(file_get_contents($eventFile), true); 571 572 if (isset($events[$date])) { 573 foreach ($events[$date] as $key => $event) { 574 if ($event['id'] === $eventId) { 575 $events[$date][$key]['completed'] = $completed; 576 break; 577 } 578 } 579 580 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 581 echo json_encode(['success' => true, 'events' => $events]); 582 return; 583 } 584 } 585 586 echo json_encode(['success' => false, 'error' => 'Event not found']); 587 } 588 589 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, 590 $description, $color, $isTask, $recurrenceType, 591 $recurrenceEnd, $baseId) { 592 $dataDir = DOKU_INC . 'data/meta/'; 593 if ($namespace) { 594 $dataDir .= str_replace(':', '/', $namespace) . '/'; 595 } 596 $dataDir .= 'calendar/'; 597 598 if (!is_dir($dataDir)) { 599 mkdir($dataDir, 0755, true); 600 } 601 602 // Calculate recurrence interval 603 $interval = ''; 604 switch ($recurrenceType) { 605 case 'daily': $interval = '+1 day'; break; 606 case 'weekly': $interval = '+1 week'; break; 607 case 'monthly': $interval = '+1 month'; break; 608 case 'yearly': $interval = '+1 year'; break; 609 default: $interval = '+1 week'; 610 } 611 612 // Set maximum end date if not specified (1 year from start) 613 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 614 615 // Calculate event duration for multi-day events 616 $eventDuration = 0; 617 if ($endDate && $endDate !== $startDate) { 618 $start = new DateTime($startDate); 619 $end = new DateTime($endDate); 620 $eventDuration = $start->diff($end)->days; 621 } 622 623 // Generate recurring events 624 $currentDate = new DateTime($startDate); 625 $endLimit = new DateTime($maxEnd); 626 $counter = 0; 627 $maxOccurrences = 100; // Prevent infinite loops 628 629 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 630 $dateKey = $currentDate->format('Y-m-d'); 631 list($year, $month, $day) = explode('-', $dateKey); 632 633 // Calculate end date for this occurrence if multi-day 634 $occurrenceEndDate = ''; 635 if ($eventDuration > 0) { 636 $occurrenceEnd = clone $currentDate; 637 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 638 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 639 } 640 641 // Load month file 642 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 643 $events = []; 644 if (file_exists($eventFile)) { 645 $events = json_decode(file_get_contents($eventFile), true); 646 } 647 648 if (!isset($events[$dateKey])) { 649 $events[$dateKey] = []; 650 } 651 652 // Create event for this occurrence 653 $eventData = [ 654 'id' => $baseId . '-' . $counter, 655 'title' => $title, 656 'time' => $time, 657 'description' => $description, 658 'color' => $color, 659 'isTask' => $isTask, 660 'completed' => false, 661 'endDate' => $occurrenceEndDate, 662 'recurring' => true, 663 'recurringId' => $baseId, 664 'created' => date('Y-m-d H:i:s') 665 ]; 666 667 $events[$dateKey][] = $eventData; 668 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 669 670 // Move to next occurrence 671 $currentDate->modify($interval); 672 $counter++; 673 } 674 } 675 676 public function addAssets(Doku_Event $event, $param) { 677 $event->data['link'][] = array( 678 'type' => 'text/css', 679 'rel' => 'stylesheet', 680 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 681 ); 682 683 $event->data['script'][] = array( 684 'type' => 'text/javascript', 685 'src' => DOKU_BASE . 'lib/plugins/calendar/script.js' 686 ); 687 } 688 // Helper function to find an event's stored namespace 689 private function findEventNamespace($eventId, $date, $searchNamespace) { 690 list($year, $month, $day) = explode('-', $date); 691 692 // List of namespaces to check 693 $namespacesToCheck = ['']; 694 695 // If searchNamespace is a wildcard or multi, we need to search multiple locations 696 if (!empty($searchNamespace)) { 697 if (strpos($searchNamespace, ';') !== false) { 698 // Multi-namespace - check each one 699 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 700 $namespacesToCheck[] = ''; // Also check default 701 } elseif (strpos($searchNamespace, '*') !== false) { 702 // Wildcard - need to scan directories 703 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 704 $namespacesToCheck = $this->findAllNamespaces($baseNs); 705 $namespacesToCheck[] = ''; // Also check default 706 } else { 707 // Single namespace 708 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 709 } 710 } 711 712 // Search for the event in all possible namespaces 713 foreach ($namespacesToCheck as $ns) { 714 $dataDir = DOKU_INC . 'data/meta/'; 715 if ($ns) { 716 $dataDir .= str_replace(':', '/', $ns) . '/'; 717 } 718 $dataDir .= 'calendar/'; 719 720 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 721 722 if (file_exists($eventFile)) { 723 $events = json_decode(file_get_contents($eventFile), true); 724 if (isset($events[$date])) { 725 foreach ($events[$date] as $evt) { 726 if ($evt['id'] === $eventId) { 727 // Found the event! Return its stored namespace 728 return isset($evt['namespace']) ? $evt['namespace'] : $ns; 729 } 730 } 731 } 732 } 733 } 734 735 return null; // Event not found 736 } 737 738 // Helper to find all namespaces under a base namespace 739 private function findAllNamespaces($baseNamespace) { 740 $dataDir = DOKU_INC . 'data/meta/'; 741 if ($baseNamespace) { 742 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 743 } 744 745 $namespaces = []; 746 if ($baseNamespace) { 747 $namespaces[] = $baseNamespace; 748 } 749 750 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 751 752 return $namespaces; 753 } 754 755 // Recursive scan for namespaces 756 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 757 if (!is_dir($dir)) return; 758 759 $items = scandir($dir); 760 foreach ($items as $item) { 761 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 762 763 $path = $dir . $item; 764 if (is_dir($path)) { 765 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 766 $namespaces[] = $namespace; 767 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 768 } 769 } 770 } 771} 772