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