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