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