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