119378907SAtari911<?php 219378907SAtari911/** 319378907SAtari911 * DokuWiki Plugin calendar (Action Component) 419378907SAtari911 * 519378907SAtari911 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 619378907SAtari911 * @author DokuWiki Community 7*815440faSAtari911 * @version 7.0.8 819378907SAtari911 */ 919378907SAtari911 1019378907SAtari911if (!defined('DOKU_INC')) die(); 1119378907SAtari911 127e8ea635SAtari911// Set to true to enable verbose debug logging (should be false in production) 137e8ea635SAtari911if (!defined('CALENDAR_DEBUG')) { 147e8ea635SAtari911 define('CALENDAR_DEBUG', false); 157e8ea635SAtari911} 167e8ea635SAtari911 17*815440faSAtari911// Load new class dependencies 18*815440faSAtari911require_once __DIR__ . '/classes/FileHandler.php'; 19*815440faSAtari911require_once __DIR__ . '/classes/EventCache.php'; 20*815440faSAtari911require_once __DIR__ . '/classes/RateLimiter.php'; 21*815440faSAtari911require_once __DIR__ . '/classes/EventManager.php'; 22*815440faSAtari911require_once __DIR__ . '/classes/AuditLogger.php'; 23*815440faSAtari911require_once __DIR__ . '/classes/GoogleCalendarSync.php'; 24*815440faSAtari911 2519378907SAtari911class action_plugin_calendar extends DokuWiki_Action_Plugin { 2619378907SAtari911 27*815440faSAtari911 /** @var CalendarAuditLogger */ 28*815440faSAtari911 private $auditLogger = null; 29*815440faSAtari911 30*815440faSAtari911 /** @var GoogleCalendarSync */ 31*815440faSAtari911 private $googleSync = null; 32*815440faSAtari911 33*815440faSAtari911 /** 34*815440faSAtari911 * Get the audit logger instance 35*815440faSAtari911 */ 36*815440faSAtari911 private function getAuditLogger() { 37*815440faSAtari911 if ($this->auditLogger === null) { 38*815440faSAtari911 $this->auditLogger = new CalendarAuditLogger(); 39*815440faSAtari911 } 40*815440faSAtari911 return $this->auditLogger; 41*815440faSAtari911 } 42*815440faSAtari911 43*815440faSAtari911 /** 44*815440faSAtari911 * Get the Google Calendar sync instance 45*815440faSAtari911 */ 46*815440faSAtari911 private function getGoogleSync() { 47*815440faSAtari911 if ($this->googleSync === null) { 48*815440faSAtari911 $this->googleSync = new GoogleCalendarSync(); 49*815440faSAtari911 } 50*815440faSAtari911 return $this->googleSync; 51*815440faSAtari911 } 52*815440faSAtari911 537e8ea635SAtari911 /** 547e8ea635SAtari911 * Log debug message only if CALENDAR_DEBUG is enabled 557e8ea635SAtari911 */ 567e8ea635SAtari911 private function debugLog($message) { 577e8ea635SAtari911 if (CALENDAR_DEBUG) { 587e8ea635SAtari911 error_log($message); 597e8ea635SAtari911 } 607e8ea635SAtari911 } 617e8ea635SAtari911 627e8ea635SAtari911 /** 637e8ea635SAtari911 * Safely read and decode a JSON file with error handling 64*815440faSAtari911 * Uses the new CalendarFileHandler for atomic reads with locking 657e8ea635SAtari911 * @param string $filepath Path to JSON file 667e8ea635SAtari911 * @return array Decoded array or empty array on error 677e8ea635SAtari911 */ 687e8ea635SAtari911 private function safeJsonRead($filepath) { 69*815440faSAtari911 return CalendarFileHandler::readJson($filepath); 707e8ea635SAtari911 } 717e8ea635SAtari911 72*815440faSAtari911 /** 73*815440faSAtari911 * Safely write JSON data to file with atomic writes 74*815440faSAtari911 * Uses the new CalendarFileHandler for atomic writes with locking 75*815440faSAtari911 * @param string $filepath Path to JSON file 76*815440faSAtari911 * @param array $data Data to write 77*815440faSAtari911 * @return bool Success status 78*815440faSAtari911 */ 79*815440faSAtari911 private function safeJsonWrite($filepath, array $data) { 80*815440faSAtari911 return CalendarFileHandler::writeJson($filepath, $data); 817e8ea635SAtari911 } 827e8ea635SAtari911 8319378907SAtari911 public function register(Doku_Event_Handler $controller) { 8419378907SAtari911 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjax'); 8519378907SAtari911 $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'addAssets'); 8619378907SAtari911 } 8719378907SAtari911 8819378907SAtari911 public function handleAjax(Doku_Event $event, $param) { 8919378907SAtari911 if ($event->data !== 'plugin_calendar') return; 9019378907SAtari911 $event->preventDefault(); 9119378907SAtari911 $event->stopPropagation(); 9219378907SAtari911 9319378907SAtari911 $action = $_REQUEST['action'] ?? ''; 9419378907SAtari911 95b498f308SAtari911 // Actions that modify data require authentication and CSRF token verification 967e8ea635SAtari911 $writeActions = ['save_event', 'delete_event', 'toggle_task', 'cleanup_empty_namespaces', 977e8ea635SAtari911 'trim_all_past_recurring', 'rescan_recurring', 'extend_recurring', 987e8ea635SAtari911 'trim_recurring', 'pause_recurring', 'resume_recurring', 997e8ea635SAtari911 'change_start_recurring', 'change_pattern_recurring']; 1007e8ea635SAtari911 101*815440faSAtari911 $isWriteAction = in_array($action, $writeActions); 102*815440faSAtari911 103*815440faSAtari911 // Rate limiting check - apply to all AJAX actions 104*815440faSAtari911 if (!CalendarRateLimiter::check($action, $isWriteAction)) { 105*815440faSAtari911 CalendarRateLimiter::addHeaders($action, $isWriteAction); 106*815440faSAtari911 http_response_code(429); 107*815440faSAtari911 echo json_encode([ 108*815440faSAtari911 'success' => false, 109*815440faSAtari911 'error' => 'Rate limit exceeded. Please wait before making more requests.', 110*815440faSAtari911 'retry_after' => CalendarRateLimiter::getRemaining($action, $isWriteAction)['reset'] 111*815440faSAtari911 ]); 112*815440faSAtari911 return; 113*815440faSAtari911 } 114*815440faSAtari911 115*815440faSAtari911 // Add rate limit headers to all responses 116*815440faSAtari911 CalendarRateLimiter::addHeaders($action, $isWriteAction); 117*815440faSAtari911 118*815440faSAtari911 if ($isWriteAction) { 119b498f308SAtari911 global $INPUT, $INFO; 120b498f308SAtari911 121b498f308SAtari911 // Check if user is logged in (at minimum) 122b498f308SAtari911 if (empty($_SERVER['REMOTE_USER'])) { 123b498f308SAtari911 echo json_encode(['success' => false, 'error' => 'Authentication required. Please log in.']); 124b498f308SAtari911 return; 125b498f308SAtari911 } 126b498f308SAtari911 127b498f308SAtari911 // Check for valid security token - try multiple sources 128b498f308SAtari911 $sectok = $INPUT->str('sectok', ''); 129b498f308SAtari911 if (empty($sectok)) { 1307e8ea635SAtari911 $sectok = $_REQUEST['sectok'] ?? ''; 131b498f308SAtari911 } 132b498f308SAtari911 133b498f308SAtari911 // Use DokuWiki's built-in check 1347e8ea635SAtari911 if (!checkSecurityToken($sectok)) { 135b498f308SAtari911 // Log for debugging 136b498f308SAtari911 $this->debugLog("Security token check failed. Received: '$sectok'"); 1377e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid security token. Please refresh the page and try again.']); 1387e8ea635SAtari911 return; 1397e8ea635SAtari911 } 1407e8ea635SAtari911 } 1417e8ea635SAtari911 14219378907SAtari911 switch ($action) { 14319378907SAtari911 case 'save_event': 14419378907SAtari911 $this->saveEvent(); 14519378907SAtari911 break; 14619378907SAtari911 case 'delete_event': 14719378907SAtari911 $this->deleteEvent(); 14819378907SAtari911 break; 14919378907SAtari911 case 'get_event': 15019378907SAtari911 $this->getEvent(); 15119378907SAtari911 break; 15219378907SAtari911 case 'load_month': 15319378907SAtari911 $this->loadMonth(); 15419378907SAtari911 break; 155da206178SAtari911 case 'get_static_calendar': 156da206178SAtari911 $this->getStaticCalendar(); 157da206178SAtari911 break; 15896df7d3eSAtari911 case 'search_all': 15996df7d3eSAtari911 $this->searchAllDates(); 16096df7d3eSAtari911 break; 16119378907SAtari911 case 'toggle_task': 16219378907SAtari911 $this->toggleTaskComplete(); 16319378907SAtari911 break; 164*815440faSAtari911 case 'google_auth_url': 165*815440faSAtari911 $this->getGoogleAuthUrl(); 166*815440faSAtari911 break; 167*815440faSAtari911 case 'google_callback': 168*815440faSAtari911 $this->handleGoogleCallback(); 169*815440faSAtari911 break; 170*815440faSAtari911 case 'google_status': 171*815440faSAtari911 $this->getGoogleStatus(); 172*815440faSAtari911 break; 173*815440faSAtari911 case 'google_calendars': 174*815440faSAtari911 $this->getGoogleCalendars(); 175*815440faSAtari911 break; 176*815440faSAtari911 case 'google_import': 177*815440faSAtari911 $this->googleImport(); 178*815440faSAtari911 break; 179*815440faSAtari911 case 'google_export': 180*815440faSAtari911 $this->googleExport(); 181*815440faSAtari911 break; 182*815440faSAtari911 case 'google_disconnect': 183*815440faSAtari911 $this->googleDisconnect(); 184*815440faSAtari911 break; 1857e8ea635SAtari911 case 'cleanup_empty_namespaces': 1867e8ea635SAtari911 case 'trim_all_past_recurring': 1877e8ea635SAtari911 case 'rescan_recurring': 1887e8ea635SAtari911 case 'extend_recurring': 1897e8ea635SAtari911 case 'trim_recurring': 1907e8ea635SAtari911 case 'pause_recurring': 1917e8ea635SAtari911 case 'resume_recurring': 1927e8ea635SAtari911 case 'change_start_recurring': 1937e8ea635SAtari911 case 'change_pattern_recurring': 1947e8ea635SAtari911 $this->routeToAdmin($action); 1957e8ea635SAtari911 break; 19619378907SAtari911 default: 19719378907SAtari911 echo json_encode(['success' => false, 'error' => 'Unknown action']); 19819378907SAtari911 } 19919378907SAtari911 } 20019378907SAtari911 2017e8ea635SAtari911 /** 2027e8ea635SAtari911 * Route AJAX actions to admin plugin methods 2037e8ea635SAtari911 */ 2047e8ea635SAtari911 private function routeToAdmin($action) { 2057e8ea635SAtari911 $admin = plugin_load('admin', 'calendar'); 2067e8ea635SAtari911 if ($admin && method_exists($admin, 'handleAjaxAction')) { 2077e8ea635SAtari911 $admin->handleAjaxAction($action); 2087e8ea635SAtari911 } else { 2097e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Admin handler not available']); 2107e8ea635SAtari911 } 2117e8ea635SAtari911 } 2127e8ea635SAtari911 21319378907SAtari911 private function saveEvent() { 21419378907SAtari911 global $INPUT; 21519378907SAtari911 21619378907SAtari911 $namespace = $INPUT->str('namespace', ''); 21719378907SAtari911 $date = $INPUT->str('date'); 21819378907SAtari911 $eventId = $INPUT->str('eventId', ''); 21919378907SAtari911 $title = $INPUT->str('title'); 22019378907SAtari911 $time = $INPUT->str('time', ''); 2211d05cddcSAtari911 $endTime = $INPUT->str('endTime', ''); 22219378907SAtari911 $description = $INPUT->str('description', ''); 22319378907SAtari911 $color = $INPUT->str('color', '#3498db'); 22419378907SAtari911 $oldDate = $INPUT->str('oldDate', ''); // Track original date for moves 22519378907SAtari911 $isTask = $INPUT->bool('isTask', false); 22619378907SAtari911 $completed = $INPUT->bool('completed', false); 22719378907SAtari911 $endDate = $INPUT->str('endDate', ''); 22887ac9bf3SAtari911 $isRecurring = $INPUT->bool('isRecurring', false); 22987ac9bf3SAtari911 $recurrenceType = $INPUT->str('recurrenceType', 'weekly'); 23087ac9bf3SAtari911 $recurrenceEnd = $INPUT->str('recurrenceEnd', ''); 23119378907SAtari911 23296df7d3eSAtari911 // New recurrence options 23396df7d3eSAtari911 $recurrenceInterval = $INPUT->int('recurrenceInterval', 1); 23496df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 23596df7d3eSAtari911 if ($recurrenceInterval > 99) $recurrenceInterval = 99; 23696df7d3eSAtari911 23796df7d3eSAtari911 $weekDaysStr = $INPUT->str('weekDays', ''); 23896df7d3eSAtari911 $weekDays = $weekDaysStr ? array_map('intval', explode(',', $weekDaysStr)) : []; 23996df7d3eSAtari911 24096df7d3eSAtari911 $monthlyType = $INPUT->str('monthlyType', 'dayOfMonth'); 24196df7d3eSAtari911 $monthDay = $INPUT->int('monthDay', 0); 24296df7d3eSAtari911 $ordinalWeek = $INPUT->int('ordinalWeek', 1); 24396df7d3eSAtari911 $ordinalDay = $INPUT->int('ordinalDay', 0); 24496df7d3eSAtari911 24596df7d3eSAtari911 $this->debugLog("=== Calendar saveEvent START ==="); 24696df7d3eSAtari911 $this->debugLog("Calendar saveEvent: INPUT namespace='$namespace', eventId='$eventId', date='$date', oldDate='$oldDate', title='$title'"); 24796df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Recurrence - type='$recurrenceType', interval=$recurrenceInterval, weekDays=" . implode(',', $weekDays) . ", monthlyType='$monthlyType'"); 24896df7d3eSAtari911 24919378907SAtari911 if (!$date || !$title) { 25019378907SAtari911 echo json_encode(['success' => false, 'error' => 'Missing required fields']); 25119378907SAtari911 return; 25219378907SAtari911 } 25319378907SAtari911 2547e8ea635SAtari911 // Validate date format (YYYY-MM-DD) 2557e8ea635SAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) || !strtotime($date)) { 2567e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid date format']); 2577e8ea635SAtari911 return; 2587e8ea635SAtari911 } 2597e8ea635SAtari911 2607e8ea635SAtari911 // Validate oldDate if provided 2617e8ea635SAtari911 if ($oldDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $oldDate) || !strtotime($oldDate))) { 2627e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid old date format']); 2637e8ea635SAtari911 return; 2647e8ea635SAtari911 } 2657e8ea635SAtari911 2667e8ea635SAtari911 // Validate endDate if provided 2677e8ea635SAtari911 if ($endDate && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) || !strtotime($endDate))) { 2687e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end date format']); 2697e8ea635SAtari911 return; 2707e8ea635SAtari911 } 2717e8ea635SAtari911 2727e8ea635SAtari911 // Validate time format (HH:MM) if provided 2737e8ea635SAtari911 if ($time && !preg_match('/^\d{2}:\d{2}$/', $time)) { 2747e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid time format']); 2757e8ea635SAtari911 return; 2767e8ea635SAtari911 } 2777e8ea635SAtari911 2787e8ea635SAtari911 // Validate endTime format if provided 2797e8ea635SAtari911 if ($endTime && !preg_match('/^\d{2}:\d{2}$/', $endTime)) { 2807e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid end time format']); 2817e8ea635SAtari911 return; 2827e8ea635SAtari911 } 2837e8ea635SAtari911 2847e8ea635SAtari911 // Validate color format (hex color) 2857e8ea635SAtari911 if (!preg_match('/^#[0-9A-Fa-f]{6}$/', $color)) { 2867e8ea635SAtari911 $color = '#3498db'; // Reset to default if invalid 2877e8ea635SAtari911 } 2887e8ea635SAtari911 2897e8ea635SAtari911 // Validate namespace (prevent path traversal) 2907e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 2917e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 2927e8ea635SAtari911 return; 2937e8ea635SAtari911 } 2947e8ea635SAtari911 2957e8ea635SAtari911 // Validate recurrence type 2967e8ea635SAtari911 $validRecurrenceTypes = ['daily', 'weekly', 'biweekly', 'monthly', 'yearly']; 2977e8ea635SAtari911 if ($isRecurring && !in_array($recurrenceType, $validRecurrenceTypes)) { 2987e8ea635SAtari911 $recurrenceType = 'weekly'; 2997e8ea635SAtari911 } 3007e8ea635SAtari911 3017e8ea635SAtari911 // Validate recurrenceEnd if provided 3027e8ea635SAtari911 if ($recurrenceEnd && (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $recurrenceEnd) || !strtotime($recurrenceEnd))) { 3037e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid recurrence end date format']); 3047e8ea635SAtari911 return; 3057e8ea635SAtari911 } 3067e8ea635SAtari911 3077e8ea635SAtari911 // Sanitize title length 3087e8ea635SAtari911 $title = substr(trim($title), 0, 500); 3097e8ea635SAtari911 3107e8ea635SAtari911 // Sanitize description length 3117e8ea635SAtari911 $description = substr($description, 0, 10000); 3127e8ea635SAtari911 31396df7d3eSAtari911 // If editing, find the event's ACTUAL namespace (for finding/deleting old event) 31496df7d3eSAtari911 // We need to search ALL namespaces because user may be changing namespace 31596df7d3eSAtari911 $oldNamespace = null; // null means "not found yet" 316e3a9f44cSAtari911 if ($eventId) { 3171d05cddcSAtari911 // Use oldDate if available (date was changed), otherwise use current date 3181d05cddcSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 3191d05cddcSAtari911 32096df7d3eSAtari911 // Search using wildcard to find event in ANY namespace 32196df7d3eSAtari911 $foundNamespace = $this->findEventNamespace($eventId, $searchDate, '*'); 32296df7d3eSAtari911 32396df7d3eSAtari911 if ($foundNamespace !== null) { 32496df7d3eSAtari911 $oldNamespace = $foundNamespace; // Could be '' for default namespace 3257e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Found existing event in namespace '$oldNamespace'"); 32696df7d3eSAtari911 } else { 32796df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Event $eventId not found in any namespace"); 3281d05cddcSAtari911 } 329e3a9f44cSAtari911 } 330e3a9f44cSAtari911 3311d05cddcSAtari911 // Use the namespace provided by the user (allow namespace changes!) 3321d05cddcSAtari911 // But normalize wildcards and multi-namespace to empty for NEW events 3331d05cddcSAtari911 if (!$eventId) { 3347e8ea635SAtari911 $this->debugLog("Calendar saveEvent: NEW event, received namespace='$namespace'"); 335e3a9f44cSAtari911 // Normalize namespace: treat wildcards and multi-namespace as empty (default) for NEW events 336e3a9f44cSAtari911 if (!empty($namespace) && (strpos($namespace, '*') !== false || strpos($namespace, ';') !== false)) { 3377e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace contains wildcard/multi, clearing to empty"); 338e3a9f44cSAtari911 $namespace = ''; 3391d05cddcSAtari911 } else { 3407e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Namespace is clean, keeping as '$namespace'"); 341e3a9f44cSAtari911 } 3421d05cddcSAtari911 } else { 3437e8ea635SAtari911 $this->debugLog("Calendar saveEvent: EDITING event $eventId, user selected namespace='$namespace'"); 344e3a9f44cSAtari911 } 345e3a9f44cSAtari911 34687ac9bf3SAtari911 // Generate event ID if new 34787ac9bf3SAtari911 $generatedId = $eventId ?: uniqid(); 34887ac9bf3SAtari911 3499ccd446eSAtari911 // If editing a recurring event, load existing data to preserve unchanged fields 3509ccd446eSAtari911 $existingEventData = null; 3519ccd446eSAtari911 if ($eventId && $isRecurring) { 3529ccd446eSAtari911 $searchDate = ($oldDate && $oldDate !== $date) ? $oldDate : $date; 35396df7d3eSAtari911 // Use null coalescing: if oldNamespace is null (not found), use new namespace; if '' (default), use '' 35496df7d3eSAtari911 $existingEventData = $this->getExistingEventData($eventId, $searchDate, $oldNamespace ?? $namespace); 3559ccd446eSAtari911 if ($existingEventData) { 3567e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Loaded existing data - namespace='" . ($existingEventData['namespace'] ?? 'NOT SET') . "'"); 3579ccd446eSAtari911 } 3589ccd446eSAtari911 } 3599ccd446eSAtari911 36087ac9bf3SAtari911 // If recurring, generate multiple events 36187ac9bf3SAtari911 if ($isRecurring) { 3629ccd446eSAtari911 // Merge with existing data if editing (preserve values that weren't changed) 3639ccd446eSAtari911 if ($existingEventData) { 3649ccd446eSAtari911 $title = $title ?: $existingEventData['title']; 3659ccd446eSAtari911 $time = $time ?: (isset($existingEventData['time']) ? $existingEventData['time'] : ''); 3669ccd446eSAtari911 $endTime = $endTime ?: (isset($existingEventData['endTime']) ? $existingEventData['endTime'] : ''); 3679ccd446eSAtari911 $description = $description ?: (isset($existingEventData['description']) ? $existingEventData['description'] : ''); 3689ccd446eSAtari911 // Only use existing color if new color is default 3699ccd446eSAtari911 if ($color === '#3498db' && isset($existingEventData['color'])) { 3709ccd446eSAtari911 $color = $existingEventData['color']; 3719ccd446eSAtari911 } 3729ccd446eSAtari911 3739ccd446eSAtari911 // Preserve namespace in these cases: 3749ccd446eSAtari911 // 1. Namespace field is empty (user didn't select anything) 3759ccd446eSAtari911 // 2. Namespace contains wildcards (like "personal;work" or "work*") 3769ccd446eSAtari911 // 3. Namespace is the same as what was passed (no change intended) 3779ccd446eSAtari911 $receivedNamespace = $namespace; 3789ccd446eSAtari911 if (empty($namespace) || strpos($namespace, '*') !== false || strpos($namespace, ';') !== false) { 3799ccd446eSAtari911 if (isset($existingEventData['namespace'])) { 3809ccd446eSAtari911 $namespace = $existingEventData['namespace']; 3817e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Preserving namespace '$namespace' (received='$receivedNamespace')"); 3829ccd446eSAtari911 } else { 3837e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing namespace to preserve (received='$receivedNamespace')"); 3849ccd446eSAtari911 } 3859ccd446eSAtari911 } else { 3867e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: Using new namespace '$namespace' (received='$receivedNamespace')"); 3879ccd446eSAtari911 } 3889ccd446eSAtari911 } else { 3897e8ea635SAtari911 $this->debugLog("Calendar saveEvent recurring: No existing data found, using namespace='$namespace'"); 3909ccd446eSAtari911 } 3919ccd446eSAtari911 39296df7d3eSAtari911 $this->createRecurringEvents($namespace, $date, $endDate, $title, $time, $endTime, $description, 39396df7d3eSAtari911 $color, $isTask, $recurrenceType, $recurrenceInterval, $recurrenceEnd, 39496df7d3eSAtari911 $weekDays, $monthlyType, $monthDay, $ordinalWeek, $ordinalDay, $generatedId); 39587ac9bf3SAtari911 echo json_encode(['success' => true]); 39687ac9bf3SAtari911 return; 39787ac9bf3SAtari911 } 39887ac9bf3SAtari911 39919378907SAtari911 list($year, $month, $day) = explode('-', $date); 40019378907SAtari911 4011d05cddcSAtari911 // NEW namespace directory (where we'll save) 40219378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 40319378907SAtari911 if ($namespace) { 40419378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 40519378907SAtari911 } 40619378907SAtari911 $dataDir .= 'calendar/'; 40719378907SAtari911 40819378907SAtari911 if (!is_dir($dataDir)) { 40919378907SAtari911 mkdir($dataDir, 0755, true); 41019378907SAtari911 } 41119378907SAtari911 41219378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 41319378907SAtari911 41496df7d3eSAtari911 $this->debugLog("Calendar saveEvent: NEW eventFile='$eventFile'"); 41596df7d3eSAtari911 41619378907SAtari911 $events = []; 41719378907SAtari911 if (file_exists($eventFile)) { 41819378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 41996df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Loaded " . count($events) . " dates from new location"); 42096df7d3eSAtari911 } else { 42196df7d3eSAtari911 $this->debugLog("Calendar saveEvent: New location file does not exist yet"); 42219378907SAtari911 } 42319378907SAtari911 4241d05cddcSAtari911 // If editing and (date changed OR namespace changed), remove from old location first 42596df7d3eSAtari911 // $oldNamespace is null if event not found, '' for default namespace, or 'name' for named namespace 42696df7d3eSAtari911 $namespaceChanged = ($eventId && $oldNamespace !== null && $oldNamespace !== $namespace); 4271d05cddcSAtari911 $dateChanged = ($eventId && $oldDate && $oldDate !== $date); 4281d05cddcSAtari911 42996df7d3eSAtari911 $this->debugLog("Calendar saveEvent: eventId='$eventId', oldNamespace=" . var_export($oldNamespace, true) . ", newNamespace='$namespace', namespaceChanged=" . ($namespaceChanged ? 'YES' : 'NO') . ", dateChanged=" . ($dateChanged ? 'YES' : 'NO')); 43096df7d3eSAtari911 4311d05cddcSAtari911 if ($namespaceChanged || $dateChanged) { 4321d05cddcSAtari911 // Construct OLD data directory using OLD namespace 4331d05cddcSAtari911 $oldDataDir = DOKU_INC . 'data/meta/'; 4341d05cddcSAtari911 if ($oldNamespace) { 4351d05cddcSAtari911 $oldDataDir .= str_replace(':', '/', $oldNamespace) . '/'; 4361d05cddcSAtari911 } 4371d05cddcSAtari911 $oldDataDir .= 'calendar/'; 4381d05cddcSAtari911 4391d05cddcSAtari911 $deleteDate = $dateChanged ? $oldDate : $date; 4401d05cddcSAtari911 list($oldYear, $oldMonth, $oldDay) = explode('-', $deleteDate); 4411d05cddcSAtari911 $oldEventFile = $oldDataDir . sprintf('%04d-%02d.json', $oldYear, $oldMonth); 44219378907SAtari911 44396df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Attempting to delete from OLD eventFile='$oldEventFile', deleteDate='$deleteDate'"); 44496df7d3eSAtari911 44519378907SAtari911 if (file_exists($oldEventFile)) { 44619378907SAtari911 $oldEvents = json_decode(file_get_contents($oldEventFile), true); 44796df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file exists, has " . count($oldEvents) . " dates"); 44896df7d3eSAtari911 4491d05cddcSAtari911 if (isset($oldEvents[$deleteDate])) { 45096df7d3eSAtari911 $countBefore = count($oldEvents[$deleteDate]); 4511d05cddcSAtari911 $oldEvents[$deleteDate] = array_values(array_filter($oldEvents[$deleteDate], function($evt) use ($eventId) { 45219378907SAtari911 return $evt['id'] !== $eventId; 453e3a9f44cSAtari911 })); 45496df7d3eSAtari911 $countAfter = count($oldEvents[$deleteDate]); 45596df7d3eSAtari911 45696df7d3eSAtari911 $this->debugLog("Calendar saveEvent: Events on date before=$countBefore, after=$countAfter"); 45719378907SAtari911 4581d05cddcSAtari911 if (empty($oldEvents[$deleteDate])) { 4591d05cddcSAtari911 unset($oldEvents[$deleteDate]); 46019378907SAtari911 } 46119378907SAtari911 462*815440faSAtari911 CalendarFileHandler::writeJson($oldEventFile, $oldEvents); 46396df7d3eSAtari911 $this->debugLog("Calendar saveEvent: DELETED event from old location - namespace:'$oldNamespace', date:'$deleteDate'"); 46496df7d3eSAtari911 } else { 46596df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No events found on deleteDate='$deleteDate' in old file"); 46619378907SAtari911 } 46796df7d3eSAtari911 } else { 46896df7d3eSAtari911 $this->debugLog("Calendar saveEvent: OLD file does NOT exist: $oldEventFile"); 46919378907SAtari911 } 47096df7d3eSAtari911 } else { 47196df7d3eSAtari911 $this->debugLog("Calendar saveEvent: No namespace/date change detected, skipping deletion from old location"); 47219378907SAtari911 } 47319378907SAtari911 47419378907SAtari911 if (!isset($events[$date])) { 47519378907SAtari911 $events[$date] = []; 476e3a9f44cSAtari911 } elseif (!is_array($events[$date])) { 477e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 4787e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $date - was not an array"); 479e3a9f44cSAtari911 $events[$date] = []; 48019378907SAtari911 } 48119378907SAtari911 482e3a9f44cSAtari911 // Store the namespace with the event 48319378907SAtari911 $eventData = [ 48487ac9bf3SAtari911 'id' => $generatedId, 48519378907SAtari911 'title' => $title, 48619378907SAtari911 'time' => $time, 4871d05cddcSAtari911 'endTime' => $endTime, 48819378907SAtari911 'description' => $description, 48919378907SAtari911 'color' => $color, 49019378907SAtari911 'isTask' => $isTask, 49119378907SAtari911 'completed' => $completed, 49219378907SAtari911 'endDate' => $endDate, 493e3a9f44cSAtari911 'namespace' => $namespace, // Store namespace with event 49419378907SAtari911 'created' => date('Y-m-d H:i:s') 49519378907SAtari911 ]; 49619378907SAtari911 4971d05cddcSAtari911 // Debug logging 4987e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Saving event '$title' with namespace='$namespace' to file $eventFile"); 4991d05cddcSAtari911 50019378907SAtari911 // If editing, replace existing event 50119378907SAtari911 if ($eventId) { 50219378907SAtari911 $found = false; 50319378907SAtari911 foreach ($events[$date] as $key => $evt) { 50419378907SAtari911 if ($evt['id'] === $eventId) { 50519378907SAtari911 $events[$date][$key] = $eventData; 50619378907SAtari911 $found = true; 50719378907SAtari911 break; 50819378907SAtari911 } 50919378907SAtari911 } 51019378907SAtari911 if (!$found) { 51119378907SAtari911 $events[$date][] = $eventData; 51219378907SAtari911 } 51319378907SAtari911 } else { 51419378907SAtari911 $events[$date][] = $eventData; 51519378907SAtari911 } 51619378907SAtari911 517*815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 51819378907SAtari911 519e3a9f44cSAtari911 // If event spans multiple months, add it to the first day of each subsequent month 520e3a9f44cSAtari911 if ($endDate && $endDate !== $date) { 521e3a9f44cSAtari911 $startDateObj = new DateTime($date); 522e3a9f44cSAtari911 $endDateObj = new DateTime($endDate); 523e3a9f44cSAtari911 524e3a9f44cSAtari911 // Get the month/year of the start date 525e3a9f44cSAtari911 $startMonth = $startDateObj->format('Y-m'); 526e3a9f44cSAtari911 527e3a9f44cSAtari911 // Iterate through each month the event spans 528e3a9f44cSAtari911 $currentDate = clone $startDateObj; 529e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 530e3a9f44cSAtari911 531e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 532e3a9f44cSAtari911 $currentMonth = $currentDate->format('Y-m'); 533e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 534e3a9f44cSAtari911 535e3a9f44cSAtari911 list($currentYear, $currentMonthNum, $currentDay) = explode('-', $firstDayOfMonth); 536e3a9f44cSAtari911 537e3a9f44cSAtari911 // Get the file for this month 538e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonthNum); 539e3a9f44cSAtari911 540e3a9f44cSAtari911 $currentEvents = []; 541e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 542e3a9f44cSAtari911 $contents = file_get_contents($currentEventFile); 543e3a9f44cSAtari911 $decoded = json_decode($contents, true); 544e3a9f44cSAtari911 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { 545e3a9f44cSAtari911 $currentEvents = $decoded; 546e3a9f44cSAtari911 } else { 5477e8ea635SAtari911 $this->debugLog("Calendar saveEvent: JSON decode error in $currentEventFile: " . json_last_error_msg()); 548e3a9f44cSAtari911 } 549e3a9f44cSAtari911 } 550e3a9f44cSAtari911 551e3a9f44cSAtari911 // Add entry for the first day of this month 552e3a9f44cSAtari911 if (!isset($currentEvents[$firstDayOfMonth])) { 553e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 554e3a9f44cSAtari911 } elseif (!is_array($currentEvents[$firstDayOfMonth])) { 555e3a9f44cSAtari911 // Fix corrupted data - ensure it's an array 5567e8ea635SAtari911 $this->debugLog("Calendar saveEvent: Fixing corrupted data at $firstDayOfMonth - was not an array"); 557e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = []; 558e3a9f44cSAtari911 } 559e3a9f44cSAtari911 560e3a9f44cSAtari911 // Create a copy with the original start date preserved 561e3a9f44cSAtari911 $eventDataForMonth = $eventData; 562e3a9f44cSAtari911 $eventDataForMonth['originalStartDate'] = $date; // Preserve the actual start date 563e3a9f44cSAtari911 564e3a9f44cSAtari911 // Check if event already exists (when editing) 565e3a9f44cSAtari911 $found = false; 566e3a9f44cSAtari911 if ($eventId) { 567e3a9f44cSAtari911 foreach ($currentEvents[$firstDayOfMonth] as $key => $evt) { 568e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 569e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][$key] = $eventDataForMonth; 570e3a9f44cSAtari911 $found = true; 571e3a9f44cSAtari911 break; 572e3a9f44cSAtari911 } 573e3a9f44cSAtari911 } 574e3a9f44cSAtari911 } 575e3a9f44cSAtari911 576e3a9f44cSAtari911 if (!$found) { 577e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth][] = $eventDataForMonth; 578e3a9f44cSAtari911 } 579e3a9f44cSAtari911 580*815440faSAtari911 CalendarFileHandler::writeJson($currentEventFile, $currentEvents); 581e3a9f44cSAtari911 582e3a9f44cSAtari911 // Move to next month 583e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 584e3a9f44cSAtari911 } 585e3a9f44cSAtari911 } 586e3a9f44cSAtari911 587*815440faSAtari911 // Audit logging 588*815440faSAtari911 $audit = $this->getAuditLogger(); 589*815440faSAtari911 if ($eventId && ($dateChanged || $namespaceChanged)) { 590*815440faSAtari911 // Event was moved 591*815440faSAtari911 $audit->logMove($namespace, $oldDate ?: $date, $date, $generatedId, $title); 592*815440faSAtari911 } elseif ($eventId) { 593*815440faSAtari911 // Event was updated 594*815440faSAtari911 $audit->logUpdate($namespace, $date, $generatedId, $title); 595*815440faSAtari911 } else { 596*815440faSAtari911 // New event created 597*815440faSAtari911 $audit->logCreate($namespace, $date, $generatedId, $title); 598*815440faSAtari911 } 599*815440faSAtari911 60019378907SAtari911 echo json_encode(['success' => true, 'events' => $events, 'eventId' => $eventData['id']]); 60119378907SAtari911 } 60219378907SAtari911 60319378907SAtari911 private function deleteEvent() { 60419378907SAtari911 global $INPUT; 60519378907SAtari911 60619378907SAtari911 $namespace = $INPUT->str('namespace', ''); 60719378907SAtari911 $date = $INPUT->str('date'); 60819378907SAtari911 $eventId = $INPUT->str('eventId'); 60919378907SAtari911 610e3a9f44cSAtari911 // Find where the event actually lives 611e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 612e3a9f44cSAtari911 613e3a9f44cSAtari911 if ($storedNamespace === null) { 614e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 615e3a9f44cSAtari911 return; 616e3a9f44cSAtari911 } 617e3a9f44cSAtari911 618e3a9f44cSAtari911 // Use the found namespace 619e3a9f44cSAtari911 $namespace = $storedNamespace; 620e3a9f44cSAtari911 62119378907SAtari911 list($year, $month, $day) = explode('-', $date); 62219378907SAtari911 62319378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 62419378907SAtari911 if ($namespace) { 62519378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 62619378907SAtari911 } 62719378907SAtari911 $dataDir .= 'calendar/'; 62819378907SAtari911 62919378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 63019378907SAtari911 6319ccd446eSAtari911 // First, get the event to check if it spans multiple months or is recurring 632e3a9f44cSAtari911 $eventToDelete = null; 6339ccd446eSAtari911 $isRecurring = false; 6349ccd446eSAtari911 $recurringId = null; 6359ccd446eSAtari911 63619378907SAtari911 if (file_exists($eventFile)) { 63719378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 63819378907SAtari911 63919378907SAtari911 if (isset($events[$date])) { 640e3a9f44cSAtari911 foreach ($events[$date] as $event) { 641e3a9f44cSAtari911 if ($event['id'] === $eventId) { 642e3a9f44cSAtari911 $eventToDelete = $event; 6439ccd446eSAtari911 $isRecurring = isset($event['recurring']) && $event['recurring']; 6449ccd446eSAtari911 $recurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 645e3a9f44cSAtari911 break; 646e3a9f44cSAtari911 } 647e3a9f44cSAtari911 } 648e3a9f44cSAtari911 649e3a9f44cSAtari911 $events[$date] = array_values(array_filter($events[$date], function($event) use ($eventId) { 65019378907SAtari911 return $event['id'] !== $eventId; 651e3a9f44cSAtari911 })); 65219378907SAtari911 65319378907SAtari911 if (empty($events[$date])) { 65419378907SAtari911 unset($events[$date]); 65519378907SAtari911 } 65619378907SAtari911 657*815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 65819378907SAtari911 } 65919378907SAtari911 } 66019378907SAtari911 6619ccd446eSAtari911 // If this is a recurring event, delete ALL occurrences with the same recurringId 6629ccd446eSAtari911 if ($isRecurring && $recurringId) { 6639ccd446eSAtari911 $this->deleteAllRecurringInstances($recurringId, $namespace, $dataDir); 6649ccd446eSAtari911 } 6659ccd446eSAtari911 666e3a9f44cSAtari911 // If event spans multiple months, delete it from the first day of each subsequent month 667e3a9f44cSAtari911 if ($eventToDelete && isset($eventToDelete['endDate']) && $eventToDelete['endDate'] && $eventToDelete['endDate'] !== $date) { 668e3a9f44cSAtari911 $startDateObj = new DateTime($date); 669e3a9f44cSAtari911 $endDateObj = new DateTime($eventToDelete['endDate']); 670e3a9f44cSAtari911 671e3a9f44cSAtari911 // Iterate through each month the event spans 672e3a9f44cSAtari911 $currentDate = clone $startDateObj; 673e3a9f44cSAtari911 $currentDate->modify('first day of next month'); // Jump to first of next month 674e3a9f44cSAtari911 675e3a9f44cSAtari911 while ($currentDate <= $endDateObj) { 676e3a9f44cSAtari911 $firstDayOfMonth = $currentDate->format('Y-m-01'); 677e3a9f44cSAtari911 list($currentYear, $currentMonth, $currentDay) = explode('-', $firstDayOfMonth); 678e3a9f44cSAtari911 679e3a9f44cSAtari911 // Get the file for this month 680e3a9f44cSAtari911 $currentEventFile = $dataDir . sprintf('%04d-%02d.json', $currentYear, $currentMonth); 681e3a9f44cSAtari911 682e3a9f44cSAtari911 if (file_exists($currentEventFile)) { 683e3a9f44cSAtari911 $currentEvents = json_decode(file_get_contents($currentEventFile), true); 684e3a9f44cSAtari911 685e3a9f44cSAtari911 if (isset($currentEvents[$firstDayOfMonth])) { 686e3a9f44cSAtari911 $currentEvents[$firstDayOfMonth] = array_values(array_filter($currentEvents[$firstDayOfMonth], function($event) use ($eventId) { 687e3a9f44cSAtari911 return $event['id'] !== $eventId; 688e3a9f44cSAtari911 })); 689e3a9f44cSAtari911 690e3a9f44cSAtari911 if (empty($currentEvents[$firstDayOfMonth])) { 691e3a9f44cSAtari911 unset($currentEvents[$firstDayOfMonth]); 692e3a9f44cSAtari911 } 693e3a9f44cSAtari911 694*815440faSAtari911 CalendarFileHandler::writeJson($currentEventFile, $currentEvents); 695e3a9f44cSAtari911 } 696e3a9f44cSAtari911 } 697e3a9f44cSAtari911 698e3a9f44cSAtari911 // Move to next month 699e3a9f44cSAtari911 $currentDate->modify('first day of next month'); 700e3a9f44cSAtari911 } 701e3a9f44cSAtari911 } 702e3a9f44cSAtari911 703*815440faSAtari911 // Audit logging 704*815440faSAtari911 $audit = $this->getAuditLogger(); 705*815440faSAtari911 $eventTitle = $eventToDelete ? ($eventToDelete['title'] ?? '') : ''; 706*815440faSAtari911 $audit->logDelete($namespace, $date, $eventId, $eventTitle); 707*815440faSAtari911 70819378907SAtari911 echo json_encode(['success' => true]); 70919378907SAtari911 } 71019378907SAtari911 71119378907SAtari911 private function getEvent() { 71219378907SAtari911 global $INPUT; 71319378907SAtari911 71419378907SAtari911 $namespace = $INPUT->str('namespace', ''); 71519378907SAtari911 $date = $INPUT->str('date'); 71619378907SAtari911 $eventId = $INPUT->str('eventId'); 71719378907SAtari911 718e3a9f44cSAtari911 // Find where the event actually lives 719e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 720e3a9f44cSAtari911 721e3a9f44cSAtari911 if ($storedNamespace === null) { 722e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 723e3a9f44cSAtari911 return; 724e3a9f44cSAtari911 } 725e3a9f44cSAtari911 726e3a9f44cSAtari911 // Use the found namespace 727e3a9f44cSAtari911 $namespace = $storedNamespace; 728e3a9f44cSAtari911 72919378907SAtari911 list($year, $month, $day) = explode('-', $date); 73019378907SAtari911 73119378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 73219378907SAtari911 if ($namespace) { 73319378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 73419378907SAtari911 } 73519378907SAtari911 $dataDir .= 'calendar/'; 73619378907SAtari911 73719378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 73819378907SAtari911 73919378907SAtari911 if (file_exists($eventFile)) { 74019378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 74119378907SAtari911 74219378907SAtari911 if (isset($events[$date])) { 74319378907SAtari911 foreach ($events[$date] as $event) { 74419378907SAtari911 if ($event['id'] === $eventId) { 7451d05cddcSAtari911 // Include the namespace so JavaScript knows where this event actually lives 7461d05cddcSAtari911 $event['namespace'] = $namespace; 74719378907SAtari911 echo json_encode(['success' => true, 'event' => $event]); 74819378907SAtari911 return; 74919378907SAtari911 } 75019378907SAtari911 } 75119378907SAtari911 } 75219378907SAtari911 } 75319378907SAtari911 75419378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 75519378907SAtari911 } 75619378907SAtari911 75719378907SAtari911 private function loadMonth() { 75819378907SAtari911 global $INPUT; 75919378907SAtari911 760e3a9f44cSAtari911 // Prevent caching of AJAX responses 761e3a9f44cSAtari911 header('Cache-Control: no-cache, no-store, must-revalidate'); 762e3a9f44cSAtari911 header('Pragma: no-cache'); 763e3a9f44cSAtari911 header('Expires: 0'); 764e3a9f44cSAtari911 76519378907SAtari911 $namespace = $INPUT->str('namespace', ''); 76619378907SAtari911 $year = $INPUT->int('year'); 76719378907SAtari911 $month = $INPUT->int('month'); 76819378907SAtari911 7697e8ea635SAtari911 // Validate year (reasonable range: 1970-2100) 7707e8ea635SAtari911 if ($year < 1970 || $year > 2100) { 7717e8ea635SAtari911 $year = (int)date('Y'); 7727e8ea635SAtari911 } 7737e8ea635SAtari911 7747e8ea635SAtari911 // Validate month (1-12) 7757e8ea635SAtari911 if ($month < 1 || $month > 12) { 7767e8ea635SAtari911 $month = (int)date('n'); 7777e8ea635SAtari911 } 7787e8ea635SAtari911 7797e8ea635SAtari911 // Validate namespace format 7807e8ea635SAtari911 if ($namespace && !preg_match('/^[a-zA-Z0-9_:;*-]*$/', $namespace)) { 7817e8ea635SAtari911 echo json_encode(['success' => false, 'error' => 'Invalid namespace format']); 7827e8ea635SAtari911 return; 7837e8ea635SAtari911 } 7847e8ea635SAtari911 7857e8ea635SAtari911 $this->debugLog("=== Calendar loadMonth DEBUG ==="); 7867e8ea635SAtari911 $this->debugLog("Requested: year=$year, month=$month, namespace='$namespace'"); 787e3a9f44cSAtari911 788e3a9f44cSAtari911 // Check if multi-namespace or wildcard 789e3a9f44cSAtari911 $isMultiNamespace = !empty($namespace) && (strpos($namespace, ';') !== false || strpos($namespace, '*') !== false); 790e3a9f44cSAtari911 7917e8ea635SAtari911 $this->debugLog("isMultiNamespace: " . ($isMultiNamespace ? 'true' : 'false')); 792e3a9f44cSAtari911 793e3a9f44cSAtari911 if ($isMultiNamespace) { 794e3a9f44cSAtari911 $events = $this->loadEventsMultiNamespace($namespace, $year, $month); 795e3a9f44cSAtari911 } else { 796e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 797e3a9f44cSAtari911 } 798e3a9f44cSAtari911 7997e8ea635SAtari911 $this->debugLog("Returning " . count($events) . " date keys"); 800e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 8017e8ea635SAtari911 $this->debugLog(" dateKey=$dateKey has " . count($dayEvents) . " events"); 802e3a9f44cSAtari911 } 803e3a9f44cSAtari911 804e3a9f44cSAtari911 echo json_encode([ 805e3a9f44cSAtari911 'success' => true, 806e3a9f44cSAtari911 'year' => $year, 807e3a9f44cSAtari911 'month' => $month, 808e3a9f44cSAtari911 'events' => $events 809e3a9f44cSAtari911 ]); 810e3a9f44cSAtari911 } 811e3a9f44cSAtari911 812da206178SAtari911 /** 813da206178SAtari911 * Get static calendar HTML via AJAX for navigation 814da206178SAtari911 */ 815da206178SAtari911 private function getStaticCalendar() { 816da206178SAtari911 global $INPUT; 817da206178SAtari911 818da206178SAtari911 $namespace = $INPUT->str('namespace', ''); 819da206178SAtari911 $year = $INPUT->int('year'); 820da206178SAtari911 $month = $INPUT->int('month'); 821da206178SAtari911 822da206178SAtari911 // Validate 823da206178SAtari911 if ($year < 1970 || $year > 2100) { 824da206178SAtari911 $year = (int)date('Y'); 825da206178SAtari911 } 826da206178SAtari911 if ($month < 1 || $month > 12) { 827da206178SAtari911 $month = (int)date('n'); 828da206178SAtari911 } 829da206178SAtari911 830da206178SAtari911 // Get syntax plugin to render the static calendar 831da206178SAtari911 $syntax = plugin_load('syntax', 'calendar'); 832da206178SAtari911 if (!$syntax) { 833da206178SAtari911 echo json_encode(['success' => false, 'error' => 'Syntax plugin not found']); 834da206178SAtari911 return; 835da206178SAtari911 } 836da206178SAtari911 837da206178SAtari911 // Build data array for render 838da206178SAtari911 $data = [ 839da206178SAtari911 'year' => $year, 840da206178SAtari911 'month' => $month, 841da206178SAtari911 'namespace' => $namespace, 842da206178SAtari911 'static' => true 843da206178SAtari911 ]; 844da206178SAtari911 845da206178SAtari911 // Call the render method via reflection (since renderStaticCalendar is private) 846da206178SAtari911 $reflector = new \ReflectionClass($syntax); 847da206178SAtari911 $method = $reflector->getMethod('renderStaticCalendar'); 848da206178SAtari911 $method->setAccessible(true); 849da206178SAtari911 $html = $method->invoke($syntax, $data); 850da206178SAtari911 851da206178SAtari911 echo json_encode([ 852da206178SAtari911 'success' => true, 853da206178SAtari911 'html' => $html 854da206178SAtari911 ]); 855da206178SAtari911 } 856da206178SAtari911 857e3a9f44cSAtari911 private function loadEventsSingleNamespace($namespace, $year, $month) { 85819378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 85919378907SAtari911 if ($namespace) { 86019378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 86119378907SAtari911 } 86219378907SAtari911 $dataDir .= 'calendar/'; 86319378907SAtari911 864e3a9f44cSAtari911 // Load ONLY current month 86587ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 86619378907SAtari911 $events = []; 86719378907SAtari911 if (file_exists($eventFile)) { 86887ac9bf3SAtari911 $contents = file_get_contents($eventFile); 86987ac9bf3SAtari911 $decoded = json_decode($contents, true); 87087ac9bf3SAtari911 if (json_last_error() === JSON_ERROR_NONE) { 87187ac9bf3SAtari911 $events = $decoded; 87287ac9bf3SAtari911 } 87387ac9bf3SAtari911 } 87487ac9bf3SAtari911 875e3a9f44cSAtari911 return $events; 87687ac9bf3SAtari911 } 877e3a9f44cSAtari911 878e3a9f44cSAtari911 private function loadEventsMultiNamespace($namespaces, $year, $month) { 879e3a9f44cSAtari911 // Check for wildcard pattern 880e3a9f44cSAtari911 if (preg_match('/^(.+):\*$/', $namespaces, $matches)) { 881e3a9f44cSAtari911 $baseNamespace = $matches[1]; 882e3a9f44cSAtari911 return $this->loadEventsWildcard($baseNamespace, $year, $month); 883e3a9f44cSAtari911 } 884e3a9f44cSAtari911 885e3a9f44cSAtari911 // Check for root wildcard 886e3a9f44cSAtari911 if ($namespaces === '*') { 887e3a9f44cSAtari911 return $this->loadEventsWildcard('', $year, $month); 888e3a9f44cSAtari911 } 889e3a9f44cSAtari911 890e3a9f44cSAtari911 // Parse namespace list (semicolon separated) 891e3a9f44cSAtari911 $namespaceList = array_map('trim', explode(';', $namespaces)); 892e3a9f44cSAtari911 893e3a9f44cSAtari911 // Load events from all namespaces 894e3a9f44cSAtari911 $allEvents = []; 895e3a9f44cSAtari911 foreach ($namespaceList as $ns) { 896e3a9f44cSAtari911 $ns = trim($ns); 897e3a9f44cSAtari911 if (empty($ns)) continue; 898e3a9f44cSAtari911 899e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($ns, $year, $month); 900e3a9f44cSAtari911 901e3a9f44cSAtari911 // Add namespace tag to each event 902e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 903e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 904e3a9f44cSAtari911 $allEvents[$dateKey] = []; 905e3a9f44cSAtari911 } 906e3a9f44cSAtari911 foreach ($dayEvents as $event) { 907e3a9f44cSAtari911 $event['_namespace'] = $ns; 908e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 909e3a9f44cSAtari911 } 91087ac9bf3SAtari911 } 91187ac9bf3SAtari911 } 91287ac9bf3SAtari911 913e3a9f44cSAtari911 return $allEvents; 914e3a9f44cSAtari911 } 91519378907SAtari911 916e3a9f44cSAtari911 private function loadEventsWildcard($baseNamespace, $year, $month) { 917e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 918e3a9f44cSAtari911 if ($baseNamespace) { 919e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 920e3a9f44cSAtari911 } 921e3a9f44cSAtari911 922e3a9f44cSAtari911 $allEvents = []; 923e3a9f44cSAtari911 924e3a9f44cSAtari911 // First, load events from the base namespace itself 925e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($baseNamespace, $year, $month); 926e3a9f44cSAtari911 927e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 928e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 929e3a9f44cSAtari911 $allEvents[$dateKey] = []; 930e3a9f44cSAtari911 } 931e3a9f44cSAtari911 foreach ($dayEvents as $event) { 932e3a9f44cSAtari911 $event['_namespace'] = $baseNamespace; 933e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 934e3a9f44cSAtari911 } 935e3a9f44cSAtari911 } 936e3a9f44cSAtari911 937e3a9f44cSAtari911 // Recursively find all subdirectories 938e3a9f44cSAtari911 $this->findSubNamespaces($dataDir, $baseNamespace, $year, $month, $allEvents); 939e3a9f44cSAtari911 940e3a9f44cSAtari911 return $allEvents; 941e3a9f44cSAtari911 } 942e3a9f44cSAtari911 943e3a9f44cSAtari911 private function findSubNamespaces($dir, $baseNamespace, $year, $month, &$allEvents) { 944e3a9f44cSAtari911 if (!is_dir($dir)) return; 945e3a9f44cSAtari911 946e3a9f44cSAtari911 $items = scandir($dir); 947e3a9f44cSAtari911 foreach ($items as $item) { 948e3a9f44cSAtari911 if ($item === '.' || $item === '..') continue; 949e3a9f44cSAtari911 950e3a9f44cSAtari911 $path = $dir . $item; 951e3a9f44cSAtari911 if (is_dir($path) && $item !== 'calendar') { 952e3a9f44cSAtari911 // This is a namespace directory 953e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 954e3a9f44cSAtari911 955e3a9f44cSAtari911 // Load events from this namespace 956e3a9f44cSAtari911 $events = $this->loadEventsSingleNamespace($namespace, $year, $month); 957e3a9f44cSAtari911 foreach ($events as $dateKey => $dayEvents) { 958e3a9f44cSAtari911 if (!isset($allEvents[$dateKey])) { 959e3a9f44cSAtari911 $allEvents[$dateKey] = []; 960e3a9f44cSAtari911 } 961e3a9f44cSAtari911 foreach ($dayEvents as $event) { 962e3a9f44cSAtari911 $event['_namespace'] = $namespace; 963e3a9f44cSAtari911 $allEvents[$dateKey][] = $event; 964e3a9f44cSAtari911 } 965e3a9f44cSAtari911 } 966e3a9f44cSAtari911 967e3a9f44cSAtari911 // Recurse into subdirectories 968e3a9f44cSAtari911 $this->findSubNamespaces($path . '/', $namespace, $year, $month, $allEvents); 969e3a9f44cSAtari911 } 970e3a9f44cSAtari911 } 97119378907SAtari911 } 97219378907SAtari911 97396df7d3eSAtari911 /** 97496df7d3eSAtari911 * Search all dates for events matching the search term 97596df7d3eSAtari911 */ 97696df7d3eSAtari911 private function searchAllDates() { 97796df7d3eSAtari911 global $INPUT; 97896df7d3eSAtari911 97996df7d3eSAtari911 $searchTerm = strtolower(trim($INPUT->str('search', ''))); 98096df7d3eSAtari911 $namespace = $INPUT->str('namespace', ''); 98196df7d3eSAtari911 98296df7d3eSAtari911 if (strlen($searchTerm) < 2) { 98396df7d3eSAtari911 echo json_encode(['success' => false, 'error' => 'Search term too short']); 98496df7d3eSAtari911 return; 98596df7d3eSAtari911 } 98696df7d3eSAtari911 98796df7d3eSAtari911 // Normalize search term for fuzzy matching 98896df7d3eSAtari911 $normalizedSearch = $this->normalizeForSearch($searchTerm); 98996df7d3eSAtari911 99096df7d3eSAtari911 $results = []; 99196df7d3eSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 99296df7d3eSAtari911 99396df7d3eSAtari911 // Helper to search calendar directory 99496df7d3eSAtari911 $searchCalendarDir = function($calDir, $eventNamespace) use ($normalizedSearch, &$results) { 99596df7d3eSAtari911 if (!is_dir($calDir)) return; 99696df7d3eSAtari911 99796df7d3eSAtari911 foreach (glob($calDir . '/*.json') as $file) { 99896df7d3eSAtari911 $data = @json_decode(file_get_contents($file), true); 99996df7d3eSAtari911 if (!$data || !is_array($data)) continue; 100096df7d3eSAtari911 100196df7d3eSAtari911 foreach ($data as $dateKey => $dayEvents) { 100296df7d3eSAtari911 // Skip non-date keys 100396df7d3eSAtari911 if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateKey)) continue; 100496df7d3eSAtari911 if (!is_array($dayEvents)) continue; 100596df7d3eSAtari911 100696df7d3eSAtari911 foreach ($dayEvents as $event) { 100796df7d3eSAtari911 if (!isset($event['title'])) continue; 100896df7d3eSAtari911 100996df7d3eSAtari911 // Build searchable text 101096df7d3eSAtari911 $searchableText = strtolower($event['title']); 101196df7d3eSAtari911 if (isset($event['description'])) { 101296df7d3eSAtari911 $searchableText .= ' ' . strtolower($event['description']); 101396df7d3eSAtari911 } 101496df7d3eSAtari911 101596df7d3eSAtari911 // Normalize for fuzzy matching 101696df7d3eSAtari911 $normalizedText = $this->normalizeForSearch($searchableText); 101796df7d3eSAtari911 101896df7d3eSAtari911 // Check if matches using fuzzy match 101996df7d3eSAtari911 if ($this->fuzzyMatchText($normalizedText, $normalizedSearch)) { 102096df7d3eSAtari911 $results[] = [ 102196df7d3eSAtari911 'date' => $dateKey, 102296df7d3eSAtari911 'title' => $event['title'], 102396df7d3eSAtari911 'time' => isset($event['time']) ? $event['time'] : '', 102496df7d3eSAtari911 'endTime' => isset($event['endTime']) ? $event['endTime'] : '', 102596df7d3eSAtari911 'color' => isset($event['color']) ? $event['color'] : '', 102696df7d3eSAtari911 'namespace' => isset($event['namespace']) ? $event['namespace'] : $eventNamespace, 102796df7d3eSAtari911 'id' => isset($event['id']) ? $event['id'] : '' 102896df7d3eSAtari911 ]; 102996df7d3eSAtari911 } 103096df7d3eSAtari911 } 103196df7d3eSAtari911 } 103296df7d3eSAtari911 } 103396df7d3eSAtari911 }; 103496df7d3eSAtari911 103596df7d3eSAtari911 // Search root calendar directory 103696df7d3eSAtari911 $searchCalendarDir($dataDir . 'calendar', ''); 103796df7d3eSAtari911 103896df7d3eSAtari911 // Search namespace directories 103996df7d3eSAtari911 $this->searchNamespaceDirs($dataDir, $searchCalendarDir); 104096df7d3eSAtari911 104196df7d3eSAtari911 // Sort results by date (newest first for past, oldest first for future) 104296df7d3eSAtari911 usort($results, function($a, $b) { 104396df7d3eSAtari911 return strcmp($a['date'], $b['date']); 104496df7d3eSAtari911 }); 104596df7d3eSAtari911 104696df7d3eSAtari911 // Limit results 104796df7d3eSAtari911 $results = array_slice($results, 0, 50); 104896df7d3eSAtari911 104996df7d3eSAtari911 echo json_encode([ 105096df7d3eSAtari911 'success' => true, 105196df7d3eSAtari911 'results' => $results, 105296df7d3eSAtari911 'total' => count($results) 105396df7d3eSAtari911 ]); 105496df7d3eSAtari911 } 105596df7d3eSAtari911 105696df7d3eSAtari911 /** 105796df7d3eSAtari911 * Check if normalized text matches normalized search term 105896df7d3eSAtari911 * Supports multi-word search where all words must be present 105996df7d3eSAtari911 */ 106096df7d3eSAtari911 private function fuzzyMatchText($normalizedText, $normalizedSearch) { 106196df7d3eSAtari911 // Direct substring match 106296df7d3eSAtari911 if (strpos($normalizedText, $normalizedSearch) !== false) { 106396df7d3eSAtari911 return true; 106496df7d3eSAtari911 } 106596df7d3eSAtari911 106696df7d3eSAtari911 // Multi-word search: all words must be present 106796df7d3eSAtari911 $searchWords = array_filter(explode(' ', $normalizedSearch)); 106896df7d3eSAtari911 if (count($searchWords) > 1) { 106996df7d3eSAtari911 foreach ($searchWords as $word) { 107096df7d3eSAtari911 if (strlen($word) > 0 && strpos($normalizedText, $word) === false) { 107196df7d3eSAtari911 return false; 107296df7d3eSAtari911 } 107396df7d3eSAtari911 } 107496df7d3eSAtari911 return true; 107596df7d3eSAtari911 } 107696df7d3eSAtari911 107796df7d3eSAtari911 return false; 107896df7d3eSAtari911 } 107996df7d3eSAtari911 108096df7d3eSAtari911 /** 108196df7d3eSAtari911 * Normalize text for fuzzy search matching 108296df7d3eSAtari911 * Removes apostrophes, extra spaces, and common variations 108396df7d3eSAtari911 */ 108496df7d3eSAtari911 private function normalizeForSearch($text) { 108596df7d3eSAtari911 // Convert to lowercase 108696df7d3eSAtari911 $text = strtolower($text); 108796df7d3eSAtari911 108896df7d3eSAtari911 // Remove apostrophes and quotes (father's -> fathers) 108996df7d3eSAtari911 $text = preg_replace('/[\x27\x60\x22\xE2\x80\x98\xE2\x80\x99\xE2\x80\x9C\xE2\x80\x9D]/u', '', $text); 109096df7d3eSAtari911 109196df7d3eSAtari911 // Normalize dashes and underscores to spaces 109296df7d3eSAtari911 $text = preg_replace('/[-_\x{2013}\x{2014}]/u', ' ', $text); 109396df7d3eSAtari911 109496df7d3eSAtari911 // Remove other punctuation but keep letters, numbers, spaces 109596df7d3eSAtari911 $text = preg_replace('/[^\p{L}\p{N}\s]/u', '', $text); 109696df7d3eSAtari911 109796df7d3eSAtari911 // Normalize multiple spaces to single space 109896df7d3eSAtari911 $text = preg_replace('/\s+/', ' ', $text); 109996df7d3eSAtari911 110096df7d3eSAtari911 // Trim 110196df7d3eSAtari911 $text = trim($text); 110296df7d3eSAtari911 110396df7d3eSAtari911 return $text; 110496df7d3eSAtari911 } 110596df7d3eSAtari911 110696df7d3eSAtari911 /** 110796df7d3eSAtari911 * Recursively search namespace directories for calendar data 110896df7d3eSAtari911 */ 110996df7d3eSAtari911 private function searchNamespaceDirs($baseDir, $callback) { 111096df7d3eSAtari911 foreach (glob($baseDir . '*', GLOB_ONLYDIR) as $nsDir) { 111196df7d3eSAtari911 $name = basename($nsDir); 111296df7d3eSAtari911 if ($name === 'calendar') continue; 111396df7d3eSAtari911 111496df7d3eSAtari911 $calDir = $nsDir . '/calendar'; 111596df7d3eSAtari911 if (is_dir($calDir)) { 111696df7d3eSAtari911 $relPath = str_replace(DOKU_INC . 'data/meta/', '', $nsDir); 111796df7d3eSAtari911 $namespace = str_replace('/', ':', $relPath); 111896df7d3eSAtari911 $callback($calDir, $namespace); 111996df7d3eSAtari911 } 112096df7d3eSAtari911 112196df7d3eSAtari911 // Recurse 112296df7d3eSAtari911 $this->searchNamespaceDirs($nsDir . '/', $callback); 112396df7d3eSAtari911 } 112496df7d3eSAtari911 } 112596df7d3eSAtari911 112619378907SAtari911 private function toggleTaskComplete() { 112719378907SAtari911 global $INPUT; 112819378907SAtari911 112919378907SAtari911 $namespace = $INPUT->str('namespace', ''); 113019378907SAtari911 $date = $INPUT->str('date'); 113119378907SAtari911 $eventId = $INPUT->str('eventId'); 113219378907SAtari911 $completed = $INPUT->bool('completed', false); 113319378907SAtari911 1134e3a9f44cSAtari911 // Find where the event actually lives 1135e3a9f44cSAtari911 $storedNamespace = $this->findEventNamespace($eventId, $date, $namespace); 1136e3a9f44cSAtari911 1137e3a9f44cSAtari911 if ($storedNamespace === null) { 1138e3a9f44cSAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 1139e3a9f44cSAtari911 return; 1140e3a9f44cSAtari911 } 1141e3a9f44cSAtari911 1142e3a9f44cSAtari911 // Use the found namespace 1143e3a9f44cSAtari911 $namespace = $storedNamespace; 1144e3a9f44cSAtari911 114519378907SAtari911 list($year, $month, $day) = explode('-', $date); 114619378907SAtari911 114719378907SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 114819378907SAtari911 if ($namespace) { 114919378907SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 115019378907SAtari911 } 115119378907SAtari911 $dataDir .= 'calendar/'; 115219378907SAtari911 115319378907SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 115419378907SAtari911 115519378907SAtari911 if (file_exists($eventFile)) { 115619378907SAtari911 $events = json_decode(file_get_contents($eventFile), true); 115719378907SAtari911 115819378907SAtari911 if (isset($events[$date])) { 1159*815440faSAtari911 $eventTitle = ''; 116019378907SAtari911 foreach ($events[$date] as $key => $event) { 116119378907SAtari911 if ($event['id'] === $eventId) { 116219378907SAtari911 $events[$date][$key]['completed'] = $completed; 1163*815440faSAtari911 $eventTitle = $event['title'] ?? ''; 116419378907SAtari911 break; 116519378907SAtari911 } 116619378907SAtari911 } 116719378907SAtari911 1168*815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 1169*815440faSAtari911 1170*815440faSAtari911 // Audit logging 1171*815440faSAtari911 $audit = $this->getAuditLogger(); 1172*815440faSAtari911 $audit->logTaskToggle($namespace, $date, $eventId, $eventTitle, $completed); 1173*815440faSAtari911 117419378907SAtari911 echo json_encode(['success' => true, 'events' => $events]); 117519378907SAtari911 return; 117619378907SAtari911 } 117719378907SAtari911 } 117819378907SAtari911 117919378907SAtari911 echo json_encode(['success' => false, 'error' => 'Event not found']); 118019378907SAtari911 } 118119378907SAtari911 1182*815440faSAtari911 // ======================================================================== 1183*815440faSAtari911 // GOOGLE CALENDAR SYNC HANDLERS 1184*815440faSAtari911 // ======================================================================== 1185*815440faSAtari911 1186*815440faSAtari911 /** 1187*815440faSAtari911 * Get Google OAuth authorization URL 1188*815440faSAtari911 */ 1189*815440faSAtari911 private function getGoogleAuthUrl() { 1190*815440faSAtari911 if (!auth_isadmin()) { 1191*815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1192*815440faSAtari911 return; 1193*815440faSAtari911 } 1194*815440faSAtari911 1195*815440faSAtari911 $sync = $this->getGoogleSync(); 1196*815440faSAtari911 1197*815440faSAtari911 if (!$sync->isConfigured()) { 1198*815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Google sync not configured. Please enter Client ID and Secret first.']); 1199*815440faSAtari911 return; 1200*815440faSAtari911 } 1201*815440faSAtari911 1202*815440faSAtari911 // Build redirect URI 1203*815440faSAtari911 $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback'; 1204*815440faSAtari911 1205*815440faSAtari911 $authUrl = $sync->getAuthUrl($redirectUri); 1206*815440faSAtari911 1207*815440faSAtari911 echo json_encode(['success' => true, 'url' => $authUrl]); 1208*815440faSAtari911 } 1209*815440faSAtari911 1210*815440faSAtari911 /** 1211*815440faSAtari911 * Handle Google OAuth callback 1212*815440faSAtari911 */ 1213*815440faSAtari911 private function handleGoogleCallback() { 1214*815440faSAtari911 global $INPUT; 1215*815440faSAtari911 1216*815440faSAtari911 $code = $INPUT->str('code'); 1217*815440faSAtari911 $state = $INPUT->str('state'); 1218*815440faSAtari911 $error = $INPUT->str('error'); 1219*815440faSAtari911 1220*815440faSAtari911 // Check for OAuth error 1221*815440faSAtari911 if ($error) { 1222*815440faSAtari911 $this->showGoogleCallbackResult(false, 'Authorization denied: ' . $error); 1223*815440faSAtari911 return; 1224*815440faSAtari911 } 1225*815440faSAtari911 1226*815440faSAtari911 if (!$code) { 1227*815440faSAtari911 $this->showGoogleCallbackResult(false, 'No authorization code received'); 1228*815440faSAtari911 return; 1229*815440faSAtari911 } 1230*815440faSAtari911 1231*815440faSAtari911 $sync = $this->getGoogleSync(); 1232*815440faSAtari911 1233*815440faSAtari911 // Verify state for CSRF protection 1234*815440faSAtari911 if (!$sync->verifyState($state)) { 1235*815440faSAtari911 $this->showGoogleCallbackResult(false, 'Invalid state parameter'); 1236*815440faSAtari911 return; 1237*815440faSAtari911 } 1238*815440faSAtari911 1239*815440faSAtari911 // Exchange code for tokens 1240*815440faSAtari911 $redirectUri = DOKU_URL . 'lib/exe/ajax.php?call=plugin_calendar&action=google_callback'; 1241*815440faSAtari911 $result = $sync->handleCallback($code, $redirectUri); 1242*815440faSAtari911 1243*815440faSAtari911 if ($result['success']) { 1244*815440faSAtari911 $this->showGoogleCallbackResult(true, 'Successfully connected to Google Calendar!'); 1245*815440faSAtari911 } else { 1246*815440faSAtari911 $this->showGoogleCallbackResult(false, $result['error']); 1247*815440faSAtari911 } 1248*815440faSAtari911 } 1249*815440faSAtari911 1250*815440faSAtari911 /** 1251*815440faSAtari911 * Show OAuth callback result page 1252*815440faSAtari911 */ 1253*815440faSAtari911 private function showGoogleCallbackResult($success, $message) { 1254*815440faSAtari911 $status = $success ? 'Success!' : 'Error'; 1255*815440faSAtari911 $color = $success ? '#2ecc71' : '#e74c3c'; 1256*815440faSAtari911 1257*815440faSAtari911 echo '<!DOCTYPE html> 1258*815440faSAtari911<html> 1259*815440faSAtari911<head> 1260*815440faSAtari911 <title>Google Calendar - ' . $status . '</title> 1261*815440faSAtari911 <style> 1262*815440faSAtari911 body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 1263*815440faSAtari911 display: flex; align-items: center; justify-content: center; 1264*815440faSAtari911 min-height: 100vh; margin: 0; background: #f5f5f5; } 1265*815440faSAtari911 .card { background: white; padding: 40px; border-radius: 12px; 1266*815440faSAtari911 box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; max-width: 400px; } 1267*815440faSAtari911 h1 { color: ' . $color . '; margin: 0 0 16px 0; } 1268*815440faSAtari911 p { color: #666; margin: 0 0 24px 0; } 1269*815440faSAtari911 button { background: #3498db; color: white; border: none; padding: 12px 24px; 1270*815440faSAtari911 border-radius: 6px; cursor: pointer; font-size: 14px; } 1271*815440faSAtari911 button:hover { background: #2980b9; } 1272*815440faSAtari911 </style> 1273*815440faSAtari911</head> 1274*815440faSAtari911<body> 1275*815440faSAtari911 <div class="card"> 1276*815440faSAtari911 <h1>' . ($success ? '✓' : '✕') . ' ' . $status . '</h1> 1277*815440faSAtari911 <p>' . htmlspecialchars($message) . '</p> 1278*815440faSAtari911 <button onclick="window.close()">Close Window</button> 1279*815440faSAtari911 </div> 1280*815440faSAtari911 <script> 1281*815440faSAtari911 // Notify parent window 1282*815440faSAtari911 if (window.opener) { 1283*815440faSAtari911 window.opener.postMessage({ type: "google_auth_complete", success: ' . ($success ? 'true' : 'false') . ' }, "*"); 1284*815440faSAtari911 } 1285*815440faSAtari911 </script> 1286*815440faSAtari911</body> 1287*815440faSAtari911</html>'; 1288*815440faSAtari911 } 1289*815440faSAtari911 1290*815440faSAtari911 /** 1291*815440faSAtari911 * Get Google sync status 1292*815440faSAtari911 */ 1293*815440faSAtari911 private function getGoogleStatus() { 1294*815440faSAtari911 $sync = $this->getGoogleSync(); 1295*815440faSAtari911 echo json_encode(['success' => true, 'status' => $sync->getStatus()]); 1296*815440faSAtari911 } 1297*815440faSAtari911 1298*815440faSAtari911 /** 1299*815440faSAtari911 * Get list of Google calendars 1300*815440faSAtari911 */ 1301*815440faSAtari911 private function getGoogleCalendars() { 1302*815440faSAtari911 if (!auth_isadmin()) { 1303*815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1304*815440faSAtari911 return; 1305*815440faSAtari911 } 1306*815440faSAtari911 1307*815440faSAtari911 $sync = $this->getGoogleSync(); 1308*815440faSAtari911 $result = $sync->getCalendars(); 1309*815440faSAtari911 echo json_encode($result); 1310*815440faSAtari911 } 1311*815440faSAtari911 1312*815440faSAtari911 /** 1313*815440faSAtari911 * Import events from Google Calendar 1314*815440faSAtari911 */ 1315*815440faSAtari911 private function googleImport() { 1316*815440faSAtari911 global $INPUT; 1317*815440faSAtari911 1318*815440faSAtari911 if (!auth_isadmin()) { 1319*815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1320*815440faSAtari911 return; 1321*815440faSAtari911 } 1322*815440faSAtari911 1323*815440faSAtari911 $namespace = $INPUT->str('namespace', ''); 1324*815440faSAtari911 $startDate = $INPUT->str('startDate', ''); 1325*815440faSAtari911 $endDate = $INPUT->str('endDate', ''); 1326*815440faSAtari911 1327*815440faSAtari911 $sync = $this->getGoogleSync(); 1328*815440faSAtari911 $result = $sync->importEvents($namespace, $startDate ?: null, $endDate ?: null); 1329*815440faSAtari911 1330*815440faSAtari911 echo json_encode($result); 1331*815440faSAtari911 } 1332*815440faSAtari911 1333*815440faSAtari911 /** 1334*815440faSAtari911 * Export events to Google Calendar 1335*815440faSAtari911 */ 1336*815440faSAtari911 private function googleExport() { 1337*815440faSAtari911 global $INPUT; 1338*815440faSAtari911 1339*815440faSAtari911 if (!auth_isadmin()) { 1340*815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1341*815440faSAtari911 return; 1342*815440faSAtari911 } 1343*815440faSAtari911 1344*815440faSAtari911 $namespace = $INPUT->str('namespace', ''); 1345*815440faSAtari911 $startDate = $INPUT->str('startDate', ''); 1346*815440faSAtari911 $endDate = $INPUT->str('endDate', ''); 1347*815440faSAtari911 1348*815440faSAtari911 $sync = $this->getGoogleSync(); 1349*815440faSAtari911 $result = $sync->exportEvents($namespace, $startDate ?: null, $endDate ?: null); 1350*815440faSAtari911 1351*815440faSAtari911 echo json_encode($result); 1352*815440faSAtari911 } 1353*815440faSAtari911 1354*815440faSAtari911 /** 1355*815440faSAtari911 * Disconnect from Google Calendar 1356*815440faSAtari911 */ 1357*815440faSAtari911 private function googleDisconnect() { 1358*815440faSAtari911 if (!auth_isadmin()) { 1359*815440faSAtari911 echo json_encode(['success' => false, 'error' => 'Admin access required']); 1360*815440faSAtari911 return; 1361*815440faSAtari911 } 1362*815440faSAtari911 1363*815440faSAtari911 $sync = $this->getGoogleSync(); 1364*815440faSAtari911 $sync->disconnect(); 1365*815440faSAtari911 1366*815440faSAtari911 echo json_encode(['success' => true]); 1367*815440faSAtari911 } 1368*815440faSAtari911 136996df7d3eSAtari911 private function createRecurringEvents($namespace, $startDate, $endDate, $title, $time, $endTime, 137096df7d3eSAtari911 $description, $color, $isTask, $recurrenceType, $recurrenceInterval, 137196df7d3eSAtari911 $recurrenceEnd, $weekDays, $monthlyType, $monthDay, 137296df7d3eSAtari911 $ordinalWeek, $ordinalDay, $baseId) { 137387ac9bf3SAtari911 $dataDir = DOKU_INC . 'data/meta/'; 137487ac9bf3SAtari911 if ($namespace) { 137587ac9bf3SAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 137687ac9bf3SAtari911 } 137787ac9bf3SAtari911 $dataDir .= 'calendar/'; 137887ac9bf3SAtari911 137987ac9bf3SAtari911 if (!is_dir($dataDir)) { 138087ac9bf3SAtari911 mkdir($dataDir, 0755, true); 138187ac9bf3SAtari911 } 138287ac9bf3SAtari911 138396df7d3eSAtari911 // Ensure interval is at least 1 138496df7d3eSAtari911 if ($recurrenceInterval < 1) $recurrenceInterval = 1; 138587ac9bf3SAtari911 138687ac9bf3SAtari911 // Set maximum end date if not specified (1 year from start) 138787ac9bf3SAtari911 $maxEnd = $recurrenceEnd ?: date('Y-m-d', strtotime($startDate . ' +1 year')); 138887ac9bf3SAtari911 138987ac9bf3SAtari911 // Calculate event duration for multi-day events 139087ac9bf3SAtari911 $eventDuration = 0; 139187ac9bf3SAtari911 if ($endDate && $endDate !== $startDate) { 139287ac9bf3SAtari911 $start = new DateTime($startDate); 139387ac9bf3SAtari911 $end = new DateTime($endDate); 139487ac9bf3SAtari911 $eventDuration = $start->diff($end)->days; 139587ac9bf3SAtari911 } 139687ac9bf3SAtari911 139787ac9bf3SAtari911 // Generate recurring events 139887ac9bf3SAtari911 $currentDate = new DateTime($startDate); 139987ac9bf3SAtari911 $endLimit = new DateTime($maxEnd); 140087ac9bf3SAtari911 $counter = 0; 140196df7d3eSAtari911 $maxOccurrences = 365; // Allow up to 365 occurrences (e.g., daily for 1 year) 140296df7d3eSAtari911 140396df7d3eSAtari911 // For weekly with specific days, we need to track the interval counter differently 140496df7d3eSAtari911 $weekCounter = 0; 140596df7d3eSAtari911 $startWeekNumber = (int)$currentDate->format('W'); 140696df7d3eSAtari911 $startYear = (int)$currentDate->format('Y'); 140787ac9bf3SAtari911 140887ac9bf3SAtari911 while ($currentDate <= $endLimit && $counter < $maxOccurrences) { 140996df7d3eSAtari911 $shouldCreateEvent = false; 141096df7d3eSAtari911 141196df7d3eSAtari911 switch ($recurrenceType) { 141296df7d3eSAtari911 case 'daily': 141396df7d3eSAtari911 // Every N days from start 141496df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 141596df7d3eSAtari911 $shouldCreateEvent = ($daysSinceStart % $recurrenceInterval === 0); 141696df7d3eSAtari911 break; 141796df7d3eSAtari911 141896df7d3eSAtari911 case 'weekly': 141996df7d3eSAtari911 // Every N weeks, on specified days 142096df7d3eSAtari911 $currentDayOfWeek = (int)$currentDate->format('w'); // 0=Sun, 6=Sat 142196df7d3eSAtari911 142296df7d3eSAtari911 // Calculate weeks since start 142396df7d3eSAtari911 $daysSinceStart = $currentDate->diff(new DateTime($startDate))->days; 142496df7d3eSAtari911 $weeksSinceStart = floor($daysSinceStart / 7); 142596df7d3eSAtari911 142696df7d3eSAtari911 // Check if we're in the right week (every N weeks) 142796df7d3eSAtari911 $isCorrectWeek = ($weeksSinceStart % $recurrenceInterval === 0); 142896df7d3eSAtari911 142996df7d3eSAtari911 // Check if this day is selected 143096df7d3eSAtari911 $isDaySelected = empty($weekDays) || in_array($currentDayOfWeek, $weekDays); 143196df7d3eSAtari911 143296df7d3eSAtari911 // For the first week, only include days on or after the start date 143396df7d3eSAtari911 $isOnOrAfterStart = ($currentDate >= new DateTime($startDate)); 143496df7d3eSAtari911 143596df7d3eSAtari911 $shouldCreateEvent = $isCorrectWeek && $isDaySelected && $isOnOrAfterStart; 143696df7d3eSAtari911 break; 143796df7d3eSAtari911 143896df7d3eSAtari911 case 'monthly': 143996df7d3eSAtari911 // Calculate months since start 144096df7d3eSAtari911 $startDT = new DateTime($startDate); 144196df7d3eSAtari911 $monthsSinceStart = (($currentDate->format('Y') - $startDT->format('Y')) * 12) + 144296df7d3eSAtari911 ($currentDate->format('n') - $startDT->format('n')); 144396df7d3eSAtari911 144496df7d3eSAtari911 // Check if we're in the right month (every N months) 144596df7d3eSAtari911 $isCorrectMonth = ($monthsSinceStart >= 0 && $monthsSinceStart % $recurrenceInterval === 0); 144696df7d3eSAtari911 144796df7d3eSAtari911 if (!$isCorrectMonth) { 144896df7d3eSAtari911 // Skip to first day of next potential month 144996df7d3eSAtari911 $currentDate->modify('first day of next month'); 145096df7d3eSAtari911 continue 2; 145196df7d3eSAtari911 } 145296df7d3eSAtari911 145396df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 145496df7d3eSAtari911 // Specific day of month (e.g., 15th) 145596df7d3eSAtari911 $targetDay = $monthDay ?: (int)(new DateTime($startDate))->format('j'); 145696df7d3eSAtari911 $currentDay = (int)$currentDate->format('j'); 145796df7d3eSAtari911 $daysInMonth = (int)$currentDate->format('t'); 145896df7d3eSAtari911 145996df7d3eSAtari911 // If target day exceeds days in month, use last day 146096df7d3eSAtari911 $effectiveTargetDay = min($targetDay, $daysInMonth); 146196df7d3eSAtari911 $shouldCreateEvent = ($currentDay === $effectiveTargetDay); 146296df7d3eSAtari911 } else { 146396df7d3eSAtari911 // Ordinal weekday (e.g., 2nd Wednesday, last Friday) 146496df7d3eSAtari911 $shouldCreateEvent = $this->isOrdinalWeekday($currentDate, $ordinalWeek, $ordinalDay); 146596df7d3eSAtari911 } 146696df7d3eSAtari911 break; 146796df7d3eSAtari911 146896df7d3eSAtari911 case 'yearly': 146996df7d3eSAtari911 // Every N years on same month/day 147096df7d3eSAtari911 $startDT = new DateTime($startDate); 147196df7d3eSAtari911 $yearsSinceStart = (int)$currentDate->format('Y') - (int)$startDT->format('Y'); 147296df7d3eSAtari911 147396df7d3eSAtari911 // Check if we're in the right year 147496df7d3eSAtari911 $isCorrectYear = ($yearsSinceStart >= 0 && $yearsSinceStart % $recurrenceInterval === 0); 147596df7d3eSAtari911 147696df7d3eSAtari911 // Check if it's the same month and day 147796df7d3eSAtari911 $sameMonthDay = ($currentDate->format('m-d') === $startDT->format('m-d')); 147896df7d3eSAtari911 147996df7d3eSAtari911 $shouldCreateEvent = $isCorrectYear && $sameMonthDay; 148096df7d3eSAtari911 break; 148196df7d3eSAtari911 148296df7d3eSAtari911 default: 148396df7d3eSAtari911 $shouldCreateEvent = false; 148496df7d3eSAtari911 } 148596df7d3eSAtari911 148696df7d3eSAtari911 if ($shouldCreateEvent) { 148787ac9bf3SAtari911 $dateKey = $currentDate->format('Y-m-d'); 148887ac9bf3SAtari911 list($year, $month, $day) = explode('-', $dateKey); 148987ac9bf3SAtari911 149087ac9bf3SAtari911 // Calculate end date for this occurrence if multi-day 149187ac9bf3SAtari911 $occurrenceEndDate = ''; 149287ac9bf3SAtari911 if ($eventDuration > 0) { 149387ac9bf3SAtari911 $occurrenceEnd = clone $currentDate; 149487ac9bf3SAtari911 $occurrenceEnd->modify('+' . $eventDuration . ' days'); 149587ac9bf3SAtari911 $occurrenceEndDate = $occurrenceEnd->format('Y-m-d'); 149687ac9bf3SAtari911 } 149787ac9bf3SAtari911 149887ac9bf3SAtari911 // Load month file 149987ac9bf3SAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 150087ac9bf3SAtari911 $events = []; 150187ac9bf3SAtari911 if (file_exists($eventFile)) { 150287ac9bf3SAtari911 $events = json_decode(file_get_contents($eventFile), true); 150396df7d3eSAtari911 if (!is_array($events)) $events = []; 150487ac9bf3SAtari911 } 150587ac9bf3SAtari911 150687ac9bf3SAtari911 if (!isset($events[$dateKey])) { 150787ac9bf3SAtari911 $events[$dateKey] = []; 150887ac9bf3SAtari911 } 150987ac9bf3SAtari911 151087ac9bf3SAtari911 // Create event for this occurrence 151187ac9bf3SAtari911 $eventData = [ 151287ac9bf3SAtari911 'id' => $baseId . '-' . $counter, 151387ac9bf3SAtari911 'title' => $title, 151487ac9bf3SAtari911 'time' => $time, 15151d05cddcSAtari911 'endTime' => $endTime, 151687ac9bf3SAtari911 'description' => $description, 151787ac9bf3SAtari911 'color' => $color, 151887ac9bf3SAtari911 'isTask' => $isTask, 151987ac9bf3SAtari911 'completed' => false, 152087ac9bf3SAtari911 'endDate' => $occurrenceEndDate, 152187ac9bf3SAtari911 'recurring' => true, 152287ac9bf3SAtari911 'recurringId' => $baseId, 152396df7d3eSAtari911 'recurrenceType' => $recurrenceType, 152496df7d3eSAtari911 'recurrenceInterval' => $recurrenceInterval, 152596df7d3eSAtari911 'namespace' => $namespace, 152687ac9bf3SAtari911 'created' => date('Y-m-d H:i:s') 152787ac9bf3SAtari911 ]; 152887ac9bf3SAtari911 152996df7d3eSAtari911 // Store additional recurrence info for reference 153096df7d3eSAtari911 if ($recurrenceType === 'weekly' && !empty($weekDays)) { 153196df7d3eSAtari911 $eventData['weekDays'] = $weekDays; 153296df7d3eSAtari911 } 153396df7d3eSAtari911 if ($recurrenceType === 'monthly') { 153496df7d3eSAtari911 $eventData['monthlyType'] = $monthlyType; 153596df7d3eSAtari911 if ($monthlyType === 'dayOfMonth') { 153696df7d3eSAtari911 $eventData['monthDay'] = $monthDay; 153796df7d3eSAtari911 } else { 153896df7d3eSAtari911 $eventData['ordinalWeek'] = $ordinalWeek; 153996df7d3eSAtari911 $eventData['ordinalDay'] = $ordinalDay; 154096df7d3eSAtari911 } 154196df7d3eSAtari911 } 154296df7d3eSAtari911 154387ac9bf3SAtari911 $events[$dateKey][] = $eventData; 1544*815440faSAtari911 CalendarFileHandler::writeJson($eventFile, $events); 154587ac9bf3SAtari911 154687ac9bf3SAtari911 $counter++; 154787ac9bf3SAtari911 } 154896df7d3eSAtari911 154996df7d3eSAtari911 // Move to next day (we check each day individually for complex patterns) 155096df7d3eSAtari911 $currentDate->modify('+1 day'); 155196df7d3eSAtari911 } 155296df7d3eSAtari911 } 155396df7d3eSAtari911 155496df7d3eSAtari911 /** 155596df7d3eSAtari911 * Check if a date is the Nth occurrence of a weekday in its month 155696df7d3eSAtari911 * @param DateTime $date The date to check 155796df7d3eSAtari911 * @param int $ordinalWeek 1-5 for first-fifth, -1 for last 155896df7d3eSAtari911 * @param int $targetDayOfWeek 0=Sunday through 6=Saturday 155996df7d3eSAtari911 * @return bool 156096df7d3eSAtari911 */ 156196df7d3eSAtari911 private function isOrdinalWeekday($date, $ordinalWeek, $targetDayOfWeek) { 156296df7d3eSAtari911 $currentDayOfWeek = (int)$date->format('w'); 156396df7d3eSAtari911 156496df7d3eSAtari911 // First, check if it's the right day of week 156596df7d3eSAtari911 if ($currentDayOfWeek !== $targetDayOfWeek) { 156696df7d3eSAtari911 return false; 156796df7d3eSAtari911 } 156896df7d3eSAtari911 156996df7d3eSAtari911 $dayOfMonth = (int)$date->format('j'); 157096df7d3eSAtari911 $daysInMonth = (int)$date->format('t'); 157196df7d3eSAtari911 157296df7d3eSAtari911 if ($ordinalWeek === -1) { 157396df7d3eSAtari911 // Last occurrence: check if there's no more of this weekday in the month 157496df7d3eSAtari911 $daysRemaining = $daysInMonth - $dayOfMonth; 157596df7d3eSAtari911 return $daysRemaining < 7; 157696df7d3eSAtari911 } else { 157796df7d3eSAtari911 // Nth occurrence: check which occurrence this is 157896df7d3eSAtari911 $weekNumber = ceil($dayOfMonth / 7); 157996df7d3eSAtari911 return $weekNumber === $ordinalWeek; 158096df7d3eSAtari911 } 158187ac9bf3SAtari911 } 158287ac9bf3SAtari911 158319378907SAtari911 public function addAssets(Doku_Event $event, $param) { 158419378907SAtari911 $event->data['link'][] = array( 158519378907SAtari911 'type' => 'text/css', 158619378907SAtari911 'rel' => 'stylesheet', 158719378907SAtari911 'href' => DOKU_BASE . 'lib/plugins/calendar/style.css' 158819378907SAtari911 ); 158919378907SAtari911 159096df7d3eSAtari911 // Load the main calendar JavaScript 159196df7d3eSAtari911 // Note: script.js is intentionally empty to avoid DokuWiki's auto-concatenation issues 159296df7d3eSAtari911 // The actual code is in calendar-main.js 159319378907SAtari911 $event->data['script'][] = array( 159419378907SAtari911 'type' => 'text/javascript', 159596df7d3eSAtari911 'src' => DOKU_BASE . 'lib/plugins/calendar/calendar-main.js' 159619378907SAtari911 ); 159719378907SAtari911 } 1598e3a9f44cSAtari911 // Helper function to find an event's stored namespace 1599e3a9f44cSAtari911 private function findEventNamespace($eventId, $date, $searchNamespace) { 1600e3a9f44cSAtari911 list($year, $month, $day) = explode('-', $date); 1601e3a9f44cSAtari911 1602e3a9f44cSAtari911 // List of namespaces to check 1603e3a9f44cSAtari911 $namespacesToCheck = ['']; 1604e3a9f44cSAtari911 1605e3a9f44cSAtari911 // If searchNamespace is a wildcard or multi, we need to search multiple locations 1606e3a9f44cSAtari911 if (!empty($searchNamespace)) { 1607e3a9f44cSAtari911 if (strpos($searchNamespace, ';') !== false) { 1608e3a9f44cSAtari911 // Multi-namespace - check each one 1609e3a9f44cSAtari911 $namespacesToCheck = array_map('trim', explode(';', $searchNamespace)); 1610e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1611e3a9f44cSAtari911 } elseif (strpos($searchNamespace, '*') !== false) { 1612e3a9f44cSAtari911 // Wildcard - need to scan directories 1613e3a9f44cSAtari911 $baseNs = trim(str_replace('*', '', $searchNamespace), ':'); 1614e3a9f44cSAtari911 $namespacesToCheck = $this->findAllNamespaces($baseNs); 1615e3a9f44cSAtari911 $namespacesToCheck[] = ''; // Also check default 1616e3a9f44cSAtari911 } else { 1617e3a9f44cSAtari911 // Single namespace 1618e3a9f44cSAtari911 $namespacesToCheck = [$searchNamespace, '']; // Check specified and default 1619e3a9f44cSAtari911 } 1620e3a9f44cSAtari911 } 1621e3a9f44cSAtari911 162296df7d3eSAtari911 $this->debugLog("findEventNamespace: Looking for eventId='$eventId' on date='$date' in namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespacesToCheck))); 162396df7d3eSAtari911 1624e3a9f44cSAtari911 // Search for the event in all possible namespaces 1625e3a9f44cSAtari911 foreach ($namespacesToCheck as $ns) { 1626e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 1627e3a9f44cSAtari911 if ($ns) { 1628e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $ns) . '/'; 1629e3a9f44cSAtari911 } 1630e3a9f44cSAtari911 $dataDir .= 'calendar/'; 1631e3a9f44cSAtari911 1632e3a9f44cSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 1633e3a9f44cSAtari911 1634e3a9f44cSAtari911 if (file_exists($eventFile)) { 1635e3a9f44cSAtari911 $events = json_decode(file_get_contents($eventFile), true); 1636e3a9f44cSAtari911 if (isset($events[$date])) { 1637e3a9f44cSAtari911 foreach ($events[$date] as $evt) { 1638e3a9f44cSAtari911 if ($evt['id'] === $eventId) { 163996df7d3eSAtari911 // IMPORTANT: Return the DIRECTORY namespace ($ns), not the stored namespace 164096df7d3eSAtari911 // The directory is what matters for deletion - that's where the file actually is 164196df7d3eSAtari911 $this->debugLog("findEventNamespace: FOUND event in file=$eventFile (dir namespace='$ns', stored namespace='" . ($evt['namespace'] ?? 'NOT SET') . "')"); 164296df7d3eSAtari911 return $ns; 1643e3a9f44cSAtari911 } 1644e3a9f44cSAtari911 } 1645e3a9f44cSAtari911 } 1646e3a9f44cSAtari911 } 1647e3a9f44cSAtari911 } 1648e3a9f44cSAtari911 164996df7d3eSAtari911 $this->debugLog("findEventNamespace: Event NOT FOUND in any namespace"); 1650e3a9f44cSAtari911 return null; // Event not found 1651e3a9f44cSAtari911 } 1652e3a9f44cSAtari911 1653e3a9f44cSAtari911 // Helper to find all namespaces under a base namespace 1654e3a9f44cSAtari911 private function findAllNamespaces($baseNamespace) { 1655e3a9f44cSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 1656e3a9f44cSAtari911 if ($baseNamespace) { 1657e3a9f44cSAtari911 $dataDir .= str_replace(':', '/', $baseNamespace) . '/'; 1658e3a9f44cSAtari911 } 1659e3a9f44cSAtari911 1660e3a9f44cSAtari911 $namespaces = []; 1661e3a9f44cSAtari911 if ($baseNamespace) { 1662e3a9f44cSAtari911 $namespaces[] = $baseNamespace; 1663e3a9f44cSAtari911 } 1664e3a9f44cSAtari911 1665e3a9f44cSAtari911 $this->scanForNamespaces($dataDir, $baseNamespace, $namespaces); 1666e3a9f44cSAtari911 166796df7d3eSAtari911 $this->debugLog("findAllNamespaces: baseNamespace='$baseNamespace', found " . count($namespaces) . " namespaces: " . implode(', ', array_map(function($n) { return $n === '' ? '(default)' : $n; }, $namespaces))); 166896df7d3eSAtari911 1669e3a9f44cSAtari911 return $namespaces; 1670e3a9f44cSAtari911 } 1671e3a9f44cSAtari911 1672e3a9f44cSAtari911 // Recursive scan for namespaces 1673e3a9f44cSAtari911 private function scanForNamespaces($dir, $baseNamespace, &$namespaces) { 1674e3a9f44cSAtari911 if (!is_dir($dir)) return; 1675e3a9f44cSAtari911 1676e3a9f44cSAtari911 $items = scandir($dir); 1677e3a9f44cSAtari911 foreach ($items as $item) { 1678e3a9f44cSAtari911 if ($item === '.' || $item === '..' || $item === 'calendar') continue; 1679e3a9f44cSAtari911 1680e3a9f44cSAtari911 $path = $dir . $item; 1681e3a9f44cSAtari911 if (is_dir($path)) { 1682e3a9f44cSAtari911 $namespace = $baseNamespace ? $baseNamespace . ':' . $item : $item; 1683e3a9f44cSAtari911 $namespaces[] = $namespace; 1684e3a9f44cSAtari911 $this->scanForNamespaces($path . '/', $namespace, $namespaces); 1685e3a9f44cSAtari911 } 1686e3a9f44cSAtari911 } 1687e3a9f44cSAtari911 } 16889ccd446eSAtari911 16899ccd446eSAtari911 /** 16909ccd446eSAtari911 * Delete all instances of a recurring event across all months 16919ccd446eSAtari911 */ 16929ccd446eSAtari911 private function deleteAllRecurringInstances($recurringId, $namespace, $dataDir) { 16939ccd446eSAtari911 // Scan all JSON files in the calendar directory 16949ccd446eSAtari911 $calendarFiles = glob($dataDir . '*.json'); 16959ccd446eSAtari911 16969ccd446eSAtari911 foreach ($calendarFiles as $file) { 16979ccd446eSAtari911 $modified = false; 16989ccd446eSAtari911 $events = json_decode(file_get_contents($file), true); 16999ccd446eSAtari911 17009ccd446eSAtari911 if (!$events) continue; 17019ccd446eSAtari911 17029ccd446eSAtari911 // Check each date in the file 17039ccd446eSAtari911 foreach ($events as $date => &$dayEvents) { 17049ccd446eSAtari911 // Filter out events with matching recurringId 17059ccd446eSAtari911 $originalCount = count($dayEvents); 17069ccd446eSAtari911 $dayEvents = array_values(array_filter($dayEvents, function($event) use ($recurringId) { 17079ccd446eSAtari911 $eventRecurringId = isset($event['recurringId']) ? $event['recurringId'] : null; 17089ccd446eSAtari911 return $eventRecurringId !== $recurringId; 17099ccd446eSAtari911 })); 17109ccd446eSAtari911 17119ccd446eSAtari911 if (count($dayEvents) !== $originalCount) { 17129ccd446eSAtari911 $modified = true; 17139ccd446eSAtari911 } 17149ccd446eSAtari911 17159ccd446eSAtari911 // Remove empty dates 17169ccd446eSAtari911 if (empty($dayEvents)) { 17179ccd446eSAtari911 unset($events[$date]); 17189ccd446eSAtari911 } 17199ccd446eSAtari911 } 17209ccd446eSAtari911 17219ccd446eSAtari911 // Save if modified 17229ccd446eSAtari911 if ($modified) { 1723*815440faSAtari911 CalendarFileHandler::writeJson($file, $events); 17249ccd446eSAtari911 } 17259ccd446eSAtari911 } 17269ccd446eSAtari911 } 17279ccd446eSAtari911 17289ccd446eSAtari911 /** 17299ccd446eSAtari911 * Get existing event data for preserving unchanged fields during edit 17309ccd446eSAtari911 */ 17319ccd446eSAtari911 private function getExistingEventData($eventId, $date, $namespace) { 17329ccd446eSAtari911 list($year, $month, $day) = explode('-', $date); 17339ccd446eSAtari911 17349ccd446eSAtari911 $dataDir = DOKU_INC . 'data/meta/'; 17359ccd446eSAtari911 if ($namespace) { 17369ccd446eSAtari911 $dataDir .= str_replace(':', '/', $namespace) . '/'; 17379ccd446eSAtari911 } 17389ccd446eSAtari911 $dataDir .= 'calendar/'; 17399ccd446eSAtari911 17409ccd446eSAtari911 $eventFile = $dataDir . sprintf('%04d-%02d.json', $year, $month); 17419ccd446eSAtari911 17429ccd446eSAtari911 if (!file_exists($eventFile)) { 17439ccd446eSAtari911 return null; 17449ccd446eSAtari911 } 17459ccd446eSAtari911 17469ccd446eSAtari911 $events = json_decode(file_get_contents($eventFile), true); 17479ccd446eSAtari911 17489ccd446eSAtari911 if (!isset($events[$date])) { 17499ccd446eSAtari911 return null; 17509ccd446eSAtari911 } 17519ccd446eSAtari911 17529ccd446eSAtari911 // Find the event by ID 17539ccd446eSAtari911 foreach ($events[$date] as $event) { 17549ccd446eSAtari911 if ($event['id'] === $eventId) { 17559ccd446eSAtari911 return $event; 17569ccd446eSAtari911 } 17579ccd446eSAtari911 } 17589ccd446eSAtari911 17599ccd446eSAtari911 return null; 17609ccd446eSAtari911 } 176119378907SAtari911} 1762