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 11// Set to true to enable verbose debug logging (should be false in production) 12if (!defined('CALENDAR_DEBUG')) { 13 define('CALENDAR_DEBUG', false); 14} 15 16class action_plugin_calendar extends DokuWiki_Action_Plugin { 17 18 /** 19 * Log debug message only if CALENDAR_DEBUG is enabled 20 */ 21 private function debugLog($message) { 22 if (CALENDAR_DEBUG) { 23 error_log($message); 24 } 25 } 26 27 /** 28 * Safely read and decode a JSON file with error handling 29 * @param string $filepath Path to JSON file 30 * @return array Decoded array or empty array on error 31 */ 32 private function safeJsonRead($filepath) { 33 if (!file_exists($filepath)) { 34 return []; 35 } 36 37 $contents = @file_get_contents($filepath); 38 if ($contents === false) { 39 $this->debugLog("Failed to read file: $filepath"); 40 return []; 41 } 42 43 $decoded = json_decode($contents, true); 44 if (json_last_error() !== JSON_ERROR_NONE) { 45 $this->debugLog("JSON decode error in $filepath: " . json_last_error_msg()); 46 return []; 47 } 48 49 return is_array($decoded) ? $decoded : []; 50 } 51 52 public function register(Doku_Event_Handler $controller) { 53 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax'); 54 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets'); 55 } 56 57 public function handleAjax(Doku_Event $event, $param) { 58 if ($event->data !== 'plugin_calendar') return; 59 $event->preventDefault(); 60 $event->stopPropagation(); 61 62 $action = $_REQUEST['action'] ?? ''; 63 64 // Actions that modify data require CSRF token verification 65 $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces', 66 'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring', 67 'trim_recurring', 'pause_recurring', 'resume_recurring', 68 'change_start_recurring', 'change_pattern_recurring']; 69 70 if (in_array($action, $writeActions)) { 71 // Check for valid security token 72 $sectok = $_REQUEST['sectok'] ?? ''; 73 if (!checkSecurityToken($sectok)) { 74 echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']); 75 return; 76 } 77 } 78 79 switch ($action) { 80 case 'save_event': 81 $this->saveEvent(); 82 break; 83 case 'delete_event': 84 $this->deleteEvent(); 85 break; 86 case 'get_event': 87 $this->getEvent(); 88 break; 89 case 'load_month': 90 $this->loadMonth(); 91 break; 92 case 'get_static_calendar': 93 $this->getStaticCalendar(); 94 break; 95 case 'search_all': 96 $this->searchAllDates(); 97 break; 98 case 'toggle_task': 99 $this->toggleTaskComplete(); 100 break; 101 case 'cleanup_empty_namespaces': 102 case 'trim_all_past_recurring': 103 case 'rescan_recurring': 104 case 'extend_recurring': 105 case 'trim_recurring': 106 case 'pause_recurring': 107 case 'resume_recurring': 108 case 'change_start_recurring': 109 case 'change_pattern_recurring': 110 $this->routeToAdmin($action); 111 break; 112 default: 113 echo json_encode(['success' => false, 'error' => 'Unknown action']); 114 } 115 } 116 117 /** 118 * Route AJAX actions to admin plugin methods 119 */ 120 private function routeToAdmin($action) { 121 $admin = plugin_load('admin', 'calendar'); 122 if ($admin && method_exists($admin, 'handleAjaxAction')) { 123 $admin->handleAjaxAction($action); 124 } else { 125 echo json_encode(['success' => false, 'error' => 'Admin handler not available']); 126 } 127 } 128 129 private function saveEvent() { 130 global $INPUT; 131 132 $namespace = $INPUT->str('namespace', ''); 133 $date = $INPUT->str('date'); 134 $eventId = $INPUT->str('eventId', ''); 135 $title = $INPUT->str('title'); 136 $time = $INPUT->str('time', ''); 137 $endTime = $INPUT->str('endTime', ''); 138 $description = $INPUT->str('description', ''); 139 $color = $INPUT->str('color', '#3498db'); 140 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 141 $isTask = $INPUT->bool('isTask', false); 142 $completed = $INPUT->bool('completed', false); 143 $endDate = $INPUT->str('endDate', ''); 144 $isRecurring = $INPUT->bool('isRecurring', false); 145 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 146 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 147 148 // New recurrence options 149 $recurrenceInterval = $INPUT->int('recurrenceInterval', 1); 150 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 151 if ($recurrenceInterval > 99) $recurrenceInterval = 99; 152 153 $weekDaysStr = $INPUT->str('weekDays', ''); 154 $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : []; 155 156 $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth'); 157 $monthDay = $INPUT->int('monthDay', 0); 158 $ordinalWeek = $INPUT->int('ordinalWeek', 1); 159 $ordinalDay = $INPUT->int('ordinalDay', 0); 160 161 $this->debugLog("=== Calendar saveEvent START ==="); 162 $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'"); 163 $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'"); 164 165 if (!$date || !$title) { 166 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 167 return; 168 } 169 170 // Validate date format (YYYY-MM-DD) 171 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { 172 echo json_encode(['success' => false, 'error' => 'Invalid date format']); 173 return; 174 } 175 176 // Validate oldDate if provided 177 if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) { 178 echo json_encode(['success' => false, 'error' => 'Invalid old date format']); 179 return; 180 } 181 182 // Validate endDate if provided 183 if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) { 184 echo json_encode(['success' => false, 'error' => 'Invalid end date format']); 185 return; 186 } 187 188 // Validate time format (HH:MM) if provided 189 if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) { 190 echo json_encode(['success' => false, 'error' => 'Invalid time format']); 191 return; 192 } 193 194 // Validate endTime format if provided 195 if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) { 196 echo json_encode(['success' => false, 'error' => 'Invalid end time format']); 197 return; 198 } 199 200 // Validate color format (hex color) 201 if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) { 202 $color = '#3498db'; // Reset to default if invalid 203 } 204 205 // Validate namespace (prevent path traversal) 206 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 207 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 208 return; 209 } 210 211 // Validate recurrence type 212 $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly']; 213 if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) { 214 $recurrenceType = 'weekly'; 215 } 216 217 // Validate recurrenceEnd if provided 218 if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) { 219 echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']); 220 return; 221 } 222 223 // Sanitize title length 224 $title = substr(trim($title), 0, 500); 225 226 // Sanitize description length 227 $description = substr($description, 0, 10000); 228 229 // If editing, find the event's ACTUAL namespace (for finding/deleting old event) 230 // We need to search ALL namespaces because user may be changing namespace 231 $oldNamespace = null; // null means "not found yet" 232 if ($eventId) { 233 // Use oldDate if available (date was changed), otherwise use current date 234 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 235 236 // Search using wildcard to find event in ANY namespace 237 $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*'); 238 239 if ($foundNamespace !== null) { 240 $oldNamespace = $foundNamespace; // Could be '' for default namespace 241 $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'"); 242 } else { 243 $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace"); 244 } 245 } 246 247 // Use the namespace provided by the user (allow namespace changes!) 248 // But normalize wildcards and multi-namespace to empty for NEW events 249 if (!$eventId) { 250 $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'"); 251 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 252 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 253 $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty"); 254 $namespace = ''; 255 } else { 256 $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'"); 257 } 258 } else { 259 $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'"); 260 } 261 262 // Generate event ID if new 263 $generatedId = $eventId ?: uniqid(); 264 265 // If editing a recurring event, load existing data to preserve unchanged fields 266 $existingEventData = null; 267 if ($eventId && $isRecurring) { 268 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 269 // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use '' 270 $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace); 271 if ($existingEventData) { 272 $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'"); 273 } 274 } 275 276 // If recurring, generate multiple events 277 if ($isRecurring) { 278 // Merge with existing data if editing (preserve values that weren't changed) 279 if ($existingEventData) { 280 $title = $title ?: $existingEventData['title']; 281 $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : ''); 282 $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : ''); 283 $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : ''); 284 // Only use existing color if new color is default 285 if ($color === '#3498db' && isset($existingEventData['color'])) { 286 $color = $existingEventData['color']; 287 } 288 289 // Preserve namespace in these cases: 290 // 1. Namespace field is empty (user didn't select anything) 291 // 2. Namespace contains wildcards (like "personal;work" or "work*") 292 // 3. Namespace is the same as what was passed (no change intended) 293 $receivedNamespace = $namespace; 294 if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 295 if (isset($existingEventData['namespace'])) { 296 $namespace = $existingEventData['namespace']; 297 $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')"); 298 } else { 299 $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')"); 300 } 301 } else { 302 $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')"); 303 } 304 } else { 305 $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'"); 306 } 307 308 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description, 309 $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd, 310 $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId); 311 echo json_encode(['success' => true]); 312 return; 313 } 314 315 list($year, $month, $day) = explode('-', $date); 316 317 // NEW namespace directory (where we'll save) 318 $dataDir = DOKU_INC . 'data/meta/'; 319 if ($namespace) { 320 $dataDir .= str_replace(':', '/', $namespace) . '/'; 321 } 322 $dataDir .= 'calendar/'; 323 324 if (!is_dir($dataDir)) { 325 mkdir($dataDir, 0755, true); 326 } 327 328 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 329 330 $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'"); 331 332 $events = []; 333 if (file_exists($eventFile)) { 334 $events = json_decode(file_get_contents($eventFile), true); 335 $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location"); 336 } else { 337 $this->debugLog("Calendar saveEvent: New location file does not exist yet"); 338 } 339 340 // If editing and (date changed OR namespace changed), remove from old location first 341 // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace 342 $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace); 343 $dateChanged = ($eventId && $oldDate && $oldDate !== $date); 344 345 $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO')); 346 347 if ($namespaceChanged || $dateChanged) { 348 // Construct OLD data directory using OLD namespace 349 $oldDataDir = DOKU_INC . 'data/meta/'; 350 if ($oldNamespace) { 351 $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/'; 352 } 353 $oldDataDir .= 'calendar/'; 354 355 $deleteDate = $dateChanged ? $oldDate : $date; 356 list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate); 357 $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 358 359 $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'"); 360 361 if (file_exists($oldEventFile)) { 362 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 363 $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates"); 364 365 if (isset($oldEvents[$deleteDate])) { 366 $countBefore = count($oldEvents[$deleteDate]); 367 $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) { 368 return $evt['id'] !== $eventId; 369 })); 370 $countAfter = count($oldEvents[$deleteDate]); 371 372 $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter"); 373 374 if (empty($oldEvents[$deleteDate])) { 375 unset($oldEvents[$deleteDate]); 376 } 377 378 file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT)); 379 $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'"); 380 } else { 381 $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file"); 382 } 383 } else { 384 $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile"); 385 } 386 } else { 387 $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location"); 388 } 389 390 if (!isset($events[$date])) { 391 $events[$date] = []; 392 } elseif (!is_array($events[$date])) { 393 // Fix corrupted data - ensure it's an array 394 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 395 $events[$date] = []; 396 } 397 398 // Store the namespace with the event 399 $eventData = [ 400 'id' => $generatedId, 401 'title' => $title, 402 'time' => $time, 403 'endTime' => $endTime, 404 'description' => $description, 405 'color' => $color, 406 'isTask' => $isTask, 407 'completed' => $completed, 408 'endDate' => $endDate, 409 'namespace' => $namespace, // Store namespace with event 410 'created' => date('Y-m-d H:i:s') 411 ]; 412 413 // Debug logging 414 $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile"); 415 416 // If editing, replace existing event 417 if ($eventId) { 418 $found = false; 419 foreach ($events[$date] as $key => $evt) { 420 if ($evt['id'] === $eventId) { 421 $events[$date][$key] = $eventData; 422 $found = true; 423 break; 424 } 425 } 426 if (!$found) { 427 $events[$date][] = $eventData; 428 } 429 } else { 430 $events[$date][] = $eventData; 431 } 432 433 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 434 435 // If event spans multiple months, add it to the first day of each subsequent month 436 if ($endDate && $endDate !== $date) { 437 $startDateObj = new DateTime($date); 438 $endDateObj = new DateTime($endDate); 439 440 // Get the month/year of the start date 441 $startMonth = $startDateObj->format('Y-m'); 442 443 // Iterate through each month the event spans 444 $currentDate = clone $startDateObj; 445 $currentDate->modify('first day of next month'); // Jump to first of next month 446 447 while ($currentDate <= $endDateObj) { 448 $currentMonth = $currentDate->format('Y-m'); 449 $firstDayOfMonth = $currentDate->format('Y-m-01'); 450 451 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 452 453 // Get the file for this month 454 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 455 456 $currentEvents = []; 457 if (file_exists($currentEventFile)) { 458 $contents = file_get_contents($currentEventFile); 459 $decoded = json_decode($contents, true); 460 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 461 $currentEvents = $decoded; 462 } else { 463 $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 464 } 465 } 466 467 // Add entry for the first day of this month 468 if (!isset($currentEvents[$firstDayOfMonth])) { 469 $currentEvents[$firstDayOfMonth] = []; 470 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 471 // Fix corrupted data - ensure it's an array 472 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 473 $currentEvents[$firstDayOfMonth] = []; 474 } 475 476 // Create a copy with the original start date preserved 477 $eventDataForMonth = $eventData; 478 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 479 480 // Check if event already exists (when editing) 481 $found = false; 482 if ($eventId) { 483 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 484 if ($evt['id'] === $eventId) { 485 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 486 $found = true; 487 break; 488 } 489 } 490 } 491 492 if (!$found) { 493 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 494 } 495 496 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 497 498 // Move to next month 499 $currentDate->modify('first day of next month'); 500 } 501 } 502 503 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 504 } 505 506 private function deleteEvent() { 507 global $INPUT; 508 509 $namespace = $INPUT->str('namespace', ''); 510 $date = $INPUT->str('date'); 511 $eventId = $INPUT->str('eventId'); 512 513 // Find where the event actually lives 514 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 515 516 if ($storedNamespace === null) { 517 echo json_encode(['success' => false, 'error' => 'Event not found']); 518 return; 519 } 520 521 // Use the found namespace 522 $namespace = $storedNamespace; 523 524 list($year, $month, $day) = explode('-', $date); 525 526 $dataDir = DOKU_INC . 'data/meta/'; 527 if ($namespace) { 528 $dataDir .= str_replace(':', '/', $namespace) . '/'; 529 } 530 $dataDir .= 'calendar/'; 531 532 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 533 534 // First, get the event to check if it spans multiple months or is recurring 535 $eventToDelete = null; 536 $isRecurring = false; 537 $recurringId = null; 538 539 if (file_exists($eventFile)) { 540 $events = json_decode(file_get_contents($eventFile), true); 541 542 if (isset($events[$date])) { 543 foreach ($events[$date] as $event) { 544 if ($event['id'] === $eventId) { 545 $eventToDelete = $event; 546 $isRecurring = isset($event['recurring']) && $event['recurring']; 547 $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 548 break; 549 } 550 } 551 552 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 553 return $event['id'] !== $eventId; 554 })); 555 556 if (empty($events[$date])) { 557 unset($events[$date]); 558 } 559 560 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 561 } 562 } 563 564 // If this is a recurring event, delete ALL occurrences with the same recurringId 565 if ($isRecurring && $recurringId) { 566 $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir); 567 } 568 569 // If event spans multiple months, delete it from the first day of each subsequent month 570 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 571 $startDateObj = new DateTime($date); 572 $endDateObj = new DateTime($eventToDelete['endDate']); 573 574 // Iterate through each month the event spans 575 $currentDate = clone $startDateObj; 576 $currentDate->modify('first day of next month'); // Jump to first of next month 577 578 while ($currentDate <= $endDateObj) { 579 $firstDayOfMonth = $currentDate->format('Y-m-01'); 580 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 581 582 // Get the file for this month 583 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 584 585 if (file_exists($currentEventFile)) { 586 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 587 588 if (isset($currentEvents[$firstDayOfMonth])) { 589 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 590 return $event['id'] !== $eventId; 591 })); 592 593 if (empty($currentEvents[$firstDayOfMonth])) { 594 unset($currentEvents[$firstDayOfMonth]); 595 } 596 597 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 598 } 599 } 600 601 // Move to next month 602 $currentDate->modify('first day of next month'); 603 } 604 } 605 606 echo json_encode(['success' => true]); 607 } 608 609 private function getEvent() { 610 global $INPUT; 611 612 $namespace = $INPUT->str('namespace', ''); 613 $date = $INPUT->str('date'); 614 $eventId = $INPUT->str('eventId'); 615 616 // Find where the event actually lives 617 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 618 619 if ($storedNamespace === null) { 620 echo json_encode(['success' => false, 'error' => 'Event not found']); 621 return; 622 } 623 624 // Use the found namespace 625 $namespace = $storedNamespace; 626 627 list($year, $month, $day) = explode('-', $date); 628 629 $dataDir = DOKU_INC . 'data/meta/'; 630 if ($namespace) { 631 $dataDir .= str_replace(':', '/', $namespace) . '/'; 632 } 633 $dataDir .= 'calendar/'; 634 635 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 636 637 if (file_exists($eventFile)) { 638 $events = json_decode(file_get_contents($eventFile), true); 639 640 if (isset($events[$date])) { 641 foreach ($events[$date] as $event) { 642 if ($event['id'] === $eventId) { 643 // Include the namespace so JavaScript knows where this event actually lives 644 $event['namespace'] = $namespace; 645 echo json_encode(['success' => true, 'event' => $event]); 646 return; 647 } 648 } 649 } 650 } 651 652 echo json_encode(['success' => false, 'error' => 'Event not found']); 653 } 654 655 private function loadMonth() { 656 global $INPUT; 657 658 // Prevent caching of AJAX responses 659 header('Cache-Control: no-cache, no-store, must-revalidate'); 660 header('Pragma: no-cache'); 661 header('Expires: 0'); 662 663 $namespace = $INPUT->str('namespace', ''); 664 $year = $INPUT->int('year'); 665 $month = $INPUT->int('month'); 666 667 // Validate year (reasonable range: 1970-2100) 668 if ($year < 1970 || $year > 2100) { 669 $year = (int)date('Y'); 670 } 671 672 // Validate month (1-12) 673 if ($month < 1 || $month > 12) { 674 $month = (int)date('n'); 675 } 676 677 // Validate namespace format 678 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 679 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 680 return; 681 } 682 683 $this->debugLog("=== Calendar loadMonth DEBUG ==="); 684 $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'"); 685 686 // Check if multi-namespace or wildcard 687 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 688 689 $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 690 691 if ($isMultiNamespace) { 692 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 693 } else { 694 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 695 } 696 697 $this->debugLog("Returning " . count($events) . " date keys"); 698 foreach ($events as $dateKey => $dayEvents) { 699 $this->debugLog(" dateKey=$dateKey has " . count($dayEvents) . " events"); 700 } 701 702 echo json_encode([ 703 'success' => true, 704 'year' => $year, 705 'month' => $month, 706 'events' => $events 707 ]); 708 } 709 710 /** 711 * Get static calendar HTML via AJAX for navigation 712 */ 713 private function getStaticCalendar() { 714 global $INPUT; 715 716 $namespace = $INPUT->str('namespace', ''); 717 $year = $INPUT->int('year'); 718 $month = $INPUT->int('month'); 719 720 // Validate 721 if ($year < 1970 || $year > 2100) { 722 $year = (int)date('Y'); 723 } 724 if ($month < 1 || $month > 12) { 725 $month = (int)date('n'); 726 } 727 728 // Get syntax plugin to render the static calendar 729 $syntax = plugin_load('syntax', 'calendar'); 730 if (!$syntax) { 731 echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']); 732 return; 733 } 734 735 // Build data array for render 736 $data = [ 737 'year' => $year, 738 'month' => $month, 739 'namespace' => $namespace, 740 'static' => true 741 ]; 742 743 // Call the render method via reflection (since renderStaticCalendar is private) 744 $reflector = new \ReflectionClass($syntax); 745 $method = $reflector->getMethod('renderStaticCalendar'); 746 $method->setAccessible(true); 747 $html = $method->invoke($syntax, $data); 748 749 echo json_encode([ 750 'success' => true, 751 'html' => $html 752 ]); 753 } 754 755 private function loadEventsSingleNamespace($namespace, $year, $month) { 756 $dataDir = DOKU_INC . 'data/meta/'; 757 if ($namespace) { 758 $dataDir .= str_replace(':', '/', $namespace) . '/'; 759 } 760 $dataDir .= 'calendar/'; 761 762 // Load ONLY current month 763 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 764 $events = []; 765 if (file_exists($eventFile)) { 766 $contents = file_get_contents($eventFile); 767 $decoded = json_decode($contents, true); 768 if (json_last_error() === JSON_ERROR_NONE) { 769 $events = $decoded; 770 } 771 } 772 773 return $events; 774 } 775 776 private function loadEventsMultiNamespace($namespaces, $year, $month) { 777 // Check for wildcard pattern 778 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 779 $baseNamespace = $matches[1]; 780 return $this->loadEventsWildcard($baseNamespace, $year, $month); 781 } 782 783 // Check for root wildcard 784 if ($namespaces === '*') { 785 return $this->loadEventsWildcard('', $year, $month); 786 } 787 788 // Parse namespace list (semicolon separated) 789 $namespaceList = array_map('trim', explode(';', $namespaces)); 790 791 // Load events from all namespaces 792 $allEvents = []; 793 foreach ($namespaceList as $ns) { 794 $ns = trim($ns); 795 if (empty($ns)) continue; 796 797 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 798 799 // Add namespace tag to each event 800 foreach ($events as $dateKey => $dayEvents) { 801 if (!isset($allEvents[$dateKey])) { 802 $allEvents[$dateKey] = []; 803 } 804 foreach ($dayEvents as $event) { 805 $event['_namespace'] = $ns; 806 $allEvents[$dateKey][] = $event; 807 } 808 } 809 } 810 811 return $allEvents; 812 } 813 814 private function loadEventsWildcard($baseNamespace, $year, $month) { 815 $dataDir = DOKU_INC . 'data/meta/'; 816 if ($baseNamespace) { 817 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 818 } 819 820 $allEvents = []; 821 822 // First, load events from the base namespace itself 823 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 824 825 foreach ($events as $dateKey => $dayEvents) { 826 if (!isset($allEvents[$dateKey])) { 827 $allEvents[$dateKey] = []; 828 } 829 foreach ($dayEvents as $event) { 830 $event['_namespace'] = $baseNamespace; 831 $allEvents[$dateKey][] = $event; 832 } 833 } 834 835 // Recursively find all subdirectories 836 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 837 838 return $allEvents; 839 } 840 841 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 842 if (!is_dir($dir)) return; 843 844 $items = scandir($dir); 845 foreach ($items as $item) { 846 if ($item === '.' || $item === '..') continue; 847 848 $path = $dir . $item; 849 if (is_dir($path) && $item !== 'calendar') { 850 // This is a namespace directory 851 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 852 853 // Load events from this namespace 854 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 855 foreach ($events as $dateKey => $dayEvents) { 856 if (!isset($allEvents[$dateKey])) { 857 $allEvents[$dateKey] = []; 858 } 859 foreach ($dayEvents as $event) { 860 $event['_namespace'] = $namespace; 861 $allEvents[$dateKey][] = $event; 862 } 863 } 864 865 // Recurse into subdirectories 866 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 867 } 868 } 869 } 870 871 /** 872 * Search all dates for events matching the search term 873 */ 874 private function searchAllDates() { 875 global $INPUT; 876 877 $searchTerm = strtolower(trim($INPUT->str('search', ''))); 878 $namespace = $INPUT->str('namespace', ''); 879 880 if (strlen($searchTerm) < 2) { 881 echo json_encode(['success' => false, 'error' => 'Search term too short']); 882 return; 883 } 884 885 // Normalize search term for fuzzy matching 886 $normalizedSearch = $this->normalizeForSearch($searchTerm); 887 888 $results = []; 889 $dataDir = DOKU_INC . 'data/meta/'; 890 891 // Helper to search calendar directory 892 $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results) { 893 if (!is_dir($calDir)) return; 894 895 foreach (glob($calDir . '/*.json') as $file) { 896 $data = @json_decode(file_get_contents($file), true); 897 if (!$data || !is_array($data)) continue; 898 899 foreach ($data as $dateKey => $dayEvents) { 900 // Skip non-date keys 901 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 902 if (!is_array($dayEvents)) continue; 903 904 foreach ($dayEvents as $event) { 905 if (!isset($event['title'])) continue; 906 907 // Build searchable text 908 $searchableText = strtolower($event['title']); 909 if (isset($event['description'])) { 910 $searchableText .= ' ' . strtolower($event['description']); 911 } 912 913 // Normalize for fuzzy matching 914 $normalizedText = $this->normalizeForSearch($searchableText); 915 916 // Check if matches using fuzzy match 917 if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) { 918 $results[] = [ 919 'date' => $dateKey, 920 'title' => $event['title'], 921 'time' => isset($event['time']) ? $event['time'] : '', 922 'endTime' => isset($event['endTime']) ? $event['endTime'] : '', 923 'color' => isset($event['color']) ? $event['color'] : '', 924 'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace, 925 'id' => isset($event['id']) ? $event['id'] : '' 926 ]; 927 } 928 } 929 } 930 } 931 }; 932 933 // Search root calendar directory 934 $searchCalendarDir($dataDir . 'calendar', ''); 935 936 // Search namespace directories 937 $this->searchNamespaceDirs($dataDir, $searchCalendarDir); 938 939 // Sort results by date (newest first for past, oldest first for future) 940 usort($results, function($a, $b) { 941 return strcmp($a['date'], $b['date']); 942 }); 943 944 // Limit results 945 $results = array_slice($results, 0, 50); 946 947 echo json_encode([ 948 'success' => true, 949 'results' => $results, 950 'total' => count($results) 951 ]); 952 } 953 954 /** 955 * Check if normalized text matches normalized search term 956 * Supports multi-word search where all words must be present 957 */ 958 private function fuzzyMatchText($normalizedText, $normalizedSearch) { 959 // Direct substring match 960 if (strpos($normalizedText, $normalizedSearch) !== false) { 961 return true; 962 } 963 964 // Multi-word search: all words must be present 965 $searchWords = array_filter(explode(' ', $normalizedSearch)); 966 if (count($searchWords) > 1) { 967 foreach ($searchWords as $word) { 968 if (strlen($word) > 0 && strpos($normalizedText, $word) === false) { 969 return false; 970 } 971 } 972 return true; 973 } 974 975 return false; 976 } 977 978 /** 979 * Normalize text for fuzzy search matching 980 * Removes apostrophes, extra spaces, and common variations 981 */ 982 private function normalizeForSearch($text) { 983 // Convert to lowercase 984 $text = strtolower($text); 985 986 // Remove apostrophes and quotes (father's -> fathers) 987 $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text); 988 989 // Normalize dashes and underscores to spaces 990 $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text); 991 992 // Remove other punctuation but keep letters, numbers, spaces 993 $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text); 994 995 // Normalize multiple spaces to single space 996 $text = preg_replace('/\s+/', ' ', $text); 997 998 // Trim 999 $text = trim($text); 1000 1001 return $text; 1002 } 1003 1004 /** 1005 * Recursively search namespace directories for calendar data 1006 */ 1007 private function searchNamespaceDirs($baseDir, $callback) { 1008 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 1009 $name = basename($nsDir); 1010 if ($name === 'calendar') continue; 1011 1012 $calDir = $nsDir . '/calendar'; 1013 if (is_dir($calDir)) { 1014 $relPath = str_replace(DOKU_INC . 'data/meta/', '', $nsDir); 1015 $namespace = str_replace('/', ':', $relPath); 1016 $callback($calDir, $namespace); 1017 } 1018 1019 // Recurse 1020 $this->searchNamespaceDirs($nsDir . '/', $callback); 1021 } 1022 } 1023 1024 private function toggleTaskComplete() { 1025 global $INPUT; 1026 1027 $namespace = $INPUT->str('namespace', ''); 1028 $date = $INPUT->str('date'); 1029 $eventId = $INPUT->str('eventId'); 1030 $completed = $INPUT->bool('completed', false); 1031 1032 // Find where the event actually lives 1033 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 1034 1035 if ($storedNamespace === null) { 1036 echo json_encode(['success' => false, 'error' => 'Event not found']); 1037 return; 1038 } 1039 1040 // Use the found namespace 1041 $namespace = $storedNamespace; 1042 1043 list($year, $month, $day) = explode('-', $date); 1044 1045 $dataDir = DOKU_INC . 'data/meta/'; 1046 if ($namespace) { 1047 $dataDir .= str_replace(':', '/', $namespace) . '/'; 1048 } 1049 $dataDir .= 'calendar/'; 1050 1051 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1052 1053 if (file_exists($eventFile)) { 1054 $events = json_decode(file_get_contents($eventFile), true); 1055 1056 if (isset($events[$date])) { 1057 foreach ($events[$date] as $key => $event) { 1058 if ($event['id'] === $eventId) { 1059 $events[$date][$key]['completed'] = $completed; 1060 break; 1061 } 1062 } 1063 1064 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 1065 echo json_encode(['success' => true, 'events' => $events]); 1066 return; 1067 } 1068 } 1069 1070 echo json_encode(['success' => false, 'error' => 'Event not found']); 1071 } 1072 1073 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime, 1074 $description, $color, $isTask, $recurrenceType, $recurrenceInterval, 1075 $recurrenceEnd, $weekDays, $monthlyType, $monthDay, 1076 $ordinalWeek, $ordinalDay, $baseId) { 1077 $dataDir = DOKU_INC . 'data/meta/'; 1078 if ($namespace) { 1079 $dataDir .= str_replace(':', '/', $namespace) . '/'; 1080 } 1081 $dataDir .= 'calendar/'; 1082 1083 if (!is_dir($dataDir)) { 1084 mkdir($dataDir, 0755, true); 1085 } 1086 1087 // Ensure interval is at least 1 1088 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 1089 1090 // Set maximum end date if not specified (1 year from start) 1091 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 1092 1093 // Calculate event duration for multi-day events 1094 $eventDuration = 0; 1095 if ($endDate && $endDate !== $startDate) { 1096 $start = new DateTime($startDate); 1097 $end = new DateTime($endDate); 1098 $eventDuration = $start->diff($end)->days; 1099 } 1100 1101 // Generate recurring events 1102 $currentDate = new DateTime($startDate); 1103 $endLimit = new DateTime($maxEnd); 1104 $counter = 0; 1105 $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year) 1106 1107 // For weekly with specific days, we need to track the interval counter differently 1108 $weekCounter = 0; 1109 $startWeekNumber = (int)$currentDate->format('W'); 1110 $startYear = (int)$currentDate->format('Y'); 1111 1112 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 1113 $shouldCreateEvent = false; 1114 1115 switch ($recurrenceType) { 1116 case 'daily': 1117 // Every N days from start 1118 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 1119 $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0); 1120 break; 1121 1122 case 'weekly': 1123 // Every N weeks, on specified days 1124 $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat 1125 1126 // Calculate weeks since start 1127 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 1128 $weeksSinceStart = floor($daysSinceStart / 7); 1129 1130 // Check if we're in the right week (every N weeks) 1131 $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0); 1132 1133 // Check if this day is selected 1134 $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays); 1135 1136 // For the first week, only include days on or after the start date 1137 $isOnOrAfterStart = ($currentDate >= new DateTime($startDate)); 1138 1139 $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart; 1140 break; 1141 1142 case 'monthly': 1143 // Calculate months since start 1144 $startDT = new DateTime($startDate); 1145 $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) + 1146 ($currentDate->format('n') - $startDT->format('n')); 1147 1148 // Check if we're in the right month (every N months) 1149 $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0); 1150 1151 if (!$isCorrectMonth) { 1152 // Skip to first day of next potential month 1153 $currentDate->modify('first day of next month'); 1154 continue 2; 1155 } 1156 1157 if ($monthlyType === 'dayOfMonth') { 1158 // Specific day of month (e.g., 15th) 1159 $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j'); 1160 $currentDay = (int)$currentDate->format('j'); 1161 $daysInMonth = (int)$currentDate->format('t'); 1162 1163 // If target day exceeds days in month, use last day 1164 $effectiveTargetDay = min($targetDay, $daysInMonth); 1165 $shouldCreateEvent = ($currentDay === $effectiveTargetDay); 1166 } else { 1167 // Ordinal weekday (e.g., 2nd Wednesday, last Friday) 1168 $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay); 1169 } 1170 break; 1171 1172 case 'yearly': 1173 // Every N years on same month/day 1174 $startDT = new DateTime($startDate); 1175 $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y'); 1176 1177 // Check if we're in the right year 1178 $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0); 1179 1180 // Check if it's the same month and day 1181 $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d')); 1182 1183 $shouldCreateEvent = $isCorrectYear && $sameMonthDay; 1184 break; 1185 1186 default: 1187 $shouldCreateEvent = false; 1188 } 1189 1190 if ($shouldCreateEvent) { 1191 $dateKey = $currentDate->format('Y-m-d'); 1192 list($year, $month, $day) = explode('-', $dateKey); 1193 1194 // Calculate end date for this occurrence if multi-day 1195 $occurrenceEndDate = ''; 1196 if ($eventDuration > 0) { 1197 $occurrenceEnd = clone $currentDate; 1198 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 1199 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 1200 } 1201 1202 // Load month file 1203 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1204 $events = []; 1205 if (file_exists($eventFile)) { 1206 $events = json_decode(file_get_contents($eventFile), true); 1207 if (!is_array($events)) $events = []; 1208 } 1209 1210 if (!isset($events[$dateKey])) { 1211 $events[$dateKey] = []; 1212 } 1213 1214 // Create event for this occurrence 1215 $eventData = [ 1216 'id' => $baseId . '-' . $counter, 1217 'title' => $title, 1218 'time' => $time, 1219 'endTime' => $endTime, 1220 'description' => $description, 1221 'color' => $color, 1222 'isTask' => $isTask, 1223 'completed' => false, 1224 'endDate' => $occurrenceEndDate, 1225 'recurring' => true, 1226 'recurringId' => $baseId, 1227 'recurrenceType' => $recurrenceType, 1228 'recurrenceInterval' => $recurrenceInterval, 1229 'namespace' => $namespace, 1230 'created' => date('Y-m-d H:i:s') 1231 ]; 1232 1233 // Store additional recurrence info for reference 1234 if ($recurrenceType === 'weekly' && !empty($weekDays)) { 1235 $eventData['weekDays'] = $weekDays; 1236 } 1237 if ($recurrenceType === 'monthly') { 1238 $eventData['monthlyType'] = $monthlyType; 1239 if ($monthlyType === 'dayOfMonth') { 1240 $eventData['monthDay'] = $monthDay; 1241 } else { 1242 $eventData['ordinalWeek'] = $ordinalWeek; 1243 $eventData['ordinalDay'] = $ordinalDay; 1244 } 1245 } 1246 1247 $events[$dateKey][] = $eventData; 1248 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 1249 1250 $counter++; 1251 } 1252 1253 // Move to next day (we check each day individually for complex patterns) 1254 $currentDate->modify('+1 day'); 1255 } 1256 } 1257 1258 /** 1259 * Check if a date is the Nth occurrence of a weekday in its month 1260 * @param DateTime $date The date to check 1261 * @param int $ordinalWeek 1-5 for first-fifth, -1 for last 1262 * @param int $targetDayOfWeek 0=Sunday through 6=Saturday 1263 * @return bool 1264 */ 1265 private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) { 1266 $currentDayOfWeek = (int)$date->format('w'); 1267 1268 // First, check if it's the right day of week 1269 if ($currentDayOfWeek !== $targetDayOfWeek) { 1270 return false; 1271 } 1272 1273 $dayOfMonth = (int)$date->format('j'); 1274 $daysInMonth = (int)$date->format('t'); 1275 1276 if ($ordinalWeek === -1) { 1277 // Last occurrence: check if there's no more of this weekday in the month 1278 $daysRemaining = $daysInMonth - $dayOfMonth; 1279 return $daysRemaining < 7; 1280 } else { 1281 // Nth occurrence: check which occurrence this is 1282 $weekNumber = ceil($dayOfMonth / 7); 1283 return $weekNumber === $ordinalWeek; 1284 } 1285 } 1286 1287 public function addAssets(Doku_Event $event, $param) { 1288 $event->data['link'][] = array( 1289 'type' => 'text/css', 1290 'rel' => 'stylesheet', 1291 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 1292 ); 1293 1294 // Load the main calendar JavaScript 1295 // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues 1296 // The actual code is in calendar-main.js 1297 $event->data['script'][] = array( 1298 'type' => 'text/javascript', 1299 'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js' 1300 ); 1301 } 1302 // Helper function to find an event's stored namespace 1303 private function findEventNamespace($eventId, $date, $searchNamespace) { 1304 list($year, $month, $day) = explode('-', $date); 1305 1306 // List of namespaces to check 1307 $namespacesToCheck = ['']; 1308 1309 // If searchNamespace is a wildcard or multi, we need to search multiple locations 1310 if (!empty($searchNamespace)) { 1311 if (strpos($searchNamespace, ';') !== false) { 1312 // Multi-namespace - check each one 1313 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 1314 $namespacesToCheck[] = ''; // Also check default 1315 } elseif (strpos($searchNamespace, '*') !== false) { 1316 // Wildcard - need to scan directories 1317 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 1318 $namespacesToCheck = $this->findAllNamespaces($baseNs); 1319 $namespacesToCheck[] = ''; // Also check default 1320 } else { 1321 // Single namespace 1322 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 1323 } 1324 } 1325 1326 $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck))); 1327 1328 // Search for the event in all possible namespaces 1329 foreach ($namespacesToCheck as $ns) { 1330 $dataDir = DOKU_INC . 'data/meta/'; 1331 if ($ns) { 1332 $dataDir .= str_replace(':', '/', $ns) . '/'; 1333 } 1334 $dataDir .= 'calendar/'; 1335 1336 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1337 1338 if (file_exists($eventFile)) { 1339 $events = json_decode(file_get_contents($eventFile), true); 1340 if (isset($events[$date])) { 1341 foreach ($events[$date] as $evt) { 1342 if ($evt['id'] === $eventId) { 1343 // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace 1344 // The directory is what matters for deletion - that's where the file actually is 1345 $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')"); 1346 return $ns; 1347 } 1348 } 1349 } 1350 } 1351 } 1352 1353 $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace"); 1354 return null; // Event not found 1355 } 1356 1357 // Helper to find all namespaces under a base namespace 1358 private function findAllNamespaces($baseNamespace) { 1359 $dataDir = DOKU_INC . 'data/meta/'; 1360 if ($baseNamespace) { 1361 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 1362 } 1363 1364 $namespaces = []; 1365 if ($baseNamespace) { 1366 $namespaces[] = $baseNamespace; 1367 } 1368 1369 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 1370 1371 $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces))); 1372 1373 return $namespaces; 1374 } 1375 1376 // Recursive scan for namespaces 1377 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 1378 if (!is_dir($dir)) return; 1379 1380 $items = scandir($dir); 1381 foreach ($items as $item) { 1382 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 1383 1384 $path = $dir . $item; 1385 if (is_dir($path)) { 1386 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1387 $namespaces[] = $namespace; 1388 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 1389 } 1390 } 1391 } 1392 1393 /** 1394 * Delete all instances of a recurring event across all months 1395 */ 1396 private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) { 1397 // Scan all JSON files in the calendar directory 1398 $calendarFiles = glob($dataDir . '*.json'); 1399 1400 foreach ($calendarFiles as $file) { 1401 $modified = false; 1402 $events = json_decode(file_get_contents($file), true); 1403 1404 if (!$events) continue; 1405 1406 // Check each date in the file 1407 foreach ($events as $date => &$dayEvents) { 1408 // Filter out events with matching recurringId 1409 $originalCount = count($dayEvents); 1410 $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) { 1411 $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 1412 return $eventRecurringId !== $recurringId; 1413 })); 1414 1415 if (count($dayEvents) !== $originalCount) { 1416 $modified = true; 1417 } 1418 1419 // Remove empty dates 1420 if (empty($dayEvents)) { 1421 unset($events[$date]); 1422 } 1423 } 1424 1425 // Save if modified 1426 if ($modified) { 1427 file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT)); 1428 } 1429 } 1430 } 1431 1432 /** 1433 * Get existing event data for preserving unchanged fields during edit 1434 */ 1435 private function getExistingEventData($eventId, $date, $namespace) { 1436 list($year, $month, $day) = explode('-', $date); 1437 1438 $dataDir = DOKU_INC . 'data/meta/'; 1439 if ($namespace) { 1440 $dataDir .= str_replace(':', '/', $namespace) . '/'; 1441 } 1442 $dataDir .= 'calendar/'; 1443 1444 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1445 1446 if (!file_exists($eventFile)) { 1447 return null; 1448 } 1449 1450 $events = json_decode(file_get_contents($eventFile), true); 1451 1452 if (!isset($events[$date])) { 1453 return null; 1454 } 1455 1456 // Find the event by ID 1457 foreach ($events[$date] as $event) { 1458 if ($event['id'] === $eventId) { 1459 return $event; 1460 } 1461 } 1462 1463 return null; 1464 } 1465} 1466