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