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 'toggle_task': 93 $this->toggleTaskComplete(); 94 break; 95 case 'cleanup_empty_namespaces': 96 case 'trim_all_past_recurring': 97 case 'rescan_recurring': 98 case 'extend_recurring': 99 case 'trim_recurring': 100 case 'pause_recurring': 101 case 'resume_recurring': 102 case 'change_start_recurring': 103 case 'change_pattern_recurring': 104 $this->routeToAdmin($action); 105 break; 106 default: 107 echo json_encode(['success' => false, 'error' => 'Unknown action']); 108 } 109 } 110 111 /** 112 * Route AJAX actions to admin plugin methods 113 */ 114 private function routeToAdmin($action) { 115 $admin = plugin_load('admin', 'calendar'); 116 if ($admin && method_exists($admin, 'handleAjaxAction')) { 117 $admin->handleAjaxAction($action); 118 } else { 119 echo json_encode(['success' => false, 'error' => 'Admin handler not available']); 120 } 121 } 122 123 private function saveEvent() { 124 global $INPUT; 125 126 $namespace = $INPUT->str('namespace', ''); 127 $date = $INPUT->str('date'); 128 $eventId = $INPUT->str('eventId', ''); 129 $title = $INPUT->str('title'); 130 $time = $INPUT->str('time', ''); 131 $endTime = $INPUT->str('endTime', ''); 132 $description = $INPUT->str('description', ''); 133 $color = $INPUT->str('color', '#3498db'); 134 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 135 $isTask = $INPUT->bool('isTask', false); 136 $completed = $INPUT->bool('completed', false); 137 $endDate = $INPUT->str('endDate', ''); 138 $isRecurring = $INPUT->bool('isRecurring', false); 139 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 140 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 141 142 if (!$date || !$title) { 143 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 144 return; 145 } 146 147 // Validate date format (YYYY-MM-DD) 148 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { 149 echo json_encode(['success' => false, 'error' => 'Invalid date format']); 150 return; 151 } 152 153 // Validate oldDate if provided 154 if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) { 155 echo json_encode(['success' => false, 'error' => 'Invalid old date format']); 156 return; 157 } 158 159 // Validate endDate if provided 160 if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) { 161 echo json_encode(['success' => false, 'error' => 'Invalid end date format']); 162 return; 163 } 164 165 // Validate time format (HH:MM) if provided 166 if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) { 167 echo json_encode(['success' => false, 'error' => 'Invalid time format']); 168 return; 169 } 170 171 // Validate endTime format if provided 172 if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) { 173 echo json_encode(['success' => false, 'error' => 'Invalid end time format']); 174 return; 175 } 176 177 // Validate color format (hex color) 178 if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) { 179 $color = '#3498db'; // Reset to default if invalid 180 } 181 182 // Validate namespace (prevent path traversal) 183 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 184 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 185 return; 186 } 187 188 // Validate recurrence type 189 $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly']; 190 if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) { 191 $recurrenceType = 'weekly'; 192 } 193 194 // Validate recurrenceEnd if provided 195 if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) { 196 echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']); 197 return; 198 } 199 200 // Sanitize title length 201 $title = substr(trim($title), 0, 500); 202 203 // Sanitize description length 204 $description = substr($description, 0, 10000); 205 206 // If editing, find the event's stored namespace (for finding/deleting old event) 207 $storedNamespace = ''; 208 $oldNamespace = ''; 209 if ($eventId) { 210 // Use oldDate if available (date was changed), otherwise use current date 211 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 212 $storedNamespace = $this->findEventNamespace($eventId, $searchDate, $namespace); 213 214 // Store the old namespace for deletion purposes 215 if ($storedNamespace !== null) { 216 $oldNamespace = $storedNamespace; 217 $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'"); 218 } 219 } 220 221 // Use the namespace provided by the user (allow namespace changes!) 222 // But normalize wildcards and multi-namespace to empty for NEW events 223 if (!$eventId) { 224 $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'"); 225 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 226 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 227 $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty"); 228 $namespace = ''; 229 } else { 230 $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'"); 231 } 232 } else { 233 $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'"); 234 } 235 236 // Generate event ID if new 237 $generatedId = $eventId ?: uniqid(); 238 239 // If editing a recurring event, load existing data to preserve unchanged fields 240 $existingEventData = null; 241 if ($eventId && $isRecurring) { 242 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 243 $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?: $namespace); 244 if ($existingEventData) { 245 $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'"); 246 } 247 } 248 249 // If recurring, generate multiple events 250 if ($isRecurring) { 251 // Merge with existing data if editing (preserve values that weren't changed) 252 if ($existingEventData) { 253 $title = $title ?: $existingEventData['title']; 254 $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : ''); 255 $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : ''); 256 $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : ''); 257 // Only use existing color if new color is default 258 if ($color === '#3498db' && isset($existingEventData['color'])) { 259 $color = $existingEventData['color']; 260 } 261 262 // Preserve namespace in these cases: 263 // 1. Namespace field is empty (user didn't select anything) 264 // 2. Namespace contains wildcards (like "personal;work" or "work*") 265 // 3. Namespace is the same as what was passed (no change intended) 266 $receivedNamespace = $namespace; 267 if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 268 if (isset($existingEventData['namespace'])) { 269 $namespace = $existingEventData['namespace']; 270 $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')"); 271 } else { 272 $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')"); 273 } 274 } else { 275 $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')"); 276 } 277 } else { 278 $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'"); 279 } 280 281 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $description, 282 $color, $isTask, $recurrenceType, $recurrenceEnd, $generatedId); 283 echo json_encode(['success' => true]); 284 return; 285 } 286 287 list($year, $month, $day) = explode('-', $date); 288 289 // NEW namespace directory (where we'll save) 290 $dataDir = DOKU_INC . 'data/meta/'; 291 if ($namespace) { 292 $dataDir .= str_replace(':', '/', $namespace) . '/'; 293 } 294 $dataDir .= 'calendar/'; 295 296 if (!is_dir($dataDir)) { 297 mkdir($dataDir, 0755, true); 298 } 299 300 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 301 302 $events = []; 303 if (file_exists($eventFile)) { 304 $events = json_decode(file_get_contents($eventFile), true); 305 } 306 307 // If editing and (date changed OR namespace changed), remove from old location first 308 $namespaceChanged = ($eventId && $oldNamespace !== '' && $oldNamespace !== $namespace); 309 $dateChanged = ($eventId && $oldDate && $oldDate !== $date); 310 311 if ($namespaceChanged || $dateChanged) { 312 // Construct OLD data directory using OLD namespace 313 $oldDataDir = DOKU_INC . 'data/meta/'; 314 if ($oldNamespace) { 315 $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/'; 316 } 317 $oldDataDir .= 'calendar/'; 318 319 $deleteDate = $dateChanged ? $oldDate : $date; 320 list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate); 321 $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 322 323 if (file_exists($oldEventFile)) { 324 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 325 if (isset($oldEvents[$deleteDate])) { 326 $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) { 327 return $evt['id'] !== $eventId; 328 })); 329 330 if (empty($oldEvents[$deleteDate])) { 331 unset($oldEvents[$deleteDate]); 332 } 333 334 file_put_contents($oldEventFile, json_encode($oldEvents, JSON_PRETTY_PRINT)); 335 $this->debugLog("Calendar saveEvent: Deleted event from old location - namespace:'$oldNamespace', date:'$deleteDate'"); 336 } 337 } 338 } 339 340 if (!isset($events[$date])) { 341 $events[$date] = []; 342 } elseif (!is_array($events[$date])) { 343 // Fix corrupted data - ensure it's an array 344 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 345 $events[$date] = []; 346 } 347 348 // Store the namespace with the event 349 $eventData = [ 350 'id' => $generatedId, 351 'title' => $title, 352 'time' => $time, 353 'endTime' => $endTime, 354 'description' => $description, 355 'color' => $color, 356 'isTask' => $isTask, 357 'completed' => $completed, 358 'endDate' => $endDate, 359 'namespace' => $namespace, // Store namespace with event 360 'created' => date('Y-m-d H:i:s') 361 ]; 362 363 // Debug logging 364 $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile"); 365 366 // If editing, replace existing event 367 if ($eventId) { 368 $found = false; 369 foreach ($events[$date] as $key => $evt) { 370 if ($evt['id'] === $eventId) { 371 $events[$date][$key] = $eventData; 372 $found = true; 373 break; 374 } 375 } 376 if (!$found) { 377 $events[$date][] = $eventData; 378 } 379 } else { 380 $events[$date][] = $eventData; 381 } 382 383 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 384 385 // If event spans multiple months, add it to the first day of each subsequent month 386 if ($endDate && $endDate !== $date) { 387 $startDateObj = new DateTime($date); 388 $endDateObj = new DateTime($endDate); 389 390 // Get the month/year of the start date 391 $startMonth = $startDateObj->format('Y-m'); 392 393 // Iterate through each month the event spans 394 $currentDate = clone $startDateObj; 395 $currentDate->modify('first day of next month'); // Jump to first of next month 396 397 while ($currentDate <= $endDateObj) { 398 $currentMonth = $currentDate->format('Y-m'); 399 $firstDayOfMonth = $currentDate->format('Y-m-01'); 400 401 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 402 403 // Get the file for this month 404 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 405 406 $currentEvents = []; 407 if (file_exists($currentEventFile)) { 408 $contents = file_get_contents($currentEventFile); 409 $decoded = json_decode($contents, true); 410 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 411 $currentEvents = $decoded; 412 } else { 413 $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 414 } 415 } 416 417 // Add entry for the first day of this month 418 if (!isset($currentEvents[$firstDayOfMonth])) { 419 $currentEvents[$firstDayOfMonth] = []; 420 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 421 // Fix corrupted data - ensure it's an array 422 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 423 $currentEvents[$firstDayOfMonth] = []; 424 } 425 426 // Create a copy with the original start date preserved 427 $eventDataForMonth = $eventData; 428 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 429 430 // Check if event already exists (when editing) 431 $found = false; 432 if ($eventId) { 433 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 434 if ($evt['id'] === $eventId) { 435 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 436 $found = true; 437 break; 438 } 439 } 440 } 441 442 if (!$found) { 443 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 444 } 445 446 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 447 448 // Move to next month 449 $currentDate->modify('first day of next month'); 450 } 451 } 452 453 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 454 } 455 456 private function deleteEvent() { 457 global $INPUT; 458 459 $namespace = $INPUT->str('namespace', ''); 460 $date = $INPUT->str('date'); 461 $eventId = $INPUT->str('eventId'); 462 463 // Find where the event actually lives 464 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 465 466 if ($storedNamespace === null) { 467 echo json_encode(['success' => false, 'error' => 'Event not found']); 468 return; 469 } 470 471 // Use the found namespace 472 $namespace = $storedNamespace; 473 474 list($year, $month, $day) = explode('-', $date); 475 476 $dataDir = DOKU_INC . 'data/meta/'; 477 if ($namespace) { 478 $dataDir .= str_replace(':', '/', $namespace) . '/'; 479 } 480 $dataDir .= 'calendar/'; 481 482 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 483 484 // First, get the event to check if it spans multiple months or is recurring 485 $eventToDelete = null; 486 $isRecurring = false; 487 $recurringId = null; 488 489 if (file_exists($eventFile)) { 490 $events = json_decode(file_get_contents($eventFile), true); 491 492 if (isset($events[$date])) { 493 foreach ($events[$date] as $event) { 494 if ($event['id'] === $eventId) { 495 $eventToDelete = $event; 496 $isRecurring = isset($event['recurring']) && $event['recurring']; 497 $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 498 break; 499 } 500 } 501 502 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 503 return $event['id'] !== $eventId; 504 })); 505 506 if (empty($events[$date])) { 507 unset($events[$date]); 508 } 509 510 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 511 } 512 } 513 514 // If this is a recurring event, delete ALL occurrences with the same recurringId 515 if ($isRecurring && $recurringId) { 516 $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir); 517 } 518 519 // If event spans multiple months, delete it from the first day of each subsequent month 520 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 521 $startDateObj = new DateTime($date); 522 $endDateObj = new DateTime($eventToDelete['endDate']); 523 524 // Iterate through each month the event spans 525 $currentDate = clone $startDateObj; 526 $currentDate->modify('first day of next month'); // Jump to first of next month 527 528 while ($currentDate <= $endDateObj) { 529 $firstDayOfMonth = $currentDate->format('Y-m-01'); 530 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 531 532 // Get the file for this month 533 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 534 535 if (file_exists($currentEventFile)) { 536 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 537 538 if (isset($currentEvents[$firstDayOfMonth])) { 539 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 540 return $event['id'] !== $eventId; 541 })); 542 543 if (empty($currentEvents[$firstDayOfMonth])) { 544 unset($currentEvents[$firstDayOfMonth]); 545 } 546 547 file_put_contents($currentEventFile, json_encode($currentEvents, JSON_PRETTY_PRINT)); 548 } 549 } 550 551 // Move to next month 552 $currentDate->modify('first day of next month'); 553 } 554 } 555 556 echo json_encode(['success' => true]); 557 } 558 559 private function getEvent() { 560 global $INPUT; 561 562 $namespace = $INPUT->str('namespace', ''); 563 $date = $INPUT->str('date'); 564 $eventId = $INPUT->str('eventId'); 565 566 // Find where the event actually lives 567 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 568 569 if ($storedNamespace === null) { 570 echo json_encode(['success' => false, 'error' => 'Event not found']); 571 return; 572 } 573 574 // Use the found namespace 575 $namespace = $storedNamespace; 576 577 list($year, $month, $day) = explode('-', $date); 578 579 $dataDir = DOKU_INC . 'data/meta/'; 580 if ($namespace) { 581 $dataDir .= str_replace(':', '/', $namespace) . '/'; 582 } 583 $dataDir .= 'calendar/'; 584 585 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 586 587 if (file_exists($eventFile)) { 588 $events = json_decode(file_get_contents($eventFile), true); 589 590 if (isset($events[$date])) { 591 foreach ($events[$date] as $event) { 592 if ($event['id'] === $eventId) { 593 // Include the namespace so JavaScript knows where this event actually lives 594 $event['namespace'] = $namespace; 595 echo json_encode(['success' => true, 'event' => $event]); 596 return; 597 } 598 } 599 } 600 } 601 602 echo json_encode(['success' => false, 'error' => 'Event not found']); 603 } 604 605 private function loadMonth() { 606 global $INPUT; 607 608 // Prevent caching of AJAX responses 609 header('Cache-Control: no-cache, no-store, must-revalidate'); 610 header('Pragma: no-cache'); 611 header('Expires: 0'); 612 613 $namespace = $INPUT->str('namespace', ''); 614 $year = $INPUT->int('year'); 615 $month = $INPUT->int('month'); 616 617 // Validate year (reasonable range: 1970-2100) 618 if ($year < 1970 || $year > 2100) { 619 $year = (int)date('Y'); 620 } 621 622 // Validate month (1-12) 623 if ($month < 1 || $month > 12) { 624 $month = (int)date('n'); 625 } 626 627 // Validate namespace format 628 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 629 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 630 return; 631 } 632 633 $this->debugLog("=== Calendar loadMonth DEBUG ==="); 634 $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'"); 635 636 // Check if multi-namespace or wildcard 637 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 638 639 $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 640 641 if ($isMultiNamespace) { 642 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 643 } else { 644 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 645 } 646 647 $this->debugLog("Returning " . count($events) . " date keys"); 648 foreach ($events as $dateKey => $dayEvents) { 649 $this->debugLog(" dateKey=$dateKey has " . count($dayEvents) . " events"); 650 } 651 652 echo json_encode([ 653 'success' => true, 654 'year' => $year, 655 'month' => $month, 656 'events' => $events 657 ]); 658 } 659 660 private function loadEventsSingleNamespace($namespace, $year, $month) { 661 $dataDir = DOKU_INC . 'data/meta/'; 662 if ($namespace) { 663 $dataDir .= str_replace(':', '/', $namespace) . '/'; 664 } 665 $dataDir .= 'calendar/'; 666 667 // Load ONLY current month 668 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 669 $events = []; 670 if (file_exists($eventFile)) { 671 $contents = file_get_contents($eventFile); 672 $decoded = json_decode($contents, true); 673 if (json_last_error() === JSON_ERROR_NONE) { 674 $events = $decoded; 675 } 676 } 677 678 return $events; 679 } 680 681 private function loadEventsMultiNamespace($namespaces, $year, $month) { 682 // Check for wildcard pattern 683 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 684 $baseNamespace = $matches[1]; 685 return $this->loadEventsWildcard($baseNamespace, $year, $month); 686 } 687 688 // Check for root wildcard 689 if ($namespaces === '*') { 690 return $this->loadEventsWildcard('', $year, $month); 691 } 692 693 // Parse namespace list (semicolon separated) 694 $namespaceList = array_map('trim', explode(';', $namespaces)); 695 696 // Load events from all namespaces 697 $allEvents = []; 698 foreach ($namespaceList as $ns) { 699 $ns = trim($ns); 700 if (empty($ns)) continue; 701 702 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 703 704 // Add namespace tag to each event 705 foreach ($events as $dateKey => $dayEvents) { 706 if (!isset($allEvents[$dateKey])) { 707 $allEvents[$dateKey] = []; 708 } 709 foreach ($dayEvents as $event) { 710 $event['_namespace'] = $ns; 711 $allEvents[$dateKey][] = $event; 712 } 713 } 714 } 715 716 return $allEvents; 717 } 718 719 private function loadEventsWildcard($baseNamespace, $year, $month) { 720 $dataDir = DOKU_INC . 'data/meta/'; 721 if ($baseNamespace) { 722 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 723 } 724 725 $allEvents = []; 726 727 // First, load events from the base namespace itself 728 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 729 730 foreach ($events as $dateKey => $dayEvents) { 731 if (!isset($allEvents[$dateKey])) { 732 $allEvents[$dateKey] = []; 733 } 734 foreach ($dayEvents as $event) { 735 $event['_namespace'] = $baseNamespace; 736 $allEvents[$dateKey][] = $event; 737 } 738 } 739 740 // Recursively find all subdirectories 741 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 742 743 return $allEvents; 744 } 745 746 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 747 if (!is_dir($dir)) return; 748 749 $items = scandir($dir); 750 foreach ($items as $item) { 751 if ($item === '.' || $item === '..') continue; 752 753 $path = $dir . $item; 754 if (is_dir($path) && $item !== 'calendar') { 755 // This is a namespace directory 756 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 757 758 // Load events from this namespace 759 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 760 foreach ($events as $dateKey => $dayEvents) { 761 if (!isset($allEvents[$dateKey])) { 762 $allEvents[$dateKey] = []; 763 } 764 foreach ($dayEvents as $event) { 765 $event['_namespace'] = $namespace; 766 $allEvents[$dateKey][] = $event; 767 } 768 } 769 770 // Recurse into subdirectories 771 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 772 } 773 } 774 } 775 776 private function toggleTaskComplete() { 777 global $INPUT; 778 779 $namespace = $INPUT->str('namespace', ''); 780 $date = $INPUT->str('date'); 781 $eventId = $INPUT->str('eventId'); 782 $completed = $INPUT->bool('completed', false); 783 784 // Find where the event actually lives 785 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 786 787 if ($storedNamespace === null) { 788 echo json_encode(['success' => false, 'error' => 'Event not found']); 789 return; 790 } 791 792 // Use the found namespace 793 $namespace = $storedNamespace; 794 795 list($year, $month, $day) = explode('-', $date); 796 797 $dataDir = DOKU_INC . 'data/meta/'; 798 if ($namespace) { 799 $dataDir .= str_replace(':', '/', $namespace) . '/'; 800 } 801 $dataDir .= 'calendar/'; 802 803 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 804 805 if (file_exists($eventFile)) { 806 $events = json_decode(file_get_contents($eventFile), true); 807 808 if (isset($events[$date])) { 809 foreach ($events[$date] as $key => $event) { 810 if ($event['id'] === $eventId) { 811 $events[$date][$key]['completed'] = $completed; 812 break; 813 } 814 } 815 816 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 817 echo json_encode(['success' => true, 'events' => $events]); 818 return; 819 } 820 } 821 822 echo json_encode(['success' => false, 'error' => 'Event not found']); 823 } 824 825 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, 826 $description, $color, $isTask, $recurrenceType, 827 $recurrenceEnd, $baseId) { 828 $dataDir = DOKU_INC . 'data/meta/'; 829 if ($namespace) { 830 $dataDir .= str_replace(':', '/', $namespace) . '/'; 831 } 832 $dataDir .= 'calendar/'; 833 834 if (!is_dir($dataDir)) { 835 mkdir($dataDir, 0755, true); 836 } 837 838 // Calculate recurrence interval 839 $interval = ''; 840 switch ($recurrenceType) { 841 case 'daily': $interval = '+1 day'; break; 842 case 'weekly': $interval = '+1 week'; break; 843 case 'monthly': $interval = '+1 month'; break; 844 case 'yearly': $interval = '+1 year'; break; 845 default: $interval = '+1 week'; 846 } 847 848 // Set maximum end date if not specified (1 year from start) 849 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 850 851 // Calculate event duration for multi-day events 852 $eventDuration = 0; 853 if ($endDate && $endDate !== $startDate) { 854 $start = new DateTime($startDate); 855 $end = new DateTime($endDate); 856 $eventDuration = $start->diff($end)->days; 857 } 858 859 // Generate recurring events 860 $currentDate = new DateTime($startDate); 861 $endLimit = new DateTime($maxEnd); 862 $counter = 0; 863 $maxOccurrences = 100; // Prevent infinite loops 864 865 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 866 $dateKey = $currentDate->format('Y-m-d'); 867 list($year, $month, $day) = explode('-', $dateKey); 868 869 // Calculate end date for this occurrence if multi-day 870 $occurrenceEndDate = ''; 871 if ($eventDuration > 0) { 872 $occurrenceEnd = clone $currentDate; 873 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 874 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 875 } 876 877 // Load month file 878 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 879 $events = []; 880 if (file_exists($eventFile)) { 881 $events = json_decode(file_get_contents($eventFile), true); 882 } 883 884 if (!isset($events[$dateKey])) { 885 $events[$dateKey] = []; 886 } 887 888 // Create event for this occurrence 889 $eventData = [ 890 'id' => $baseId . '-' . $counter, 891 'title' => $title, 892 'time' => $time, 893 'endTime' => $endTime, 894 'description' => $description, 895 'color' => $color, 896 'isTask' => $isTask, 897 'completed' => false, 898 'endDate' => $occurrenceEndDate, 899 'recurring' => true, 900 'recurringId' => $baseId, 901 'namespace' => $namespace, // Add namespace! 902 'created' => date('Y-m-d H:i:s') 903 ]; 904 905 $events[$dateKey][] = $eventData; 906 file_put_contents($eventFile, json_encode($events, JSON_PRETTY_PRINT)); 907 908 // Move to next occurrence 909 $currentDate->modify($interval); 910 $counter++; 911 } 912 } 913 914 public function addAssets(Doku_Event $event, $param) { 915 $event->data['link'][] = array( 916 'type' => 'text/css', 917 'rel' => 'stylesheet', 918 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 919 ); 920 921 $event->data['script'][] = array( 922 'type' => 'text/javascript', 923 'src' => DOKU_BASE . 'lib/plugins/calendar/script.js' 924 ); 925 } 926 // Helper function to find an event's stored namespace 927 private function findEventNamespace($eventId, $date, $searchNamespace) { 928 list($year, $month, $day) = explode('-', $date); 929 930 // List of namespaces to check 931 $namespacesToCheck = ['']; 932 933 // If searchNamespace is a wildcard or multi, we need to search multiple locations 934 if (!empty($searchNamespace)) { 935 if (strpos($searchNamespace, ';') !== false) { 936 // Multi-namespace - check each one 937 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 938 $namespacesToCheck[] = ''; // Also check default 939 } elseif (strpos($searchNamespace, '*') !== false) { 940 // Wildcard - need to scan directories 941 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 942 $namespacesToCheck = $this->findAllNamespaces($baseNs); 943 $namespacesToCheck[] = ''; // Also check default 944 } else { 945 // Single namespace 946 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 947 } 948 } 949 950 // Search for the event in all possible namespaces 951 foreach ($namespacesToCheck as $ns) { 952 $dataDir = DOKU_INC . 'data/meta/'; 953 if ($ns) { 954 $dataDir .= str_replace(':', '/', $ns) . '/'; 955 } 956 $dataDir .= 'calendar/'; 957 958 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 959 960 if (file_exists($eventFile)) { 961 $events = json_decode(file_get_contents($eventFile), true); 962 if (isset($events[$date])) { 963 foreach ($events[$date] as $evt) { 964 if ($evt['id'] === $eventId) { 965 // Found the event! Return its stored namespace 966 return isset($evt['namespace']) ? $evt['namespace'] : $ns; 967 } 968 } 969 } 970 } 971 } 972 973 return null; // Event not found 974 } 975 976 // Helper to find all namespaces under a base namespace 977 private function findAllNamespaces($baseNamespace) { 978 $dataDir = DOKU_INC . 'data/meta/'; 979 if ($baseNamespace) { 980 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 981 } 982 983 $namespaces = []; 984 if ($baseNamespace) { 985 $namespaces[] = $baseNamespace; 986 } 987 988 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 989 990 return $namespaces; 991 } 992 993 // Recursive scan for namespaces 994 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 995 if (!is_dir($dir)) return; 996 997 $items = scandir($dir); 998 foreach ($items as $item) { 999 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 1000 1001 $path = $dir . $item; 1002 if (is_dir($path)) { 1003 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1004 $namespaces[] = $namespace; 1005 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 1006 } 1007 } 1008 } 1009 1010 /** 1011 * Delete all instances of a recurring event across all months 1012 */ 1013 private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) { 1014 // Scan all JSON files in the calendar directory 1015 $calendarFiles = glob($dataDir . '*.json'); 1016 1017 foreach ($calendarFiles as $file) { 1018 $modified = false; 1019 $events = json_decode(file_get_contents($file), true); 1020 1021 if (!$events) continue; 1022 1023 // Check each date in the file 1024 foreach ($events as $date => &$dayEvents) { 1025 // Filter out events with matching recurringId 1026 $originalCount = count($dayEvents); 1027 $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) { 1028 $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 1029 return $eventRecurringId !== $recurringId; 1030 })); 1031 1032 if (count($dayEvents) !== $originalCount) { 1033 $modified = true; 1034 } 1035 1036 // Remove empty dates 1037 if (empty($dayEvents)) { 1038 unset($events[$date]); 1039 } 1040 } 1041 1042 // Save if modified 1043 if ($modified) { 1044 file_put_contents($file, json_encode($events, JSON_PRETTY_PRINT)); 1045 } 1046 } 1047 } 1048 1049 /** 1050 * Get existing event data for preserving unchanged fields during edit 1051 */ 1052 private function getExistingEventData($eventId, $date, $namespace) { 1053 list($year, $month, $day) = explode('-', $date); 1054 1055 $dataDir = DOKU_INC . 'data/meta/'; 1056 if ($namespace) { 1057 $dataDir .= str_replace(':', '/', $namespace) . '/'; 1058 } 1059 $dataDir .= 'calendar/'; 1060 1061 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1062 1063 if (!file_exists($eventFile)) { 1064 return null; 1065 } 1066 1067 $events = json_decode(file_get_contents($eventFile), true); 1068 1069 if (!isset($events[$date])) { 1070 return null; 1071 } 1072 1073 // Find the event by ID 1074 foreach ($events[$date] as $event) { 1075 if ($event['id'] === $eventId) { 1076 return $event; 1077 } 1078 } 1079 1080 return null; 1081 } 1082} 1083