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